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.
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.
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.
“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.”
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.
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
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.
Without more context, this comment sounds like rehashing old (personal?) drama.
This was perhaps a bad comparison and I should have compared e.g. Java generics to Zig’s comptime T.
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.
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.
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.
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)?
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!
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.
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.
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.
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?
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.
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.
"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.
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?
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.
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).
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
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.
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
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.
TypeScript is to JavaScript
as
Zig is to C
I am a huge TS fan.
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
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.