Things Zig comptime won't do
To me, the uniqueness of Zig's comptime is a combination of two things:
1. comtpime replaces many other features that would be specialised in other languages with or without rich compile-time (or runtime) metaprogramming, and
2. comptime is referentially transparent[1], that makes it strictly "weaker" than AST macros, but simpler to understand; what's surprising is just how capable you can be with a comptime mechanism with access to introspection yet without the referentially opaque power of macros.
These two give Zig a unique combination of simplicity and power. We're used to seeing things like that in Scheme and other Lisps, but the approach in Zig is very different. The outcome isn't as general as in Lisp, but it's powerful enough while keeping code easier to understand.
You can like it or not, but it is very interesting and very novel (the novelty isn't in the feature itself, but in the place it has in the language). Languages with a novel design and approach that you can learn in a couple of days are quite rare.
[1] In short, this means that you get no access to names or expressions, only the values they yield.
One thing it is used for is generating string literals, which then can be fed to the compiler. This takes the place of macros.
CTFE is one of D's most popular and loved features.
If the presence of features is how we judge design, then the product with the most features would be considered the best design. Of course, often the opposite is the case. The absence of features is just as crucial for design as their presence. It's like saying that a device with a touchscreen and a physical keyboard has essentially the same properties as a device with only a touchscreen.
If a language has a mechanism that can do exactly what Zig's comptime does but it also has generics or templates, macros, and/or conditional compilation, then it doesn't have anything resembling Zig's comptime.
Various compile-time evaluations are not even remotely positioned in D, Nim, or C++ as they are in Zig.
See my other reply. I don't understand your comment.
Let me put it like this: Zig's comptime is a general compilation time computation mechanism that has introspection capabilities and replaces generics/templates, interfaces/typeclasses, macros, and conditional compilation.
It's like that the main design feature of some devices is that they have a touchscreen but not a keyboard. The novelty isn't the touchscreen; it's in the touchscreen eliminating the keyboard. The touchscreen itself doesn't have to be novel; the novelty is how it's used to eliminate the keyboard. If your device has a touchscreen and a keyboard, then it does not have the same design feature.
Zig's novel comptime is a mechanism that eliminates other specialised features, and if these features are still present, then your language doesn't have Zig's comptime. It has a touchscreen and a keyboard, whereas Zig's novelty is a touchscreen without a keyboard.
The irony here is back in the 2000's, many programmers were put off by C++ templates, and found them to be confusing. Myself included. But when I (belatedly) realized that function templates were functions with compile time parameters, I had an epiphany:
Don't call them templates! Call them functions with compile time parameters. The people who were confused by templates understood that immediately. Then later, after realizing that they had been using templates all along, became comfortable with templates.
BTW, I wholeheartedly agree that it is better to have a small set of features that can do the same thing as a larger set of features. But I'm not seeing how comptime is accomplishing that.
But I'm not seeing how comptime is accomplishing that.
Because Zig does the work of C++'s templates, macros, conditional compilation, constexprs, and concepts with one relatively simple feature.
fn print(comptime T: type, value: T) void {
That's a template. In D it looks like: void print(T)(T value) {
which is also a template.In Zig I can pass an entire struct instance full of config values as a single comptime parameter and thread it anywhere in my program. The big difference here is that when you treat compile-time programming as a "special" thing that is supported completely differently in the language, you need to add these features in a painfully piecemeal way. Whereas if it's just re-using the machinery already in place in your language, these restrictions don't exist and your users don't need to look up what values can be comptime values...they're just another kind of thing I pass to functions, so "of course" I can pass a struct instance.
Zig has almost no restrictions on what kinds of values can be `comptime` parameters.
Neither does D. The main restriction is the CTFE needs to be pure. I.e. you cannot call the operating system in CTFE (this is a deliberate restriction, mainly to avoid clever malware).
CTFE isn't "special" in D, either. CTFE is triggered for any instance of a "constant expression" in the grammar, and doesn't require a keyword.
“In contrast, there’s absolutely no facility for dynamic source code generation in Zig. You just can’t do that, the feature isn’t! [sic]
Zig has a completely different feature, partial evaluation/specialization, which, none the less, is enough to cover most of use-cases for dynamic code generation.”
fn f(comptime x: u32, y: u32) u32 {
if (x == 0) return y + 1;
if (x == 1) return y * 2;
return y;
}
and in D: uint f(uint x)(uint y) {
if (x == 0) return y + 1;
if (x == 1) return y * 2;
return y;
}
The two parameter lists make it a function template, the first set of parameters are the template parameters, which are compile time. The second set are the runtime parameters. The compile time parameters can also be types, and aliased symbols.I bet if you use such a function with different comptime arguments, compile it, and dump the assembler you'll see that function appearing multiple times, each with somewhat different code generated for it.
Zig's use of comptime in a function argument makes it a template :-/
That you can draw an isomorphism between two things does not mean they are ergonomically identical.
for Zig enthusiasts, the set of features that Zig has appeals to them. Just like enthusiasts of every programming language.
I find it rather amusing that it's a Java and a Rust enthusiast who are extolling Zig approach here! I am not particularly well read with respect to programming languages, but I don't recall many languages which define generic pair as
fn Pair(A: type, B: type) type {
return struct { fst: A, snd: B };
}
The only one that comes to mind is 1ML, and I'd argue that it is also revolutionary.I think the innovation here is imperative type-level programming--languages that support type-level programming are typically functional languages, or functional languages at the type level. Certainly interesting, but not revolutionary IMO.
Pair :: type -> type -> type
let Pair a b = product a b
This is one half of the innovation, dependent-types lite.The second half is how every other major feature is expressed _directly_ via comptime/partial evaluation, not even syntax sugar is necessary. Generic, macros, and conditional compilation are the three big ones.
This is one half of the innovation, dependent-types lite.
But that's not dependent types. Dependent types are types that depend on values. If all the arguments to a function are either types or values, then you don't have dependent types: you have kind polymorphism, as implemented for example in GHC extensions[1].
The second half is how every other major feature is expressed _directly_ via comptime/partial evaluation, not even syntax sugar is necessary. Generic, macros, and conditional compilation are the three big ones.
I'd argue that not having syntactic sugar is pretty minor, but reasonable people can differ I suppose.
[1] https://ghc.gitlab.haskell.org/ghc/doc/users_guide/exts/poly...
Dependent types are types that depend on values.
Like this?
fn f(comptime x: bool) if (x) u32 else bool {
return if (x) 0 else false;
}
Show me the language that used a general purpose compile-time mechanisms to avoid specialised features such as generics/templates, interfaces/typeclasses, macros, and conditional compilation before Zig, then I'll say that language was revolutionary.
I also find it hard to believe that you can't see how replacing all these features with a single one (that isn't AST macros) is novel. I'm not saying you have to think it's a good idea -- that's a matter of personal taste (at least until we can collect more objective data) -- but it's clearly novel.
I don't know all the languages in the world and it's possible there was a language that did that before Zig, but none of the languages mentioned here did. Of course, it's possible that no other language did that because it's stupid, but that doesn't mean it's not novel (especially as the outcome does not appear stupid on the face of it).
If you say that incomplete implementations count, then I could argue that the C preprocessor subsumes generics/templates, interfaces/typeclasses†, macros, and conditional compilation.
†Exercise for the reader: build a generics system in the C preprocessor that #error's out if the wrong type is passed using the trick in[1].
But Zig's comptime only approximates the features you mentioned; it doesn't fully implement them
That's like saying that a touchscreen device without a keyboard only approximates a keyboard but doesn't fully implement one. The important thing is that the feature performs the duty of those other features.
If you say that incomplete implementations count, then I could argue that the C preprocessor subsumes generics/templates, interfaces/typeclasses†, macros, and conditional compilation.
There are two problems with this, even if we assumed that the power of C's preprocessor is completely equivalent to Zig's comptime:
First, C's preprocessor is a distinct meta-language; one major point of Zig's comptime is that the metalanguage is the same language as the object language.
Second, it's unsurprising that macros -- whether they're more sophisticated or less -- can do the role of all those other features. As I wrote in my original comment (https://news.ycombinator.com/item?id=43745438) one of the exciting things about Zig is that a feature that isn't macros (and is strictly weaker than macros, as it's referentially transparent) can replace them for the most part, while enjoying a greater ease of understanding.
I remember that one of my first impressions of Zig was that it evoked the magic of Lisp (at least that was my gut feeling), but in a completely different way, one that doesn't involve AST manipulation, and doesn't suffer from many of the problems that make List macros problematic (i.e. creating DSLs with their own rules). I'm not saying it may not have other problems, but that is very novel.
I hadn't seen any such fresh designs in well over a decade. Now, it could be that I simply don't know enough languages, but you also haven't named other languages that work on this design principle, so I think my excitement was warranted. I'll let you know if I think that's not only a fresh and exciting design but also a good one in ten years.
BTW, I have no problem with you finding Zig's comptime unappealing to your tastes or even believing it suffers from fundamental issues that may prove problematic in practice (although personally I think that, when considering both pros and cons of this design versus the alternatives, there's some promise here). I just don't understand how you can say that the design isn't novel while not naming one other language with a similar core design: a mechanism for partial evaluation of the object language (with access to additional reflective operations) that replace those other features I mentioned (by performing their duty, if not exactly their mode of operation).
For example, I've looked at Terra, but it makes a distinction between the meta language and the object (or "runtime") language.
The important thing is that the feature performs the duty of those other features.
Zig's comptime doesn't do everything that Rust (or Java, or C#, or Swift, etc.) generics do, and I know you know this given your background in type theory. Zig doesn't allow for the inference and type-directed method resolution that Rust or the above languages do, because the "generics" that you create using Zig comptime aren't typechecked until they're instantiated. You can improve the error messages using "comptime if" or whatever Zig calls it (at the cost of a lot of ergonomics), but the compiler still can't reliably typecheck the bodies of generic functions before the compiler does the comptime evaluation.
Now I imagine you think that this feature doesn't matter, or at least doesn't matter enough to be worth the complexity it adds to the compiler. (I disagree, of course, because I find reliable IDE autocomplete and inline error messages to be enormously useful when writing generic Rust functions.) But that's the entire point: Zig comptime is not performing the duty of generics; it's approximating generics in a way that offers a tradeoff.
When I first looked at Zig comptime, it didn't evoke the "magic of Lisp" at all in me (and I do share an appreciation of simplicity in programming languages, though I feel like Scheme offers more of that than Lisp). Rather, my reaction was "oh, this is basically just what D does", having played with D a decent amount in years prior. Nothing I've seen in the intervening years has changed that impression. Zig's metaprogramming features are a spin on metaprogramming facilities that D thoroughly explored over a decade before Zig came on the scene.
Edit: Here's an experiment. Start with D and start removing features: GC, the class system, exceptions, etc. etc. Do you get to something that's more or less Zig modulo syntax? From what I can tell, you do. That's what I mean by "not revolutionary".
Zig doesn't allow for the inference and type-directed method resolution that Rust or the above languages do
Well, but Zig also doesn't allow for overloads and always opts for explicitness regardless of comptime, so I would say that's consonant with the rest of the design.
Now I imagine you think that this feature doesn't matter, or at least doesn't matter enough to be worth the complexity it adds to the compiler.
I don't care too much about the complexity of the compiler (except in how compilation times are affected), but I do care about the complexity of the language. And yes, there are obviously tradeoffs here, but they're not the same tradeoffs as C++ templates and I think it's refreshing. I can't yet tell how "good" the tradeoff is.
Here's an experiment. Start with D and start removing features: GC, the class system, exceptions, etc. etc. Do you get to something that's more or less Zig modulo syntax?
I don't know D well enough to tell. I'd probably start by looking at how D would do this[1]: https://ziglang.org/documentation/master/#Case-Study-print-i...
For instance, the notion of a comptime variable (for which I couldn't find an analogue in D) is essential to the point that the "metalanguage" and the object language are pretty much the same language.
Interestingly, in Zig, the "metalanguage" is closer to being a superset of the object language whereas in other languages with compile-time phases, the metalanguage, if not distinct, is closer to being a subset. I think Terra is an interesting point of comparison, because there, while distinct, the metalanguage is also very rich.
[1] which, to me, gives the "magical Lisp feeling" except without macros.
the notion of a comptime variable (for which I couldn't find an analogue in D)
A comptime variable in D would look like:
enum v = foo(3);
Since an enum initialization is a ConstExpression, it's initialization must be evaluated at compile time.A comptime function parameter in D looks like:
int mars(int x)(int y) { ... }
where the first parameter list consists of compile time parameters, and the second the runtime parameters.D does not have a switch-over-types statement, but the equivalent can be done with a sequence of static-if statements:
static if (is(T == int)) { ... }
else static if (is(T == float)) { ... }
Static If is always evaluated at compile time. The IsExpression does pattern matching on types.This is one of the things that allow the "comptime language" to just be Zig, as in this example: https://ziglang.org/documentation/master/#Case-Study-print-i...
Anyway, I found this article that concludes that D's compile time evaluation is equivalent in power to Zig's, although it also doesn't cover how comptime variables can be used in Zig: https://renato.athaydes.com/posts/comptime-programming
However, as I've said many times, knowing about the theoretical power of partial evaluation, what excites me in Zig isn't what comptime can do (although I am impressed with the syntactic elegance of the mechanism) but how it is used to avoid adding other features.
A phone with a touchscreen is evolutionary; a phone without a keypad is revolutionary. The revolution is in the unique experience of using "just comptime" for many things.
It is, of course, a tradeoff, and whether or not that tradeoff is "good" remains to be seen, but I think this design is one of the most novel designs in programming languages in many, many years.
I'm not saying it may not have other problems, but that is very novel.
Just to explicitly acknowledge this, it inherits the C++ problem that you don't get type errors inside a function until you call the function and, when that happens, its not always immediately obvious whether the problem is in the caller or in the callee.
Picking some features while leaving others out is something that every language does; if doing that is enough to make a language "revolutionary", then every language is revolutionary.
Picking a set of well motivated and orthogonal features that combine well in flexible ways is definitely enough to be revolutionary if that combination permits expressive programming in ways that used to be unwieldy, error-prone or redundant, eg. "redundant" in the sense that you have multiple ways of expressing the same thing in overlapping but possibly incompatible ways. It doesn't follow that every language must be revolutionary just because they pick features too, there are conditions to qualify.
For systems programming, I think Zig is revolutionary. I don't think any other language matches Zig's cross-compilation, cross-platform and metaprogramming story in such a simple package. And I don't even use Zig, I'm just a programming language theory enthusiast.
I agree with Walter: Zig isn't doing anything novel.
"Novel" is relative. Anyone familiar with MetaOCaml wouldn't have seen Zig as particularly novel in a theoretical sense, as comptime is effectively a restricted multistage language. It's definitely revolutionary for an industry language though. I think D has too much baggage to qualify, even if many Zig expressions have translations into D.
Picking some features while leaving others out is something that every language does; if doing that is enough to make a language "revolutionary", then every language is revolutionary.
You can say that about the design of any product. Yet, once in a while, we get revolutionary designs (even if every feature in isolation is not completely novel) when the choice of what to include and what to leave out is radically different from other products in the same category in a way that creates a unique experience.
Zig has a library to that allows you to have an AoS that is laid out in memory like a SoA: https://zig.news/kristoff/struct-of-arrays-soa-in-zig-easy-i... . If you read the implementation (https://github.com/ziglang/zig/blob/master/lib/std/multi_arr...) the SoA is an elaborately specialized type, parameterized on a struct type that it introspects at compile time.
It’s neat because one might reach for macros for this sort of the thing (and I’d expect the implementation to be quite complex, if it’s even possible) but the details of Zig’s comptime—you can inspect the fields of the type parameter struct, and the SoA can be highly flexible about its own fields—mean that you don’t need a macro system, and the Zig implementation is actually simpler than a macro approach probably would be.
The argument that the author of TFA is making is that Zig’s comptime is a very limited feature (which, they argue, is good. It restricts users from introducing architecture dependencies/cross-compilation bugs, is more amenable to optimization, etc), and yet it allows users to do most of the things that more general alternatives (such as code generation or a macro system) are often used for.
In other words, while Zig of course didn’t invent compile-time functions (see lisp macros), it is notable and useful from a PL perspective if Zig users are doing things that seem to require macros or code generation without actually having those features. D users, in contrast, do have code generation.
Or, alternatively, while many languages support metaprogramming of some kind, Zig’s metaprogramming language is at a unique maxima of safety (which macros and code generation lack) and utility (which e.g. Java/Go runtime reflection, which couldn’t do the AoS/SoA thing, lack)
Edit Ok, I think Zig comptime expressions are just like D templates, like you said. The syntax is nicer than C++ templates. Zig’s “No host leakage” (to guarantee cross-compile-ability) looks like the one possibly substantively different thing.
Zig’s “No host leakage” (to guarantee cross-compile-ability) looks like the one possibly substantively different thing.
That is a good idea, but could be problematic if one relies on size_t, which changes in size from 32 to 64 bit. D's CTFE adds checks for undefined behavior, such as shifting by more bits than are in the type being shifted. These checks are not done at runtime for performance reasons.
D's CTFE also does not allow calling the operating system, and only works on functions that are "pure".
enum arr = { return iota(5).map!(i => i * 10).array; }();
static assert(arr == [0,10,20,30,40]);
All living languages accrete features over time. D started out as a much more modest language. It originally eschewed templates and operator overloading, for example.
Some features were abandoned, too, like complex numbers and the "bit" data type.
D pioneered compile time function execution (CTFE) back around 2007
Pioneered? Forth had that in the 1970s, lisp somewhere in the 1960s (I’m not sure whether the first versions of either had it, so I won’t say 1970 respectively 1960), and there may be other or even older examples.
BTW, D's ImportC C compiler does CTFE, too!! CTFE is a natural fit for C, and works like a champ. Standard C should embrace it.
"Steve Russell said, look, why don't I program this eval ... and I said to him, ho, ho, you're confusing theory with practice, this eval is intended for reading, not for computing. But he went ahead and did it. That is, he compiled the eval in my paper into IBM 704 machine code, fixing bugs, and then advertised this as a Lisp interpreter, which it certainly was. So at that point Lisp had essentially the form that it has today”
It’s very cool to be able to just say “Y is just X”. You know in a museum. Or at a distance. Not necessarily as something you have to work with daily. Because I would rather take something ranging from Java’s interface to Haskell’s typeclasses since once implemented, they’ll just work. With comptime types, according to what I’ve read, you’ll have to bring your T to the comptime and find out right then and there if it will work. Without enough foresight it might not.
That’s not something I want. I just want generics or parametric polymorphism or whatever it is to work once it compiles. If there’s a <T> I want to slot in T without any surprises. And whether Y is just X is a very distant priority at that point. Another distant priority is if generics and whatever else is all just X undernea... I mean just let me use the language declaratively.
I felt like I was on the idealistic end of the spectrum when I saw you criticizing other languages that are not installed on 3 billion devices as too academic.[2] Now I’m not so sure?
[1] https://news.ycombinator.com/item?id=24292760
[2] But does Scala technically count since it’s on the JVM though?
I wait 10-15 years before judging if a feature is "good"; determining that a feature is bad is usually quicker.
With comptime types, according to what I’ve read, you’ll have to bring your T to the comptime and find out right then and there if it will work. Without enough foresight it might not.
But the point is that all that is done at compile time, which is also the time when all more specialised features are checked.
That’s not something I want. I just want generics or parametric polymorphism or whatever it is to work once it compiles.
Again, everything is checked at compile-time. Once it compiles it will work just like generics.
I mean just let me use the language declaratively.
That's fine and expected. I believe that most language preferences are aesthetic, and there have been few objective reasons to prefer some designs over others, and usually it's a matter of personal preference or "extra-linguistic" concerns, such as availability of developers and libraries, maturity, etc..
Now I’m not so sure?
Personally, I wouldn't dream of using Zig or Rust for important software because they're so unproven. But I do find novel designs fascinating. Some even match my own aesthetic preferences.
But the point is that all that is done at compile time, which is also the time when all more specialised features are checked....
Again, everything is checked at compile-time. Once it compiles it will work just like generics.
No. My compile-time when using a library with a comptime type in Zig is not guaranteed to work because my user experience could depend on if the library writer tested with the types (or compile-time input) that I am using.[1] That’s not a problem in Java or Haskell: if the library works for Mary it will work for John no matter what the type-inputs are.
That's fine and expected. I believe that most language preferences are aesthetic, and there have been few objective reasons to prefer some designs over others, and usually it's a matter of personal preference or "extra-linguistic" concerns, such as availability of developers and libraries, maturity, etc..
Please don’t retreat to aesthetics. What I brought up is a concrete and objective user experience tradeoff.
[1] based on https://strongly-typed-thoughts.net/blog/zig-2025#comptime-i...
No. My compile-time when using a library with a comptime type in Zig is not guaranteed to work because my user experience could depend on if the library writer tested with the types (or compile-time input) that I am using.[1] That’s not a problem in Java or Haskell: if the library works for Mary it will work for John no matter what the type-inputs are.
What you're saying isn't very meaningful. Even generics may impose restrictions on their type parameters (e.g. typeclasses in Zig or type bounds in Java) and don't necessarily work for all types. In both cases you know at compile-time whether your types fit the bounds or not.
It is true that the restrictions in Haskell/Java are more declarative, but the distinction is more a matter of personal aesthetic preference, which is exactly what's expressed in that blog post (although comptime is about as different from C++ templates as it is from Haskell/Java generics). Like anything, and especially truly novel approaches, it's not for everyone's tastes, but neither are Java, Haskell, or Rust, for that matter. That doesn't make Zig's approach any less novel or interesting, even if you don't like it. I find Rust's design unpalatable, but that doesn't mean it's not interesting or impressive, and Zig's approach -- again, like it or not -- is even more novel.
What you're saying isn't very meaningful. Even generics may impose restrictions on their type parameters (e.g. typeclasses in Zig or type bounds in Java) and don't necessarily work for all types. In both cases you know at compile-time whether your types fit the bounds or not.
Java type-bounds is what I mean with declarative. The library author wrote them, I know them, I have to follow them. It’s all spelled out. According to the link that’s not the case with the Zig comptime machinery. It’s effectively duck-typed from the point of view of the client (declaration).
I also had another source in mind which explicitly described how Zig comptime is “duck typed” but I can’t seem to find it. Really annoying.
It is true that the restrictions in Haskell/Java are more declarative, but the distinction is more a matter of personal aesthetic preference, which is exactly what's expressed in that blog post (although comptime is about as different from C++ templates as it is from Haskell/Java generics).
It’s about as aesthetic as having spelled out reasons (usability) for preferring static typing over dynamic typing or vice versa. It’s really not. At all.
, but that doesn't mean it's not interesting or impressive, and Zig's approach -- again, like it or not -- is even more novel.
I prefer meaningful leaps forward in programming language usability over supposed most-streamlined and clever approaches (comptime all the way down). I guess I’m just a pragmatist in that very narrow area.
According to the link that’s not the case with the Zig comptime machinery. It’s effectively duck-typed from the point of view of the client (declaration).
It is "duck-typed", but it is checked at compile time. Unlike ducktyping in JS, you know whether or not your type is a valid argument just as you would for Java type bounds -- the compiler lets you know. Everything is also all spelled out, just in a different way.
It’s about as aesthetic as having spelled out reasons (usability) for preferring static typing over dynamic typing or vice versa. It’s really not. At all.
But everything is checked statically, so all the arguments of failing fast apply here, too.
I prefer meaningful leaps forward in programming language usability over supposed most-streamlined and clever approaches (comptime all the way down). I guess I’m just a pragmatist in that very narrow area.
We haven't had "meaningful leaps forward in programming language usability" in a very long time (and there are fundamental reasons for that, and indeed the situation was predicted decades ago). But if we were to have a meaningful leap forward, first we'd need some leap forward and then we could try learning how meaningful it is (which usually takes a very long time). I don't know that Zig's comptime is a meaningful leap forward or not, but as one of the most novel innovations in programming languages in a very long time, at least it's something that's worth a look.
It is "duck-typed", but it is checked at compile time. Unlike ducktyping in JS, you know whether or not your type is a valid argument just as you would for Java type bounds -- the compiler lets you know. Everything is also all spelled out, just in a different way.
At this point I will have to defer to Zig users.
But the wider point stands whether I am correct about Zig usability or not (mostly leaning on the aforelinked URLs). Plenty of things can be compile-time and yet have widely different usability. Something that relies on unconstrained build-time code generation can be much harder to use than macros, which in turn can be harder to use than something like “constant expressions”, and so on.
Which I already said in my original comment. But here’s a source that I didn’t find last time: https://strongly-typed-thoughts.net/blog/zig-2025#comptime-i...
Academics have thought about evaluating things at compile time (or any time) for decades. No, you can’t just slot in eval at a weird place that no one ever thought of (they did) and immediately solve a suite of problems that other languages use multiple discrete features for (there’s a reason they do that).
comptime can’t outright replace many language features because it chooses different tradeoffs to get to where it wants.
You're missing the point. I don't have any theory to qualify this, but:
I've worked in a language with lisp-ey macros, and I absolutely hate hate hate when people build too-clever DSLs that hide a lot of weird shit like creating variable names or pluralizing database tables for me, swapping camel-case and snake case, creating a ton of logic under the hood that's hard to chase.
Zig's comptime for the most part shys you away from those sorts of things. So yes, it's not fully feature parity in the language theory sense, but it really blocks you or discourages you away from shit you don't need to do, please for the love of god don't. Hard to justify theoretically. it's real though.
It's just something you notice after working with it for while.
That you want to make a completely different point about macros gone wild is not my problem.
Without more context, this comment sounds like rehashing old (personal?) drama.
The usual persona is the hard-nosed pragmatist[1] who thinks language choice doesn’t matter and that PL preference is mostly about “programmer enjoyment”.
[1] https://news.ycombinator.com/item?id=16889706
Edit: The original claim might have been skewed. Due to occupation the PL discussions often end up being about Java related things, and the JVM language which is criticized has often been Scala specifically. Here he recommends Kotlin over Scala (not Java): https://news.ycombinator.com/item?id=9948798
Because I would rather take something ranging from Java’s interface to Haskell’s typeclasses since once implemented, they’ll just work. With comptime types, according to what I’ve read, you’ll have to bring your T to the comptime and find out right then and there if it will work. Without enough foresight it might not.
This was perhaps a bad comparison and I should have compared e.g. Java generics to Zig’s comptime T.
If I understand correctly you're using the term in a different (perhaps more correct/original?) sense where it roughly means that two expressions with the same meaning/denotation can be substituted for each other without changing the meaning/denotation of the surrounding program. This property is broken by macros. A macro in Rust, for instance, can distinguish between `1 + 1` and `2`. The comptime system in Zig in contrast does not break this property as it only allows one to inspect values and not un-evaluated ASTs.
The expression `i++` in C is not a value in C (although it is a "value" in some semantic descriptions of C), yet a C expression that contains `i++` and cannot distinguish between `i++` and any other C operation that increments i by 1, is referentially transparent, which is pretty much all C expressions except for those involving C macros.
Macros are not referentially transparent because they can distinguish between, say, a variable whose name is `foo` and is equal to 3 and a variable whose name is `bar` and is equal to 3. In other words, their outcome may differ not just by what is being referenced (3) but also by how it's referenced (`foo` or `bar`), hence they're referentially opaque.
What could be argued, is if Zig's version of it is comparatively better, but that is a very difficult argument to make. Not only in terms of how different languages are used, but something like an overall comparison of features looks to be needed in order to make any kind of convincing case, beyond hyping a particular feature.
My understanding is that Jai, for example, doesn’t do this, and runs comptime code on the host.Many powerful compile-time meta programming systems work by allowing you to inject arbitrary strings into compilation, sort of like #include whose argument is a shell-script that generates the text to include dynamically. For example, D mixins work that way:
And Rust macros, while technically producing a token-tree rather than a string, are more or less the same
the uniqueness of Zig's comptime... > You can like it or not, but it is very interesting and very novel...
While true, such features in Zig can be interesting, they are not particularly novel (as other highly knowledgeable readers have pointed out). Zig's comptime is often marketed or hyped as being special, while overlooking that other languages often do similar, but have their own perspectives and reasoning on how metaprogramming and those type of features fit into their language. Not to mention, metaprogramming has its downsides too. It's not all roses.
The article does seek to make comparisons with other languages, but arguably out of context, as to what those languages are trying to achieve with their feature sets. Comptime should not be looked at in a bubble, but as part of the language as a whole.
A language creator with an interesting take on metaprogramming in general, is Ginger Bill (of Odin). Who often has enthusiasts attempt to pressure him into making more extensive use of it in his language, but he pushes back because of various problems it can cause, and has argued he often comes up with optimal solutions without it. There are different sides to the story, in regards to usage and goals, relative to the various languages being considered.
Perhaps the safety is the tradeoff with the comparative ease of using the language compared to Rust, but I’d love the best of both worlds if it were possible
but I’d love the best of both worlds if it were possible
I am just going to quote what pcwalton said the other day that perhaps answer your question.
I’d be much more excited about that promise [memory safety in Rust] if the compiler provided that safety, rather than asking the programmer to do an extraordinary amount of extra work to conform to syntactically enforced safety rules. Put the complexity in the compiler, dudes.That exists; it's called garbage collection.
If you don't want the performance characteristics of garbage collection, something has to give. Either you sacrifice memory safety or you accept a more restrictive paradigm than GC'd languages give you. For some reason, programming language enthusiasts think that if you think really hard, every issue has some solution out there without any drawbacks at all just waiting to be found. But in fact, creating a system that has zero runtime overhead and unlimited aliasing with a mutable heap is as impossible as finding two even numbers whose sum is odd.
I ask because I am obvious blind to other cases - that's what I'm curious about! I generally find the &s to be a net help even without mem safety ... They make it easier to reason about structure, and when things mutate.
What in particular do you find cumbersome about the borrow system?
The refusal to accept code that the developer knows is correct, simply because it does not fit how the borrow checker wants to see it implemented. That kind of heavy-handed and opinionated supervision is overhead to productivity. (In recent times, others have taken to saying that Rust is less "fun.")
When the purpose of writing code is to solve a problem and not engage in some pedantic or academic exercise, there are much better tools for the job. There are also times when memory safety is not a paramount concern. That makes the overhead of Rust not only unnecessary but also unwelcome.
The refusal to accept code that the developer knows is correct,
How do you know it is correct? Did you prove it with pre-condition, invariants and post-condition? Or did you assume based on prior experience.
This means it rejects, by definition alone, bug-free code because that bug free code uses a pattern that is not acceptable.
IOW, while Rust rejects code with bugs, it also rejects code without bugs.
It's part of the deal when choosing Rust, and people who choose Rust know this upfront and are okay with it.
This means it rejects, by definition alone, bug-free code because that bug free code uses a pattern that is not acceptable.
That is not true by definition alone. It is only true if you add the corollary that the patterns which rustc prevents are sometimes bug-free code.
That is not true by definition alone. It is only true if you add the corollary that the patterns which rustc prevents are sometimes bug-free code.
That corollary is only required in the cases that a pattern is unable to produce bug-free code.
In practice, there isn't a pattern that reliably, 100% of the time and deterministically produces a bug.
How do you know you haven't been writing unsafe code for years, when C unsafe guidelines have like 200 entries[1].
[1]https://www.dii.uchile.cl/~daespino/files/Iso_C_1999_definit... (Annex J.2 page 490)
Writing provable anything is hard because it forces you to think carefully about that. You can no longer reason by going into flow mode, letting fast and incorrect part of the brain take over.
Although the more usual pattern here is to ditch pointers and instead have a giant array of objects referring to each other via indices into said array. But this is effectively working around the borrow checker - those indices are semantically unchecked references, and although out-of-bounds checks will prevent memory corruption, it is possible to store index to some object only for that object to be replaced with something else entirely later.
it is possible to store index to some object only for that object to be replaced with something else entirely later.
That's what generational arenas are for, at the cost of having to check for index validity on every access. But that cost is only in comparison to "keep a pointer in a field" with no additional logic, which is bug-prone.
This is similar to creating a broadly-used data structure and realizing that some field has to be optional. Option<T> will require you to change everything touching it, and virally spread through all the code that wanted to use that field unconditionally. However, that's not the fault of the Option syntax, it's the fault of semantics of optionality. In languages that don't make this "miserable" at compile time, this problem manifests with a whack-a-mole of NullPointerExceptions at run time.
With experience, I don't get this "oh no, now there's a lifetime popping up everywhere" surprise in Rust any more. Whether something is going to be a temporary view or permanent storage can be known ahead of time, and if it can be both, it can be designed with Cow-like types.
I also got a sense for when using a temporary loan is a premature optimization. All data has to be stored somewhere (you can't have a reference to data that hasn't been stored). Designs that try to be ultra-efficient by allowing only temporary references often force data to be stored in a temporary location first, and then borrowed, which doesn't avoid any allocations, only adds dependencies on external storage. Instead, the design can support moving or collecting data into owned (non-temporary) storage directly. It can then keep it for an arbirary lifetime without lifetime annotations, and hand out temporary references to it whenever needed. The run-time cost can be the same, but the semantics are much easier to work with.
The aliasing rules in Rust are also pretty strict. There are plenty of single-threaded programs where I want to be able to occasionally read a piece of information through an immutable reference, but that information can be modified by a different piece of code. This usually indicates a design issue in your program but sometimes you just want to throw together some code to solve an immediate problem. The extra friction from the borrow checker makes it less attractive to use Rust for these kinds of programs.
There are plenty of single-threaded programs where I want to be able to occasionally read a piece of information through an immutable reference, but that information can be modified by a different piece of code.
You could do that using Cell or RefCell. I agree that it makes it more cumbersome.
This separation is also why it is basically impossible to make apples-to-apples comparisons between languages.
Messy things I've hit (from ~5KLoC of Rust; I'm a Rust beginner, I primarily do C) are: cyclical references; a large structure that needs efficient single-threaded mutation while referenced from multiple places (i.e. must use some form of cell) at first, but needs to be sharable multithreaded after all the mutating is done; self-referential structures are roughly impossible to move around (namely, an object holding &-s to objects allocated by a bump allocator, movable around as a pair, but that's not a thing (without libraries that I couldn't figure out at least)); and refactoring mutability/lifetimes is also rather messy.
My issue is with ergonomics and performance. In my experience with a range of languages, the most performant way of writing the code is not the way you would idiomatically write it. They make good performance more complicated than it should be.
This holds true to me for my work with Java, Python, C# and JavaScript.
What I suppose I’m looking for is a better compromise between having some form of managed runtime vs non managed
And yes, I’ve also tried Go, and it’s DX is its own type of pain for me. I should try it again now that it has generics
What kind of idiomatic or unidiomatic C# do you have in mind?
I’d say if you are okay with GC side effects, achieving good performance targets is way easier than if you care about P99/999.
Is Rust going to be the last popular language to be invented that explores that space? I hope not.
Seeing how most people hate the lifetime annotations, yes. For the foreseeable future.
People want unlimited freedom. Unlimited freedom rhymes with unlimited footguns.
TypeScript is to JavaScript
as
Zig is to C
I am a huge TS fan.
[0] https://en.m.wikipedia.org/wiki/Embrace,_extend,_and_extingu...
To be fair, I don't believe there is a centralized and stated mission with Zig but it does feel like the story has moved beyond the "Incrementally improve your C/C++/Zig codebase" moniker.
Curious onlookers want to know if the Zig ecosystem and community will be as hostile to the decades of C libraries as the Rust zealots have been.
Definitely not the case in Zig. From my experience, the relationship with C libraries amounts to "if it works, use it".
And the relationship with C libraries certainly feels like a placeholder, akin to before the compiler was self-hosted. While I have seen some novel projects in Zig, there are certainly more than a few "pure Zig" rewrites of C libraries. Ultimately, this is free will. I just wonder if the Zig community is teeing up for a repeat of Rust's actix-web drama but rather than being because of the use of unsafe, it would be due to the use of C libraries instead of the all-Zig counterparts (assuming some level of maturity with the latter). While Zig's community appears healthier and more pragmatic, hype and ego have a way of ruining everything.
static linking?
Yes
Dynamic linking?
Yes
Importing/inclusion?
Yes
How does this translate (no pun intended) when the LLVM backend work is completed?
I'm not sure what you mean. It sounds like you think they're working on being able to use LLVM as a backend, but that has already been supported, and now they're working on not depending on LLVM as a requirement.
Does this extend to reproducible builds?
My hunch would be yes, but I'm not certain.
Hermetic builds?
I have never heard of this, but I would guess the same as reproducible.
While I have seen some novel projects in Zig, there are certainly more than a few "pure Zig" rewrites of C libraries.
It's a nice exercise, especially considering how close C and Zig are semantically. It's helpful for learning to see how C things are done in Zig, and rewriting things lets you isolate that experience without also being troubled with creating something novel.
For more than a few not rewrites, check out https://github.com/allyourcodebase, which is a group that repackages existing C libraries with the Zig package manager / build system.
andrewrk's wording towards C and its main ecosystem (POSIX) is very hostile, if that is something you'd like to go by.
C interop is very important, and very valuable. However, by removing undefined behaviours, replacing macros that do weird things with well thought-through comptime, and making sure that the zig compiler is also a c compiler, you get a nice balance across lots of factors.
It's a great language, I encourage people to dig into it.
Whether that ends up happening is obviously yet to be seen; as it stands there are plenty of Zig codebases with C in the mix. The idea, though, is that there shouldn't be anything stopping a programmer from replacing that C with Zig, and the two languages only coexist for the purpose of allowing that replacement to be gradual.
It’s totally subjective but I find the language boring to use. For side projects I like having fun thus I picked zig.
To each his own of course.
refactor codebase are rust’s main drawbacks
Hard disagree about refactoring. Rust is one of the few languages where you can actually do refactoring rather safely without having tons of tests that just exist to catch issues if code changes.
Python and Ruby are two very popular counterexamples.
In both cases, while it wasn't curly brackets that drove their adoption, it was unavoidable frameworks.
Most people only use Ruby when they have Rails projects, and what made Python originally interesting was Zope CMS.
And nowadays AI/ML frameworks, that are actually written in C, C++ and Fortran, making Python relevant because scientists decided on picking Python for their library bindings, it could have been Tcl just as well, as choices go.
So yeah, maybe not always curly brackets, but definitly something that makes it unavoidable, sadly Modula-2 lacked that, an OS vendor pushing it no matter what, FAANG style.
Zig’s comptime feature is most famous for what it can do: generics!, conditional compilation!, subtyping!, serialization!, ORM! That’s fascinating, but, to be fair, there’s a bunch of languages with quite powerful compile time evaluation capabilities that can do equivalent things.
I'm curious what are these other languages that can do these things? I read HN regularly but don't recall them. Or maybe that's including things like Java's annotation processing which is so clunky that I wouldn't classify them to be equivalent.
Annotations themselves are pretty great, and AFAIK, they are most widely used with reflection or bytecode rewriting instead. I get that the maintainers dislike macro-like capabilities, but the reality is that many of the nice libraries/facilities Java has (e.g. transparent spans), just aren't possible without AST-like modifications. So, the maintainers don't provide 1st class support for rewriting, and they hold their noses as popular libraries do it.
Closely related, I'm pretty excited to muck with the new class file API that just went GA in 24 (https://openjdk.org/jeps/484). I don't have experience with it yet, but I have high hopes.
Note that more intrusive changes -- including not only bytecode-rewriting agents, but also the use of those AST-modifying "libraries" (really, languages) -- require command-line flags that tell you that the semantics of code may be impacted by some other code that is identified in those flags. This is part of "integrity by default": https://openjdk.org/jeps/8305968
The integrity by default JEPs are really about trying to reduce developers depending upon JDK/JRE implementation details, for example, sun.misc.Unsafe. From the JEP:
In short: The use of JDK-internal APIs caused serious migration issues, there was no practical mechanism that enabled robust security in the current landscape, and new requirements could not be met. Despite the value that the unsafe APIs offer to libraries, frameworks, and tools, the ongoing lack of integrity is untenable. Strong encapsulation and the restriction of the unsafe APIs — by default — are the solution.
If you're dependent on something like ClassFileTransformer, -javaagent, or setAccessible, you'll just set a command-line flag. If you're not, it's because you're already doing this through other means like a custom ClassLoader or a build step.
Just because something mucks with a program's AST doesn't mean that it's introducing a new "language".
That depends on the language specification. The Java spec dictates what code a Java compiler must accept and must reject. Any "mucking with AST" that changes that is, by definition, not Java. For example, many Lombok programs are clearly not written in Java because the Java spec dictates that a Java compiler (with or without annotation processors) must reject them.
In Scheme or Clojure, user-defined AST transformations are very much part of the language.
The integrity by default JEPs are really about trying to reduce developers depending upon JDK/JRE implementation details
I'm one of the JEP's authors, and it concerns multiple things. In general, it concerns being able to make guarantees about certain invariants.
If you're not, it's because you're already doing this through other means like a custom ClassLoader or a build step.
Custom class loaders fall within integrity by default, as their impact is localised. Build step transforms also require an explicit run of some executable. The point of integrity by default is that any possibility of breaking invariants that the spec wishes to enforce must require some visible, auditable step. This is to specifically exclude invariant-breaking operations by code that appears to be a regular library.
I feel like we're talking right past one another. The ultimate reality is that annotation processors are pretty terrible for implementing functionality that a lot of Java developers depend upon. You could say annotation processors "weren't designed for that", but then you're just agreeing with me. This is sad, because arguably something quite similar to annotation processors could make the jobs of all of these developers a lot easier, instead of having them falling back to other mechanisms.
If your concern is integrity by default, why not just add yet another flag for can-muck-with-the-ast-annotation-processors? Or we can continue with the status quo.
If your concern is integrity by default, why not just add yet another flag for can-muck-with-the-ast-annotation-processors?
There is such a flag (or, rather, a set of flags), and that's exactly what the Lombok compiler uses to change javac to compile Lombok sources rather than Java sources.
However, we think there are much better solutions to the problem those languages try to solve than allowing AST manipulation.
However, we think there are much better solutions
I'd like to hear more. Can I discuss this further with you in a more appropriate venue than this forever thread?
The class of code I'm referring to is more like those you already mention in your JEP: logging, tracing, profiling, serialization, authn/authz, mocking, ffi, and so on. I would describe all of those as fitting under the umbrella of "cross-cutting" and needing a "meta-programming" facility.
Those are traditionally offered in Java in the form of bytecode transformation rather than AST transformations, as the notion of "compile time" in Java is not as clear as it is in, say, Zig; Project Leyden will make it even more vague, as it will allow caching JIT output from one run to the next.
Can I discuss this further with you in a more appropriate venue than this forever thread?
Sure, you can email me at the email address I use on the JDK mailing lists (e.g. loom-dev).
Those are traditionally offered in Java in the form of bytecode transformation
And we've come full circle. I think they're traditionally written as bytecode transformations, because the entire pipeline for both writing and using many kinds of program transformations in bytecode is far simpler, more accessible, and more performant than implementing and executing a source-to-source compiler that feeds into another java compiler.
That said, there are also times you wish to perform transforms on programs for which you don't have access to source, in which case your hand is forced. Ideally, you would be able to write many classes of transforms agnostic to that context.
Sure
Thanks!
It’s beautiful to implement an incredibly fast serde in like 10 lines without requiring other devs to annotate their packages.
I wouldn’t include Rust on that list if we’re speaking of compile time and compile time type abilities.
Last time I tried it Rust’s const expression system is pretty limited. Rust’s macro system likewise is also very weak.
Primarily you can only get type info by directly passing the type definition to a macro, which is how derive and all work.
Rust’s macro system likewise is also very weak.
How so? Rust procedural macros operate on token stream level while being able to tap into the parser, so I struggle to think of what they can't do, aside from limitations on the syntax of the macro.
If you have a derive macro for
#[derive(MyTrait)]
struct Foo {
bar: Bar,
baz: Baz,
}
then your macro can see that it references Bar and Baz, but it can't know anything about how those types are defined. Usually, the way to get around it is to define some trait on both Bar and Baz, which your Foo struct depends on, but that still only gives you access to that information at runtime, not when evaluating your macro.Another case would be something like
#[my_macro]
fn do_stuff() -> Bar {
let x = foo();
x.bar()
}
Your macro would be able to see that you call the functions foo() and Something::bar(), but it wouldn't have the context to know the type of x.And even if you did have the context to be able to see the scope, you probably still aren't going to reimplement rustc's type inference rules just for your one macro.
Scala (for example) is different: any AST node is tagged with its corresponding type that you can just ask for, along with any context to expand on that (what fields does it have? does it implement this supertype? are there any relevant implicit conversions in scope?). There are both up- and downsides to that (personally, I do quite like the locality that Rust macros enforce, for example), but Rust macros are unquestionably weaker.
A much much better system would be one that lets you write vanilla Rust code to manipulate either the token stream or the parsed AST.
Now, should they do anything they please? Definitely not, but they can. That's why there's a (serious) macro which runs your Python code, and a (joke, in the sense that you should never use it, not that it wouldn't work) macro which replaces your running compiler with a different one so that code which is otherwise invalid will compile anyway...
When you execute code at compile time, on which machine does it execute? The natural answer is “on your machine”, but it is wrong!
I don’t understand this.
If I am cross-compiling a program is it not true that comptime code literally executes on my local host machine? Like, isn’t that literally the definition of “compile-time”?
If there is an endian architecture change I could see Zig choosing to emulate the target machine on the host machine.
This feels so wrong to me. HostPlatform and TargetPlatform can be different. That’s fine! Hiding the host platform seems wrong. Can aomeone explain why you want to hide this seemingly critical fact?
Don’t get me wrong, I’m 100% on board the cross-compile train. And Zig does it literally better than any other compiled language that I know. So what am I missing?
Or wait. I guess the key is that, unlike Jai, comptime Zig code does NOT run at compile time. It merely refers to things that are KNOWN at compile time? Wait that’s not right either. I’m confused.
The reason is fairly simple: you want comptime code to be able to compute correct values for use at runtime. At the same time, there's zero benefit to not hiding the host platform in comptime, because, well, what use case is there for knowing e.g. the size of pointer in the arch on which the compiler is running?
Which, yes, means that the comptime interpreter emulates the target machine.
Reasonable if that’s how it works. I had absolutely no idea that Zig comptime worked this way!
there's zero benefit to not hiding the host platform in comptime
I don’t think this is clear. It is possibly good to hide host platform given Zig’s more limited comptime capabilities.
However in my $DayJob an extremely common and painful source of issues is trying to hide host platform when it can not in fact be hidden.
My generation code is probably going to allocate some memory and have some pointers and do some stuff. Why on earth would I want this compile-time code to run on an emulated version of the target platform? If I’m on a 64-bit platform then pointers are 8-bytes why would I pretend they aren’t? Even if the target is 32-bit?
Does that make sense? If the compiletime code ONLY runs on the host platform then you plausibly need to expose both host and target.
I’m pretty sure I’m thinking about zig comptime all wrong. Something isn’t clicking.
Why on earth would I want this compile-time code to run on an emulated version of the target platform? If I’m on a 64-bit platform then pointers are 8-bytes why would I pretend they aren’t? Even if the target is 32-bit? Does that make sense?
Nope, sorry, to me it doesn't. If you're cross-compiling for some other platform, then yes, I'd think you want the generated binary to be compatible with the target platform. And in order to verify that that binary code is correct for that target platform, you need to "allocate some memory and have some pointers and do some stuff" as you do on that platform.
So why on Earth would you want stuff -- like pointer sizes and whatnot -- to not be compatible with the target platform, but with whatever you happen to be compiling on? What good is pointer size compatibility with your compiling platform to a user of your end-result binary on the target platform? Looks like the mother of all it-worked-on-my-machine statements: "Whaddaya mean it has memory allocation errors on your machine? I ran it as if for my totally-different machine at compile-time, so of course it works on yours!"
On the other hand, "comptime" is actually executed within the compiler similar to C++'s `consteval`. There's no actual "emulation" going on. The "emulation" is just ensuring that any observable characteristic of the platform matches the target, but it's all smoke and mirrors. You can create pointers to memory locations, but these memory locations and pointers are not real. They're all implemented using the same internal mechanisms that power the rest of the compilation process. The compiler's logic to calculate the value of a global constant (`const a: i32 = 1 + 2;`) is the "comptime" that allows generic functions, ORMs, and all these other neat use cases.
It's Odin's Disc. It has only one side. Nothing else on Earth has only one side.
(Not having much Spanish, I at first thought "Odin's disco(teque)" and then "no, that doesn't make sense about sides", but then, surely primed by English "disco", thought "it must mean Odin's record/lp/album".)
I wonder if there's some message in here. As a modern American reader, if I believed the story was contemporary, I'd think it's making a point about Christianity substituting honor for destructive greed. That a descendant of the wolves of Odin would worship a Hebrew instead and kill him for a bit of money is quite sad, but I don't think it an inaccurate characterization. There's also the element of resentment towards Odin for not just handing over monetary blessings. That's sad to me as well. Part of me hopes that one day Odin isn't held in such contempt.
Practically, "zig build"-time-eval. As such there's another 'comptime' stage with more freedom, unlimited run-time (no @setEvalBranchQuota), can do IO (DB schema, network lookups, etc.) but you lose the freedom to generate zig types as values in the current compilation; instead of that you of course have the freedom to reduce->project from target compiled semantic back to input syntax down to string to enter your future compilation context again.
Back in the day, where I had to glue perl and tcl via C at one point in time, passing strings for perl generated through tcl is what this whole thing reminds me of. Sure it works. I'm not happy about it. There's _another_ "macro" stage that you can't even see in your code (it's just @import).
The zig community bewilders me at times with their love for lashing themselves. The sort of discussions which new sort of self-harm they'd love to enforce on everybody is borderline disturbing.
The zig community bewilders me at times with their love for lashing themselves. The sort of discussions which new sort of self-harm they'd love to enforce on everybody is borderline disturbing.
Personally, I find the idea that a compiler might be able to reach outside itself completely terrifying (Access the network or a database? Are you nuts?).
That should be 100% the job of a build system.
Now, you can certainly argue that generating a text file may or may not be the best way to reify the result back into the compiler. However, what the compiler gets and generates should be completely deterministic.
Personally, I find the idea that a compiler might be able to reach outside itself completely terrifying (Access the network or a database? Are you nuts?).
Yeah, although so can build.rs or whatever you call in your Makefile. If something like cargo would have built-in sandboxing, that would be interesting.
Personally, I find the idea that a compiler might be able to reach outside itself completely terrifying (Access the network or a database? Are you nuts?).
In gamedev code is small part of the end product. "Data-driven" is the term if you want to look it up. Doing an optimization pass that will partially evaluate data+code together as part of the build is normal. Code has like 'development version' that supports data modifications and 'shipping version' that can assume that data is known.
The more traditional example of PGO+LTO is just another example how code can be specialized for existing data. I don't know a toolchain that survives change of PGO profiling data between builds without drastic changes in the resulting binary.
Personally, I find the idea that a compiler might be able to reach outside itself completely terrifying (Access the network or a database? Are you nuts?).
Why though? F# has this feature called TypeProviders where you can emit types to the compiler. For example, you can do do:
type DbSchema = PostgresTypeProvider<"postgresql://postgres:...">
type WikipediaArticle = WikipediaTypeProvider<"https://wikipedia.org/wiki/Hello">
and now you have a type that references that Article or that DB. You can treat it as if you had manually written all those types. You can fully inspect it in the IDE, debugger or logger. It's a full type that's autogenerated in a temp directory.When I first saw it, I thought it was really strange. Then thought about it abit, played with it, and thought it was brilliant. Literally one of the smartest ideas ever. It's first class codegen framework. There were some limitations, but still.
After using it in a real project, you figure out why it didn't catch on. It's so close, but it's missing something. Just one thing is out of place there. The interaction is painful for anything that's not a file source, like CsvTypeProvider or a public internet url. It does also create this odd dependenciey that your code has that can't be source controlled or reproduced. There were hacks and workarounds, but nothing felt right for me.
It was however, the best attempt at a statically typed language trying to imitate python or javascript scripting syntax. Where you just say put a db uri, and you start assuming types.
Personally, I find the idea that a compiler might be able to reach outside itself completely terrifying (Access the network or a database? Are you nuts?).
It’s not the compiler per se.
Let’s say you want a build system that is capable of generating code. Ok we can all agree that’s super common and not crazy.
Wouldn’t it be great if the code that generated Zig code also be written in Zig? Why should codegen code be written in some completely unrelated language? Why should developers have to learn a brand new language to do compile time code Gen? Why yes Rust macros I’m staring angrily at you!
That should be 100% the job of a build system.
What is the primary difference between build system and compiler in your mind? Why not have the compiler know how to build things, and so compile-time codegen you want to put in the build system, happens during compilation?
Personally, I find the idea that a compiler might be able to reach outside itself completely terrifying (Access the network or a database? Are you nuts?).
What is "itself" here, please? Access a static 'external' source? Access a dynamically generated 'external' source? If that file is generated in the build system / build process as derived information, would you put it under version control? If not, are you as nuts as I am?
Some processes require sharp tools, and you can't always be afraid to handle one. If all you have is a blunt tool, well, you know how the saying goes for C++.
However, what the compiler gets and generates should be completely deterministic.
The zig community treats 'zig build' as "the compile step", ergo what "the compiler" gets ultimately is decided "at compile, er, zig build time". What the compiler gets, i.e., what zig build generates within the same user-facing process, is not deterministic.
Why would it be. Generating an interface is something that you want to be part of a streamline process. Appeasing C interfaces will be moving to a zig build-time multi-step process involving zig's 'translate-c' whose output you then import into your zig file. You think anybody is going to treat that output differently than from what you'd get from doing this invisibly at comptime (which, btw, is what practically happens now)?
The zig community treats 'zig build' as "the compile step", ergo what "the compiler" gets ultimately is decided "at compile, er, zig build time". What the compiler gets, i.e., what zig build generates within the same user-facing process, is not deterministic.
I know of no build system that is completely deterministic unless you go through the process of very explicitly pinning things. Whereas practically every compiler is deterministic (gcc, for example, would rebuild itself 3 times and compare the last two to make sure they were byte identical). Perhaps there needs to be "zigmeson" (work out and generate dependencies) and "zigninja" (just call compiler on static resources) to set things apart, but it doesn't change the fact that "zig build" dispatches to a "build system" and "zig"/"zig cc" dispatches to a "compiler".
Appeasing C interfaces will be moving to a zig build-time multi-step process involving zig's 'translate-c' whose output you then import into your zig file. You think anybody is going to treat that output differently than from what you'd get from doing this invisibly at comptime (which, btw, is what practically happens now)?
That's a completely different issue, but it illustrates the problem perfectly.
The problem is that @cImport() can be called from two different modules on the same file. What about if there are three? What about if they need different versions? What happens when a previous @cImport modifies how that file translates. How do you do link time optimization on that?
This is exactly why your compiler needs to run on static resources that have already been resolved. I'm fine with my build system calling a SAT solver to work out a Gordian Knot of dependencies. I am not fine with my compiler needing to do that resolution.
What is "itself"
If I understand correctly the zig compiler is sandboxed to the local directory of the project's build file. Except for possibly c headers.
The builder and linker can reach out a bit.
Distinguishing between `comptime` and `build time` is a distinction from the ivory tower. 'zig build' can happily reach anywhere, and generate anything.
It is also helpful for code/security review to have a one-stop place to look to see if anything outside of the git tree/submodule system can affect what's run.
However, "macros" are a disaster to debug in every language that they appear. "comptime" sidesteps that because you can generally force it to run at runtime where your normal debugging mechanisms work just fine (returning a type being an exception).
"Macros" generally impose extremely large cognitive overhead and making them hygienic has spawned the careers of countless CS professors. In addition, macros often impose significant compiler overhead (how many crates do Rust's proc-macros pull in?).
It is not at all clear that the full power of general macros is worth the downstream grief that they cause (I also hold this position for a lot of compiler optimizations, but that's a rant for a different day).
However, "macros" are a disaster to debug in every language that they appear.
I have only used proper macros in Common Lisp, but at least there they are developed and debugged just like any other function. You call `macroexpand` in the repl to see the output of the macro and if there's an error you automatically get thrown in the same debugger that you use to debug other functions.
At the very least, that places you outside the boundary of a lot of the types of system programming that languages like C, C++, Rust, and Zig are meant to do.
You can @setEvalBranchQuota essentially as big as you want, @embedFile an XML file, comptime parse it and generate types based on that (BTDT). You can slow down compilation as much as you want to already. Unrestricting the expressiveness of comptime has as much to do with compile times, as much as the restricted amount, and perceived entanglement of zig build and build.zig has to do with compile times.
The knife about unrestricted / restricted comptime cuts both ways. Have you considered stopping using comptime and generate strings for cachable consumption of portable zig code for all the currently supported comptime use-cases right now? Why wouldn't you? What is it that you feel is more apt to be done at comptime? Can you accept that others see other use-cases that don't align with andrewrk's (current) vision? If I need to update a slow generation at 'project buildtime' your 'compilation speed' argument tanks as well. It's the problem space that dictates the minimal/optimal solution, not the programming language designer's headspace.
Don't really get it, lets go back to the old days because it is cool, kind of vibe.
Ironically nothing this matters in the long term, as eventually LLMs will be producing binaries directly.
While I agree that's typically a bad idea, this seems to have nothing to do specifically with zig.
I get how you start with the idea that there's something deficient in zig's comptime causing this, but... what?
I also have some doubts about how commonly used free-form code generation is with zig.
These kinds of insights are what I love about Zig. Andrew Kelley just might be the patron saint of the KISS principle.
A long time ago I had an enlightenment experience where I was doing something clever with macros in F#, and it wasn't until I had more-or-less finished the whole thing that I realized I could implement it in a lot less (and more readable) code by doing some really basic stuff with partial application and higher order functions. And it would still be performant because the compiler would take care of the clever bits for me.
Not too long after that, macros largely disappeared from my Lisp code, too.
At some point you realize you need type information, so you just add it to your func params.
That bubbles all the way up and you are done. Or you realize in certain situation it is not possible to provide the type and you need to solve a arch/design issue.
Unrolling as a performance optimization is usually slightly different, typically working in batches rather than unrolling the entire thing, even when the length is known at compile time.
The docs suggest not using `inline` for performance without evidence it helps in your specific usage, largely because the bloated binary is likely to be slower unless you have a good reason to believe your case is special, and also because `inline` _removes_ optimization potential from the compiler rather than adding it (its inlining passes are very, very good, and despite having an extremely good grasp on which things should be inlined I rarely outperform the compiler -- I'm never worse, but the ability to not have to even think about it unless/until I get to the microoptimization phase of a project is liberating).