const std = @import("std");
const File = std.Io.File;
pub fn main(init: std.process.Init) !void {
_ = try File.stdout().writeStreamingAll(init.io, "Hello, World!\n");
}
That's a lot to follow, just to output a plan-text message, especially after this line: "The primary goal of Zig is to be a better solution to the sorts of tasks that are currently solved with C. A primary concern in that respect is readability…"> Semantic analysis is the most difficult part of the compiler to handle incrementally. Perhaps unsurprisingly then, this is where language design starts to matter a lot: while I am pretty confident that most modern languages could support incremental compilation similar to how we do, certain design decisions can make that much more difficult. Zig has had its design tweaked over the years (sometimes controversially) specifically so that it is easier to support fast incremental compilation.
This is something I wish that we had done with Rust. It is impossible to do all of the things at once, though, and we already had a tremendous amount of things to do. This is also part of the "when do you ship 1.0" tradeoff; for our goals with the language, 2015 was the right moment to launch, but if had a few more years to bake things, maybe we could have made compile times way faster. Software engineering is hard.
I understand that for a release mode a single giant binary may be desirable, but I am struggling to understand this design for debug builds. Moreover, while reading this article, I found myself wondering what happens if the main binary becomes corrupted. Maybe the user cancels compilation with ctrl+c while it is patching the binary. Even if they have a story for avoiding and/or detecting corruption, it is simpler to not patch in the first place and always generate a new main binary. Again, this is reasonable because the new binary is mainly just a list of shared libraries to link which will not take up much space and can be written to disk quickly. Moreover, this process can be done recursively, e.g. at the subdirectory level, so that during incremental linking a few quite small shared libraries may be produced rather than patching in the new code and writing cascading relocations.
Alternatively, here's a simpler version (prints to stderr).
const std = @import("std");
pub fn main() void {
std.debug.print("Hello, world!\n", .{});
}
In practice, you normally don't want to print messages to stdout. So the increased friction here actually pushes you in a better direction.10 PRINT “Hello World”
Beautifully simple and readable. But it’s not a good language by modern standards.
In your example, I see a lot of complexity being surfaced: output streams, locals instead of globals, error handling. I don’t know Zig but all of those are things that are important to address, and I like that the example doesn’t sweep them under the rug in pursuit of a false readability.
Zig doesn't have the same safety guarantees, it's on the dev to use safe coding patterns, so the tradeoff for safety is discipline or experience.
https://github.com/rust-lang/rust/issues/2369#issuecomment-1...
This would already help a lot.
I recommend anybody who's interested in incremental recompilation to read what GHC does, because the effort to achieve that is relatively low.
Of course there's always desire for more:
GHC currently needs to parse+typecheck+codegen a file before it can process other files that import it. Codegen is slow. Thus, there's currently demand split compilation into "stages", so that the next file can be typechecked after its imports have been just typechecked (not codegenned).
I would also enjoy if recompilation avoidance were to happen at the function level, not the file level.
Macro systems are a key language feature that can destroy incremental recompilation. In theory, Haskell is well set up for that, as its macro system (TemplateHaskell) is fully AST based and _theoretically_ could distinguish "fully pure" macros from side-effectful macros (such as splicing the current git commit in as a string literal). But the recompilation avoidance system does not currently exploit such differences.
I'm not sure what that means. Java lets you do many things programs may want to do in a memory-safe way but not everything. Rust lets you do fewer things than Java in a memory-safe way, but more things than Zig. Zig lets you do fewer things in a memory-safe way than Rust, but more than C. So among these four languages we already have four levels of memory safety, none of them is 100%, all of them give up something in exchange for what they offer, and different programmers have different preferences for the compromise they prefer, and even that preference is context-dependent. Which of those less-than-100% memory safety compromises is the table stakes? And given that all of these compromises require something that could be quite substantial, depending on the circumstance, in exchange, and consequently programmers with the highest level of knowledge and expertise choose every one of those four in different situations, to me it seems pretty obvious that none of these is "table stakes".
Explicitly passing IO in is a fine design choice, but it's not a correctness issue to say others are wrong to not do so.
(a process with 100 shared libraries takes 6ms to run, which is a lot better (0.9ms for 1 library, for reference), but, especially with in-place patching skipping work on unchanged values, static linking still has a good shot at beating dynamic linking, especially if you run the binary multiple times)
Incremental compilation generally already depends on its stored intermediate data not getting corrupted, and the final binary need not be any differently handled in that aspect.
TL;DR: only debug builds for now, could extend to release builds once we have our own optimisation passes one day, but some optimisations will still be inapplicable.
Getting incremental linker to work with llvm is kinda hard atm. Maybe the zig team has a plan dunno.
An example that's being funded right now: https://rust-lang.github.io/rust-project-goals/2026/expansio...
In particular, Rust made several good design decisions around this stuff that keeps those checks fast, like keeping checks local rather than being global.
Maybe at some point in the future zig could add a rust compilation target ( like with `-ofmt=c` )...
I don't think I agree with this framing. The question to me isn't "what can you do while being memory safe", it's "can you accidentally do something memory unsafe without noticing?" Rust and Java are the same here; you need to explicitly opt into using the language's mechanism for relaxing restrictions (Rust's `unsafe` blocks, Java's `Unsafe` class APIs), whereas from what I understand, neither Zig or C offers anything strict in that way.
Zig has an identical memory safety profile to C. It has facilities to make it easier to stay memory safe, but those facilities are basically equivalent to what you have in C++ and that's equivalent memory safety profile as C.
> So among these four languages we already have four levels of memory safety, none of them is 100%
No, you've pretended like there's four when really it's Java / Rust which are safe by default and Zig/C/C++ which are unsafe by default.
One effective metric to evaluate is memory safety per LoC. Rust is ~0.2 vulnerabilities per MLoC. Java is effectively 0. C and C++ both seem to be about 1,000 vulnerabilities per MLoC. Zig is too new and hasn't had any analysis done on it, but generously it's likely at least 10-100.
So the table stakes could be defined as 1 memory safety vulnerability per MLoC.
Also note that Java has unsafe, but doesn't have the culture of plainly stating safety invariants like Rust. The unsafe features of Java are less widely used, but when they are you rarely know if a Java library has unsafe internals for performance, and if they do, it may be hard to audit
Using this native (written in Zig) C compiler to translate C source into Zig source as a part of the build, would presumably lend itself trivially to all the incremental logic in TFA, as updating C would update the generated Zig, and the incremental logic would detect differences just like it detects differences made by a human in an editor. Maybe there are aspects of the generated Zig that would complicate that somewhat, but I don't know -- just a warning about my ignorance.
This is part of plans to remove the hard LLVM dependency. AFAIK, the LLVM dependency will still be a variant many will use for the convenience of Zig as a much better clang, but removing the hard dependency is part of enabling all these great features like incremental compilation.
Should they?
We can always compile with full optimization just before shipping?
That's not planned AFAIK (see https://github.com/ziglang/zig/issues/16269). `translate-c` is really only intended for header translation, not C source code.
See https://github.com/ziglang/zig/issues/20875 for the (not fully fleshed out yet) plans around C compilation.
You have just described six orders of magnitude in your attempt to rebut pron pointing out the four languages have four levels of memory safety.
Now, that's ridiculous, but something not too different happens to me with Rust. I reach for a low-level language when I want to do low-level things in a more convenient way than in Java, but the very things that would make me reach for a low-level language in the first place are unsafe in Rust. So in ~100% of the programs I want to write in a low-level language, Rust and Zig offer the same level of memory safety (but I need to pay a higher price for Rust). That Rust reminds me that what I want to do is unsafe doesn't help me.
Of course, other people may want to reach for a low-level language in other situations and their perspective could be different, but if I pay the price and get little in return I can't see how that would be "table stakes". Table stakes imply some universality that is obviously not here.
They really don't. Look at how many basic data structures (in the standard library or outside it) require unsafe features in Java vs Rust.
> Zig has an identical memory safety profile to C
It really doesn't. Zig gives you the same spatial memory safety as Rust and very much not like C (and violations of spatial memory safety are a bigger cause of vulnerabilities than violations of temporal memory safety).
The main issue is that so far such tools haven't been a priority for Rust.
There's also stuff around name resolution.
Proc macros are just an inherently very slow way to do what they do.
Because Rust commits to the traditional compilation model and pipleine (which I think is overall a good thing, or at least, a good thing to support), it does a lot of work that will eventually be thrown away. Consider this example: I have a library with a function foo that returns a simple 42. I have a binary which calls foo from that library and prints the result. Now imagine the library is a hundred thousand lines of unrelated code to what the binary needs, but is useful for other people. Because compilation works in the "produce libraries, produce binary, link them all together" style model, you have to compile the entire library with all of that code, when all you need is really one function. That intermediate work is useful, and I'm picking an example that's deliberately extreme, of course.
There's a bunch of stuff like this, and I do not have time to really say more than that right now. But yeah, monomorphized generics also produce a lot of compile time pressure too, in various ways.
Anyway I just also want to reiterate a few things: first of all, all of these decisions were made for good reasons, and there are pros to what Rust does and why. It's just that compile times suffer because of it. What I wish was that we had taken compile times into more consideration when deciding what to do and why in a more serious way. The same decisions might have been made, but at least it would have been known, rather than the situation now, where there's just a tremendous amount of work to try to optimize what exists, rather than having the freedom to maybe tweak some things to make that job way easier.
Now you might argue that the other features of Zig, like `defer`, are so good that they reduce the chance of memory errors and therefore memory safety has less value for Zig. But that seems highly dubious to me, especially for use-after-free. I guess we'll find out when Zig has more widespread use.
What I do not like, primarily comes down to how the project is talked about and marketed. First, because it promotes an "us vs them" mindset, instead of a "we're all trying to improve memory safety" mindset, and second, because in doing so, it also overstates its case.
These things are sort of intertwined. Let's talk about the overstatement first. Fil-c has its own definition of memory safety that is slightly different than others. For example, I saw this recently:
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
struct User {
char name[8];
int is_root;
};
int main(int argc, char*argv[]) {
struct User* user = malloc(sizeof(struct User));
strcpy(user->name, argv[1]);
if (user->is_root) {
printf("I am root!\n");
} else {
printf("I am not root :(\n");
}
return 0;
}
This, when invoked with "012345678" passed in, will print "I am root!". In my understanding, this is deliberately allowed.But beyond corners like this, fil-c's author will go on about "Rust has unsafe as a hatch, fil-c does not" while if you control-f for "zunsafe_" on https://fil-c.org/stdfil you get ... escape hatches.
The author regularly erases the difference between "traps at runtime" and "is prevented at compile time", which are legitimate tradeoffs where one or the other may be better depending on what you're doing. But they're presented as either equivalent, or one is superior, and I find this muddles the discourse. The performance issues also tie into this, "add a GC" is absolutely a valid way to handle these sorts of issues, but it is not the same thing as what Rust does. And that's okay! But presenting it as purely superior means that it's just hard to talk about.
Speaking of muddling the discourse, the author regularly trolls on X, providing tons of bad faith arguments and generally trying to rile up a "fil-c vs Rust" war that I think reduces our ability to talk about these differences in a calm, engineering focused context.
Finally, due to its design, fil-c is effectively Linux only. That's great for Linux, but many people also use other systems, and so it is not a meaningful option for them.
Anyway, after saying all that: I still think that it is a good project, and that it should exist and continue to be worked on. I just wish that the heat was turned down, and people could talk about the various approaches and their tradeoffs without turning it into a culture war.
Honestly, that's exactly how I feel in reverse. The framing you gave is more intellectually interesting, but it doesn't help explain the actual real-world outcomes where in practice, Rust and Java both don't have much problem with unsafety, whereas C does, and at least from what I've heard, Zig does as well.
> I reach for a low-level language when I want to do low-level things in a more convenient way than in Java, but the very things that would make me reach for a low-level language in the first place are unsafe in Rust. So in ~100% of the programs I want to write in a low-level language, Rust and Zig offer the same level of memory safety (but I need to pay a higher price for Rust). That Rust reminds me that what I want to do is unsafe doesn't help me.
I mean, sure, if you want to do things that are fundamentally not possible to validate because you think you're smart enough not to screw up, that's going to make Rust a tough sell. My issue with it is that history has shown that the best C and C++ programmers in the world still write code where memory safety rears its head, so I'm distrustful of the claim that being smart and diligent is enough to prevent the sort of bugs that we're still dealing with after half a century of us learning how not to write C. You need to have an excess of either talent or hubris to consider that a reasonably safe path, and given that the amount of talent needed is a lot higher than the amount of hubris, it seems way more likely that it's the latter.
The alternative is just learning how to write code that doesn't require expressing things in a way that can't be validated. While there are some things that fundamentally are not possible to, I'm dubious that it's anywhere close to as high as you seem to expect if your experience is that you literally can't reduce the amount of unsafe code you need in Rust below "literally my entire program is unsafe".
(To be clear, squeek502 is a part of the Zig core team [0], so he knows what he's talking about :D)
Even a simple change to one file results in re-parsing the whole library because definitions come from anywhere and we have to obey the 1970s single file compilation model. The result is a driver spawns 8 threads and each one wastes time re-parsing every file in the library looking for definitions. AFAIK Rust doesn't really track dependencies at the file or function level either so it doesn't really know what changed.
To me compilers should be content-addressed databases. Each declaration and its associated content generate hashes that roll up to its containing type or namespace, then to the file, then to the library as a whole, along with hashes of the dependencies. Changing the type signature of a single function should result in the compiler being able to cheaply determine whether that has any visibility and if so to what other files in the same library or if it affects the public interface.
A file that hasn't changed and whos inputs hasn't changed should re-use the IR from the prior compilation. Even for an individual type that should be the case so changing the internals of a function in a struct only regnerates that one function and nothing else. The compiler knows deterministically that change can't have affected anything else.
That has major benefits for code completion and editing as prior compilations can feed into generating errors or suggested corrections.
Then you can take things a step further and JIT a changed function, injecting the new machine code on the fly so long as the shapes of the types don't change. Very useful for debugging.
Compilers are mostly held back because the people who write compilers are stuck on certain ideas about how compilers should be written.
Note that it's not a serious suggestion, but I wonder what effect it would have on build times.
I feel extremely validated reading this! I've had a pet theory for a while that compile times would be drastically reduced if every single item in a crate were implicitly under a cargo feature flag and then they only got enabled if they were imported (and used in non-dead code). I know that making something like that work isn't anywhere close to as simple as I'm describing it, and there are probably a million edge cases, but I've long felt that the ergonomics of Cargo features basically making it too annoying to expose everything conditionally (and then transitively expose all of the features from all of the direct dependencies as well so that things depending on your library could also only conditionally enable them) is secretly the reason that people think Rust compile times are slow, and seeing someone who has way more direct knowledge than me of how all of it works under the hood give a similar take makes me more confident that I might have been on to something all along.
They aren't necessarily, though. Supposing that Zig were "10 issues per MLoC" (with just as much handwaving as the original poster), it would be equidistant from Rust and C. Java may also be more than one order of magnitude away from Rust; we say ~0 but is it 0.01, 0.001, 0.0001...? And why is "1 issue per MLoC" the acceptable metric that delineates what constitutes table-stakes memory-safe language? Because it's a nice, round-sounding number? I think 0 is a nicer, rounder number than 1, so let's call only Java table stakes and condemn all other languages to the garbage bin, tradeoffs be damned. Or would you say your arbitrary delineation point is worth more than mine?
On the muddling the discourse, I'm not on twitter and don't engage there, so I don't have an opinion on that, but I did come across https://news.ycombinator.com/item?id=49044561 recently, and I just don't see how the author can make such bold claims while examples like the one Steve provided above are still in the language. Corrupting memory in Fil-C is still easy, type confusion is still easy, intra-object overflows are still easy. Fil-C prevents a range of classes of bugs from being exploitable, but it doesn't stop the bugs from happening.
Do you have any actual benchmarks?
But you did the same thing when, on the spectrum that ranges from C to ATS, with Zig, Rust, and Java somewhere in the middle (though all closer to C than to ATS), you declared the exact compromise that Rust makes "table stakes"! [1]
Zig improves on C's memory safety when it comes to spatial safety, possibly the more impactful kind, so it, too, could be part of the "we're all trying to improve memory safety", yet you exclude it.
You're trying to draw some hard line that passes exactly between Rust and Zig on the C to ATS spectrum, and I'm trying to say that that line isn't there (your attempt at a definition of delineating safe and unsafe code also applies to C). Obviously, C, Zig, Rust, Java, and ATS all make very different tradeoffs, all of which may be more or less attractive to different people and in different circumstances, but there is no sharp line, at least not one that is meaningful enough to be "table stakes". Your personal inclinations place a premium on the things Rust offers and Zig doesn't while mine are the opposite, but I make no claim to universality.
I'm happy to accept that not everyone shares my aesthetics and can understand why some people prefer Rust, but those claims to or hints at universality annoy me (as they did when they were made by Haskellers, and I actually find Haskell's aesthetics quite pleasing), as they are simply unsupported. I've spent a lot of time studying formal methods and software correctness in general (https://pron.github.io) and if there's one thing we know in that field is that things are never that simple (and, bringing this back to this posts topic, even something like incremental compilation can contribute to program correctness).
(Now, you may argue that you're only talking about "memory safety" and not correctness in general, but what gives memory safety value is that violations are causes of many dangerous vulnerabilities; but once, say, Java eliminates all of them, 100% of bugs/vulnerability - which are still numerous - will be caused by other problems, all potentially avoidable with ATS, so why isn't ATS table stakes? Of course, the answer is cost, but all the languages on the spectrum differ in their costs.)
[1]: I assume that you meant Rust's compromise, because you implied that Zig doesn't pass that bar but Rust does.
This is a fundamental misunderstanding of how "unsafe" code relates to a platform's trusted computing base. Rust could move all of those unsafe data structures out of the standard library and into the compiler itself, thereby reducing the amount of occurrences of the string "unsafe" in the source, code, but this would do nothing to reduce the size of the trusted computing base that Rust presents. In fact, it would decrease our confidence in that code, because Rust libraries have a robust ecosystem of tools for validating their correctness, unlike whatever bespoke IR the Rust compiler itself is emitting. Java's own data structures are implemented with the support of an extensive runtime written in C++, which forms their own trusted computing base that every user of Java relies upon, and demands just as much careful auditing as any data structure in the Rust standard library.
I'm not interested in the definition so much as I am in calling it "table stakes", and so the fact that these languages satisfy their promises is uninteresting in isolation. What matters is the value of their promises. The majority of Rust programs I see, I wouldn't have written in a low-level language, so the fact that it offers memory safety for the things I don't need it to do does nothing for me.
Now, clearly, Rust's originators didn't consider what Java offers (or at least what it offered 20 years ago when Rust was first conceived) to be table stakes or they wouldn't have wanted Rust. Java exacted some price in exchange for its memory safety that was unacceptable to Rust's originators and trumped its memory safety. But the same thing happens with Rust vs Zig. Rust exacts a heavy price for its memory safety, that - just as in Rust's case vs Java - is sometimes unacceptable. So I can't see how any of these could be "table stakes".
> I mean, sure, if you want to do things that are fundamentally not possible to validate because you think you're smart enough not to screw up, that's going to make Rust a tough sell.
What Rust can validate and what can fundamentally be validated are two very, very different things. Compared to what ATS can validate, what Rust can validate is almost indistinguishable from C. In Rust you have to do lots and lots of things that require you to be "smart enough not to screw up" that you could prove in ATS, and still no one (including Rust programmers) would say that what ATS offers is "table stakes" because, obviously, it comes at a high price that the people who choose Rust don't want to pay.
So clearly different languages offer different capabilities and charge a price for them. Sometimes the price is worth it and sometimes it isn't.
> so I'm distrustful of the claim that being smart and diligent is enough to prevent the sort of bugs that we're still dealing with after half a century of us learning how not to write C
But Java or Rust programs still suffer from a lot of bugs that ATS could eliminate, if you're willing to pay the price, and you're clearly unwilling. ATS programmers could say about Rust programmers what you say about C++ programmers. Clearly there's no universal table stakes here.
Incidentally, you might want to look at gc-sections, which Rust already does. As well as https://rust-lang.github.io/rust-project-goals/2025h2/relink... which is kinda related.
The sort of key here is understanding that "produce a library" means that every public item is "used" in the sense of "do we need to compile it." The real trick is to do demand-driven compilation starting from the actual final program's needs, and this is inherently at odds with the idea of producing standalone libraries and combining them into the final artifact.
Yes for the reasons I already gave. I think that at the point that you're having to stretch the numbers from their post to the breaking point to remove the pretty clear order of magnitude differences it's not really a constructive way to engage.
I think you have two groups with one at ~.1 and one on ~100. You seen to either disagree with that, or think it doesn't matter, I'm not sure which. But taking that assumption as true it is self evident that the 3-order-of magnitude demarcation is not arbitrary.
I don't even work on Rust anymore, and in fact started this thread with a criticism of Rust. There are lots of good criticisms of Rust. There is a difference between "this criticism isn't good" and "every criticism is an offense."
> Those languages rely on a much larger pile of YOLO C/C++ code for their runtimes and standard libraries than Fil-C does. So Fil-C is safer than those
Given the relative immaturity of Fil-C, this seems wildly wrong to me. I’m not sure how to take his claims about his runtime seriously.
I think this is a case of people who can dish it out but can't take it. As far as I'm concerned if you troll someone you should expect to get trolled back.
Rust folks, this whole thing is a thread about Zig’s new feature - not even a memory safety-related feature! - and we cannot spend the whole damn time talking about Rust.
Steve, even you - I don’t believe I have ever seen you say an unkind word. But have you considered that it may be unkind to have written more than half of the words on a thread about a Zig performance feature?
I mean, if you're already going to say "I don't want a low level language for anything other than what I can use unsafe for", then of course Rust will seem like overkill. I'd argue that the value of Rust is that it makes low-level viable for a lot of stuff that would otherwise require a lack of memory safety; a lot of it is stuff that might be written in a higher level language, but that's just because relatively few programs are impossible to write in higher level languages. That doesn't mean that the ones that need to be lower level can't be written in Rust though.
> Now, clearly, Rust's originators didn't consider what Java offers (or at least what it offered 20 years ago when Rust was first conceived) to be table stakes or they wouldn't have wanted Rust. Java exacted some price in exchange for its memory safety that was unacceptable to Rust's originators and trumped its memory safety. But the same thing happens with Rust vs Zig. Rust exacts a heavy price for its memory safety, that - just as in Rust's case vs Java - is sometimes unacceptable. So I can't see how any of these could be "table stakes".
Yes, "table stakes" is a value judgment, and one some people will disagree with. The cost for memory safety in Java is performance overhead though, and the cost for memory safety in Rust is not being able to express certain valid things that can't be validated; those are both objectively different from not offering memory safety at all, and my point is that the cases where what you want to express is literally impossible in Rust to do safely while actually being memory safe are pretty rare. There are some cases where what you're trying to do are fundamentally unsafe, in which case you need to use an unsafe block, but that's not anywhere close to the same as removing validation from the entire program. I'm fairly skeptical that you're basing your view that there are so many cases where you want to do something that's guaranteed to be safe but impossible to write in safe Rust on objective criteria, and extremely skeptical that the programs you write are anywhere close to entirely comprised of logic that can't be expressed safely.
> What Rust can validate and what can fundamentally be validated are two very, very different things. Compared to what ATS can validate, what Rust can validate is almost indistinguishable from C. In Rust you have to do lots and lots of things that require you to be "smart enough not to screw up" that you could prove in ATS, and still no one (including Rust programmers) would say that what ATS offers is "table stakes" because, obviously, it comes at a high price that the people who choose Rust don't want to pay.
> So clearly different languages offer different capabilities and charge a price for them. Sometimes the price is worth it and sometimes it isn't.
Sure, no one is disputing that. But that doesn't change the fact that some languages objectively require you to opt into which parts are memory unsafe, and others don't. It's obvious we won't see eye to eye on whether that's table stakes or not, but that's a difference of opinion, and having a different opinion than you isn't literally illogical; I find your take on it to be as hard to understand as mine is to you.
> But Java or Rust programs still suffer from a lot of bugs that ATS could eliminate, if you're willing to pay the price, and you're clearly unwilling. ATS programmers could say about Rust programmers what you say about C++ programmers. Clearly there's no universal table stakes here.
You're again taking an empirical argument as an abstract one and ignoring the real world outcomes that languages produce. You mentioned finding the fact that they actually produce real world software that in practice do not suffer from the class of bugs that C/C++ suffers from uninteresting, and that's fine, but it's meaningful for people who care about software actually getting used in the real world for real things. You seem to be arguing that unless you can eliminate literally the most bugs of any language in existence, then eliminating any bugs by picking a language that eliminates some of them is a useless endeavor. To me, the reasonable thing would be to choose a place to draw the line and say "anything beyond this is too risky, but I'll tolerate anything that's at least this safe", and memory safety is in practice the place I think it makes sense to do. I don't agree at all that not drawing any line at all is the only logical choice in a scenario when there are multiple places to draw it.
It is absolutely possible that one language might actually have an objectively better approach to memory safety than another, and in such cases it is usually possible to argue for this using sound technical or empirical arguments. But the way the author of Fil-C presents their arguments it often comes across in a kind of antagonistic manner, like he has a chip on his shoulder.
I do not go around posting "omg Rust is SO MUCH BETTER than zig or fil-c, which are TRASH." I talk about engineering tradeoffs, and what matters to me personally. I do not say "if you use Zig, you are a bad person." I am not saying that any comparison is bad. I am saying that the way that the comparison is presented is bad. That is different.
> you declared the exact compromise that Rust makes "table stakes"
Table stakes for me.
> Zig improves on C's memory safety when it comes to spatial safety,
I agree that it's an improvement on C!
> yet you excluded it.
I said that it is not pursuing a design that I personally find compelling enough to use to write software. That doesn't mean that I think it's worthless. This whole thing started off with me talking about how much I respect the Zig project! Yet you're trying to turn this into something where I'm talking shit. I presented a specific technical tradeoff that is important to me. That is very different.
> You're trying to draw some hard line on a spectrum that passes exactly between Rust and Zig, and I'm trying to say that that line isn't there (your attempt at a definition of delineating safe and unsafe code also applies to C)
I don't believe you've shown that. And my "attempt" does apply to C: it fails the bar, because it does not delineate between a safe subset and an unsafe superset.
> you may argue that you're only talking about "memory safety" and not correctness in general,
I am in fact talking about "memory safety" and have been this whole time, yes.
> what gives memory safety value is that violations are causes of many dangerous vulnerabilities; but once, say, Java eliminates all of them, 100% of bugs/vulnerability - which are still numerous - will be caused by other problems, all potentially avoidable with ATS, so why isn't ATS table stakes?
This is just an entirely different question. Yes, there are other forms of safety that are important too. That's just not what we're talking about here.
this is not so far off from hint-mostly-unused, no?
---
Rate-limit edit replying to below response:
> a variety of statements I haven't said
We are in a conversation thread specifically about statements of this nature, which I was contesting. The original poster of this thread called their arbitrary definition of memory safety "table stakes" and explicitly said that they rule out Zig as a language completely on this basis alone. If you don't agree with them, I'm not sure what we're discussing.
> you take issue with the assumptions I'm making ... but you could just state that instead of saying that I'm being hypocritical
The only assumption I disagreed with is assigning Zig to exactly 100 when a poster I was replying to originally asserted a range of "10 or 100"; and regardless of whether we agree on assigning a concrete lower/upper bound to that assumption, everything else is not an assumption but a value judgment given the condition "assuming the premise holds". Yet your position is taking those value judgments - which orders of magnitudes to accept, which to group together as being the same degree of memory safety - and asserting them as objectively correct boundaries with minimal rationalisation beyond "because I feel it is so".
Inherently? No! I commented specifically because I was really glad to see this post. This work that Zig is doing is very good, and I wanted to call that out, in part specifically because I am on "the other side" in whatever sense that is. Why would it be unkind for kind words to be coming from me?
You may well be right. I've yet to learn about it, but I'm planning to.
Ok, so if you meant "table stakes" as an expression of a personal preference and suitability to the programs you write without making an unsupported universal claim such as "this leads to better correctness" or "the price is almost always worth it" then we're good :)
> Yes, there are other forms of safety that are important too.
The thing is that they can be at least equally important, and some affordances for memory safety could potentially _harm_ them. To me, Rust offers little safety in the programs I want to write in a low-level language, but the price it charges in language complexity and implicitness ends up in a negative balance (I can't prove it, of course; as I said, software correctness is very complicated, and some of the greatest researchers in the field were proven wrong on how to best achieve it).
Maybe, but I don't see making a low language viable for something it's not needed as offering much value. Low-level languages are primarily designed to give you direct, low-level control over interaction with the hardware, they sacrifice other things for that goal (including performance [1]), and so if I don't need that control I don't use a low-level language. When I do need that control, I find that Rust requires reaching for unsafe too frequently while still paying the full price for the safety of things I don't use (even Rust's memory management of strings doesn't give me the control I want; I have to work pretty hard for it).
> The cost for memory safety in Java is performance overhead though,
It's not performance (you often gain performance, especially in large programs). It's warmup and footprint.
> But that doesn't change the fact that some languages objectively require you to opt into which parts are memory unsafe, and others don't.
Like I said, C also fits in the category, so it's not a meaningful distinction. The difference is in what you can do in the safe subset. Zig lets you do more things in a safe way than C (where the safe subset is effectively empty), Rust lets you do more safe things than Zig, and Java lets you do more safe things than Rust.
> You mentioned finding the fact that they actually produce real world software that in practice do not suffer from the class of bugs that C/C++ suffers from uninteresting
I didn't say that that's uninteresting; in fact Zig also eliminates spatial unsafety as well as Rust, and I think that's good. I said that merely looking at broad statistics is uninteresting if you don't consider the kinds of programs being written. I.e. Rust gives me safety mostly when I write code with the same level of low-level control as I have in Java, then that's the part I find interesting.
> You seem to be arguing that unless you can eliminate literally the most bugs of any language in existence, then eliminating any bugs by picking a language that eliminates some of them is a useless endeavor.
That's the very thing I'm arguing against. I'm saying that different languages eliminate different bugs at a cost (again, Zig eliminates many memory safety bugs you'd find in C or even C++, arguably the most dangerous ones). What I'm saying is that what you get and whether the price is worth it depends both on the program you're writing and on your personal preferences. Just to be clear, "preferences" doesn't mean I care more or less about correctness, but which approaches to correctness I find more or less effective, something on which there is no consensus.
> To me, the reasonable thing would be to choose a place to draw the line and say "anything beyond this is too risky, but I'll tolerate anything that's at least this safe", and memory safety is in practice the place I think it makes sense to do.
I think it also depends on the kinds of programs you write, because for many programs I write (and for which I pick Java) Rust's level of memory safety is too low, and for the programs I pick a low-level language I wish I could have some cheap memory safety, but it's not offered to me. So in those cases I would prefer Zig's spatial memory safety, as it's no worse than Rust, and not pay the high price for Rust's while getting little in return. Anyway, I'm saying that it's both a matter of which approach you believe leads to better correctness and the kinds of programs you write in the language.
[1]: For example, the fact that in Java, references are not required to be stable machine pointers opens the door to some powerful optimisations that are not available to languages where pointers are required to be machine pointers (or something close enough to them). Or the fact that low-level languages require that the machine instructions executed are those present in the compiled image (or close enough), or, if you want, caring about worst-case performance at the expense of average case performance (although both C++ and Rust specifically don't always make that easy) precludes some other very powerful optimisations. People like me who've spent years on huge C++ programs know that the low-level control offered by low-level languages (regardless of the question of safety) sometimes helps performance and sometimes harms it.
It is true that for various reasons, Rust can't take as much advantage as say, C can.
> I'm guessing this is related to what you mean by potentially being able to do something differently if there were more time before 1.0?
I mean more traditional language design things, but sure, this too.
If you want to have a conversation with me about the things I'm talking about, I welcome it, but I don't see that happening.
But hey, nerd holy wars have existed since the internet began. I use vim btw...oh you use emacs? You're an idiot. Etc etc.
Yes, like I keep saying: two clusters each within an order of magnitude, separated by three orders of magnitude feel to me like two distinct things. That does not at all feel arbitrary. I think that claim is pretty self-explanatory. You appear to think it reduces to "because I feel it so" and in some sense it does. I am applying my own judgement and values in constructing those clusters. Someone who felt that any amount of memory safety was unacceptable would structure them differently. Someone who cared naught about memory safety would similarly group them differently too.
It’s funny, I’ve heard people claim this about rust developers for years. But I’ve seen very little evidence of it. Where are all these toxic comments? Look at Klabnik’s comments in this thread. He’s lovely.
—-
A son comes home to his poverty stricken family with a spring in his step. “Mum! Dad! All that time at community college paid off! I got a job!”. Dad immediately snaps - “so what, now you have a job, you think you’re better than us?”
What happened? Dad is unconsciously projecting a belief onto his son. Something like “unemployed people are shameful”. Then dad feels judged by the projected belief and he attacks the son for it. But it wasn’t the son’s belief in the first place. He just wanted his parents to be proud.
How does the son respond? It’s a tricky one. If the son defends himself by talking up how great it is to have a job, he reinforces the projection and dad will get more angry. If he says “there’s nothing to be proud of for having a job” then he’s lying about his values. It’s a trap.
When I’m feeling uncharitable, I project this same dynamic onto rust and C/C++ devs. “Mom! Dad! I figured out a way to get memory safety without sacrificing native execution and performance!” C: “So you think your language is better than ours? Why are you so toxic about it?”
I’m not really sure how to respond to comments like yours. I think you’re mad at ghosts.
The downside is that functions which are called from multiple dependent crates will need to be codegen'd in each of their dependents, so this can increase compile times if the crate is not "mostly unused."
So it's not quite as powerful as full demand-driven compilation, because of how Rust separates the compilation process into separate crates.
What I mean is a bit different though, it’s that these arguments you get drawn into end up drowning out any real discussion of Zig’s progress. I don’t think that’s your intent but it is frustrating. I should be clear, I don’t think it’s wrong for you to defend yourself from accusations etc., I just wish it didn’t look like this.
I wonder how much better HN would be if they took a page from other forum systems that said “you know what, this whole branch of stuff should be moved over here and renamed so the original topic can move on”.
Sorry, all this may be unhelpful, I don’t know where the line should be, I’m just thinking out loud about the problem.
First of all I respect your point of view - I'm not a Rust absolutist, I think that garbage collected languages are a massive advantage for a lot of things and would never criticise someone choosing a higher level language. Likewise I wouldn't criticise someone choosing Zig or Oden or Jai or even C for tasks where you really need that low level control.
For me, I like to have a single language that I can use for pretty much everything. Afaik there is no other language that is a) popular b) has a modern toolchain with integrated build, formatting & linting etc, and c) can be used both in the kernel and for developing websites. Rust might not be the best choice for most of the spectrum of software, but it's good enough for everything. I can write a low level service + a web server and UI in the same language, where with other choices I would need to use two separate languages. This matters to me because I don't have the time to maintain mastery of multiple languages, I find a lot of value in focusing deeply on one language and learning it completely.
Now I also don't write a lot of low level rust, I've never written a block of unsafe before and I probably write "unidiomatic" rust with too much copying, too many Arc<Mutex>>'s etc. But I like knowing that I can if I need to.
Rust has a lot of other things going for it. A good type system with plenty of nice language constructs that are missing in a lot of higher level languages. It has Cargo and a healthy ecosystem (although I do worry about the number of dependencies used sometimes). And a large community of very smart people. I'm not saying this is exclusive to Rust, but as a whole Rust is a unique language with no alternatives if you value the things I do.
So I would say that it's approach to memory safety threads the needle where it can be used (although not the very best choice) for when you'd use a higher level language, but also gives enough control that you can do plenty of low level stuff in it safely, and with clearly delineated unsafe sections where you really can do anything.
Well, Fil-C and also Cheri show that C is a language that can be implemented with perfect memory safety for 99.9% of the language. This is not true for every language but is also not an accident in C. But also with the typical implementations of C such as clang and gcc you can essentially get spatial memory easily by using safe abstractions.
To me, those are also performance characteristics. Maybe my view on what constitutes "performance" is broader than average here.
> When I do need that control, I find that Rust requires reaching for unsafe too frequently while still paying the full price for the safety of things I don't use (even Rust's memory management of strings doesn't give me the control I want; I have to work pretty hard for it).
Fair enough, I can't tell you that you don't have that experience when writing Rust. It's pretty different from mine though, and the experience of the large number of former C/C++ devs I've worked with after they learned Rust; the only people I've talked to with that experience didn't really try to learn Rust and went in hoping that it wouldn't work for them, which informs my perception here, but I recognize that individual experiences won't always fit into larger trends.
> Like I said, C also fits in the category, so it's not a meaningful distinction. The difference is in what you can do in the safe subset. Zig lets you do more things in a safe way than C (where the safe subset is effectively empty), Rust lets you do more safe things than Zig, and Java lets you do more safe things than Rust.
I don't think I understand what you're saying here. I don't know of a way to turn off undefined behavior by default in C and only opt into it in discrete segements of the code, but maybe I'm missing something.
> That's the very thing I'm arguing against. I'm saying that different languages eliminate different bugs at a cost (again, Zig eliminates many memory safety bugs you'd find in C or even C++, arguably the most dangerous ones). What I'm saying is that what you get and whether the price is worth it depends both on the program you're writing and on your personal preferences. Just to be clear, "preferences" doesn't mean I care more or less about correctness, but which approaches to correctness I find more or less effective, something on which there is no consensus.
It seems like you're arguing against the idea of memory safety as a category at all then. To me, "I can't write code that's memory unsafe without explicitly opting into it" seems like an objective statement, and it's objectively different than "I can't write certain types of memory safety bugs in a given language". I don't really understand what's useful about being able to write memory unsafe code without having to opt in when in practice the number of bugs from mistaken memory safety are overwhelmingly more common than the cases when you're forced to opt into unsafe because Rust forced you to work around the constraints, and even in low-level programs, the actual number of truly unsafe operations you need to do tend to be fairly low in my experience. I guess I can't say for certain that you don't truly need to do things that you're forced to write unsafe for too often, but to me, it seems like you're refusing to pay a pretty small price for mostly ideological purity rather than pragmatism.
> I think it also depends on the kinds of programs you write, because for many programs I write (and for which I pick Java) Rust's level of memory safety is too low
> [1]: For example, the fact that in Java, references are not required to be stable machine pointers opens the door to some powerful optimisations that are not available to languages where pointers are required to be machine pointers (or something close enough to them). Or the fact that low-level languages require that the machine instructions executed are those present in the compiled image (or close enough), or, if you want, caring about worst-case performance at the expense of average case performance (although both C++ and Rust specifically don't always make that easy) precludes some other very powerful optimisations.
I'm struggling to imagine what the circumstances are where these are genuine concerns rather than theoretical or premature optimizations. What are some examples of programs where you'd get better characteristics running them if they were written in Java rather than Rust due to the lack of enough "memory safety" in Rust?
This is both true and not the whole story. In C, the file (okay if you want to get REALLY technical it's not the file but I'm talking about 99.9% of computers and not some old mainframe platforms) is the compilation unit. You pass a .c in, you get a .o (or whatever for your platform) out. Producing a final binary is where the static vs dynamic choice really comes into play, but you end up with these intermediate artifacts because that is how the language is defined.
> I would have expected that when static linking is the default, the argument for having standalone intermediate libraries is much weaker,
It is weaker, sure. But that doesn't mean there aren't other advantages. For example, you can more easily parallelize a large workload by breaking it up into multiple intermediate libraries, and compiling those simultaneously, whereas doing it all in one compilation/translation unit requires compiler support for said parallelization. Especially if you're already writing a batch compiler, this is a much easier win than rearchitecting the whole thing.
The serious point: I care about whether the program I write aborts regularly, whether due to a Rust panic or a capability violation.
For low-level programs, I already said that Rust doesn't offer much safety for the things I reach low-level languages for (or, conversely, its safe subset doesn't offer the very control I'm after in such a language). Furthermore, the complexity and implicitness of the language make it harder for me to carefully understand the kind of subtle code I write in such programs. The long build times could mean I write fewer tests.
For high-level programs, Rust's safe subset is technically sufficient, but the problems are even worse (and exactly match C++'s): High level Rust code looks quite good and is easy to write, same as in C++, but the problems start with the maintenance and evolution. Small local changes - to a returned object's lifetime or thread-share ability, or between static and dynamic dispatch - require non-local changes. That's because low-level languages have low abstraction, i.e. the same contract covers fewer possible implementations. True, unlike C++, Rust tells you what things you need to change, but you still need to change them. That was the main problem we had with C++: the code looks great and it's very easy to write at first, but the maintenance and evolution costs - especially when the program is large and long-lived - get high and remain high forever. Furthermore, once a program grows large, it starts suffering from similar performance ovhearheads large C++ programs suffer from: you find yourself needing more dynamic dispatch, which is slow in Rust and C++; you find yourself needing more shared objects with different lifetimes, which are also slow in those languages, so the program isn't even particularly fast or scalable (sure it's faster than a JS or a Go program, but that doesn't say much). Java (or C#) which is aimed at optimising the performance of large programs, removes many of these overheads. Lastly, deep always-on observability/profiling isn't quite poor (it's better now with eBPF, but still a long ways away from what you get with Java or C#).
So yes, Rust and C++ are intended as "one language for everything", and Rust is probably somewhat better than C++, but your high level programs pay for the low level feature (i.e. suffer from the maintenance and performance costs of low-level languages), while your low-level programs pay for the high-level features (the complexity needed for implicitness and safety). So yes, you can do everything, but rarely as well as could be done, and while I see the value in getting expertise only in one language, 1. it's a language that requires a lot of expertise as its "multi-functionality" makes it very complicated, so much so that you could probably become an expert at two more specialised languages for not much more effort, and 2. I think that if you really need to write low-level code, e.g. you're writing a kernel or a hardware driver or a controller or a GC, then expertise in the domain dwarfs expertise in the language anyway (i.e. we're talking years of required experience until you're really good at it).
BUT I acknowledge that the weight I assign to these things is subjective, and I'm certain others reach the opposite conclusion through arguments that are no less reasonable than mine.
Yes, but they come with speed gains, so you can't say that you pay "performance overheads" when Java removes some of the performance overheads that programs in low-level languages and replaces them with others. You could similarly say that you pay performance overheads when going in the other direction.
> and the experience of the large number of former C/C++ devs I've worked with after they learned Rust
And it's not my experience or a large number of C/C++ devs I work with.
> the only people I've talked to with that experience didn't really try to learn Rust and went in hoping that it wouldn't work for them
Then your exposure isn't wide enough.
> I don't think I understand what you're saying here.
What I'm saying is that we can't say that the value is merely in the existence of a clear syntactic distinction between safe and unsafe code, because that distinction exists in C, only in C, the clearly delineated line between safe and unsafe code is that between `int main(void) {}` and anything that isn't that; i.e. any program other than that explicitly opts into unsafety. So any meaningful discussion about memory safe languages must include what you can do in the safe subset. In C's "safe subset" (the empty program), you can do nothing, and that's what makes it not valuable. But for my needs, what you can do in Rust's safe subset (compared to both Java and Zig) is also far too little (to justify the cost).
> To me, "I can't write code that's memory unsafe without explicitly opting into it" seems like an objective statement
It is, but what I'm trying to say is that it alone doesn't have much value. In C you also "can't write code that's memory unsafe without explicitly opting into it" by writing anything other than the empty program, but obviously you wouldn't consider C's memory-safe subset suitable because you can't use it to do what you want to do in C. Rust's value is not, therefore, in that it has a memory-safe subset, but that it has a useful memory-safe subset. It's just that the utility of that subset depends on the kinds of programs you'd want to use a low-level language in the first place.
> it seems like you're refusing to pay a pretty small price for mostly ideological purity rather than pragmatism.
Quite the opposite. The price of Rust's complexity, implicitness, and compilation time is too high for what little safety I get in return, that I don't want to pay it for pragmatic reasons.
> I'm struggling to imagine what the circumstances are where these are genuine concerns rather than theoretical or premature optimizations. What are some examples of programs where you'd get better characteristics running them if they were written in Java rather than Rust due to the lack of enough "memory safety" in Rust?
It's nothing to do with memory safety. Low-level programs sacrifice optimisation opportunities available to Java because above all else they need to offer low-level control. That low-level control can translate to good performance sometimes (especially in smaller programs), and sometimes it translates to worse performance (especially in large programs). The huge C++ programs I worked on migrated to Java not (just) for safety but also for better performance than C++ (again, it's easy to get excellent performance in low-level languages when the programs are small or specialised; it gets harder and harder as they grow). So we got better performance than C++ while also getting better safety than Rust, a much simpler language than Rust (or C++), and faster build cycles than Rust (or C++). But the topic of how Java reduces the overheads that C/C++/Rust/Zig programs often have when they grow large (although Zig makes it easier than the other them to reduce them) is a whole complicated topic. I might give a talk about it at the upcoming Devoxx.
I'm not sure I understand your point about dynamic dispatch being slow in Rust/C++ or shared objects? If you're targeting native (which I find important) neither Java or C# are going to be faster surely. Maybe if you're willing to run Java/C# JIT you might find some wins (skeptical it's faster across the board) but you also don't need dynamic dispatch in performance-critical areas. I rarely reach for a Box<dyn Something> even in my high-level code.
I haven't worked on large Rust projects (> 500k loc) so I can't speak to the maintenance costs of that, but for me it doesn't matter (at least yet).
But we see even experienced professionals making mistakes with low level languages and I think it's worth considering if it's worth some of the cons you bring up to avoid those. Kind of reminds me of Carmack talking about static code analysis years ago:
> The more I push code through static analysis, the more I’m amazed that computers boot at all.
> And it's not my experience or a large number of C/C++ devs I work with.
>> the only people I've talked to with that experience didn't really try to learn Rust and went in hoping that it wouldn't work for them
> Then your exposure isn't wide enough.
Or maybe your exposure is only to people who didn't give it a fair chance? I don't know how either of us can be confident that we know 100% for sure that our sample is more definitive.
> What I'm saying is that we can't say that the value is merely in the existence of a clear syntactic distinction between safe and unsafe code, because that distinction exists in C, only in C, the clearly delineated line between safe and unsafe code is that between `int main(void) {}` and anything that isn't that; i.e. any program other than that explicitly opts into unsafety. So any meaningful discussion about memory safe languages must include what you can do in the safe subset. In C's "safe subset" (the empty program), you can do nothing, and that's what makes it not valuable. But for my needs, what you can do in Rust's safe subset (compared to both Java and Zig) is also far too little (to justify the cost).
> It is, but what I'm trying to say is that it alone doesn't have much value. In C you also "can't write code that's memory unsafe without explicitly opting into it" by writing anything other than the empty program, but obviously you wouldn't consider C's memory-safe subset suitable because you can't use it to do what you want to do in C. Rust's value is not, therefore, in that it has a memory-safe subset, but that it has a useful memory-safe subset. It's just that the utility of that subset depends on the kinds of programs you'd want to use a low-level language in the first place.
That seems like an absurd false dichotomy in the form I was talking about before. I don't seriously believe that you can't easily identify when looking at Rust code whether unsafe is explicitly being allowed in it or not, or that you are writing programs that are doing things that would require unsafe literally everywhere.
I've genuinely been trying to understand where you're coming from, but the more I try, the more it seems like you just genuinely seem to think that you're too smart to accidentally write memory safety bugs, or that the memory safety bugs don't matter much. Maybe you're right, but I don't think there's anything left for me to learn from your point of view.
I get it. The language certainly does appeal to some people, and I can understand why, just as I understand why it does not appeal to others.
> I found myself wondering why I had to keep track of lifetimes, nullability etc in my head when it was so easy to mess those up.
And I agree with that, but my conclusion (after decades of experience with low-level programming) is somewhat different: Don't reach for a low-level language unless precise low-level control over the hardware is the exact thing you're after. And when that is the case, I find that safe Rust doesn't offer the control I need, and unsafe Rust (and/or a lot of custom code) is not what I want to use.
> Maybe if you're willing to run Java/C# JIT you might find some wins
Of course I use the JIT. That's exactly what it's for. Now, I don't care if the buffer from which the CPU reads instructions is memmapped from a file or generated by a JIT, but I do know that some people like the "single native file experience". To that end, we're working with Google to add a small feature to the JDK that would allow it to link the JVM, other native libraries, and Java classes into a single native executable (it's still going to JIT the Java code, but you'd be launching a "native binary").
> (skeptical it's faster across the board)
I wouldn't say it's faster across the board. You sometimes can write large programs in C++ (or Rust) that match and even exceed Java's performance, but it gets harder and harder the larger the program is. On average, I find that the "effort per performance" is, on average, significantly lower in Java in large programs. And it's not just the JIT. Another weak point of low level languages is that their pointers can't move, which means they can't use moving collectors, which also offer superb efficiency, again, mostly in large programs when you have lots of objects of varying sizes and lifetimes (especially now when we no longer have GC pauses).
> but you also don't need dynamic dispatch in performance-critical areas
You certainly don't start out needing it. Over time, however (and important codebases last at least 15-25 years), it either creeps in or it affects sufficiently many less critical paths to make an impact. You can try and re-architect things, but it takes a lot of effort (and it's this evolution effort that was a major reason for C++'s decline).
> But we see even experienced professionals making mistakes with low level languages
Absolutely, but my prescription would be to avoid low level languages altogether, and that has indeed been the industry's trajectory, and it's continuing. And when you absolutely do need to kind of control that low level languages offer, language complexity can also cause (or help hide) mistakes in code that is often very subtle, and the added safety, which is partial at best in those situations, isn't enough to offset that. Again, this isn't universal, but there are reasons to avoid Rust in low level code that are just as good as the reasons to pick it, and so different people will choose differently.
You don't need unsafe "literally everywhere" to run into issues. First, what matters most are the areas that are most subtle/tricky in your program. If in those areas Rust doesn't add much safety and makes things worse due to language complexity, that's a problem. Second, when you want low-level control, you might well want it in quite large swaths of the code. For example, one thing that low-level languages currently, in principle, do better than Java is arenas. But the whole point of arenas is that you want _all_ allocations in some large and elaborate call chain to go in the arena (and you'd like to enjoy both the standard library and 3rd party libraries). Rust doesn't make that easy (and neither does C++, for that matter).
> the more it seems like you just genuinely seem to think that you're too smart to accidentally write memory safety bugs, or that the memory safety bugs don't matter much
I don't see how you've reached that conclusion. I told you that for most programs I choose a language that is more memory-safe than Rust, and when I choose a language that's less memory-safe than Rust it's when Rust doesn't offer much safety, either.
See, this is exactly the thing I find so annoying in the Rust discourse. There's no doubt Rust significantly helps avoid memory safety issues (i.e. Rust => more men-safety) but that doesn't mean that caring about memory safety issues means preferring Rust (more mem-safety => Rust). One simply doesn't follow from the other because the logical implication is reversed.
As a member of the Zig core team, one of the most impactful projects I’ve been involved with is the implementation of incremental compilation into the Zig compiler. This feature allows the compiler to detect which individual functions and declarations have changed since a project was last built, recompile only that code, and directly patch the resulting bytes into the output binary, making the rebuild extremely fast.
The Zig project has been working towards this feature for a long time, and over the last few release cycles, it has finally gone from a proof-of-concept quality feature to one which is viable for real-world projects and which most of the Zig core team makes daily use of.
Today, using Zig’s incremental compilation, you can make changes to real, complex applications in a matter of milliseconds.
But don’t just take my word for it! Here’s a simple video (no audio) demonstrating me using Zig to quickly make and test some changes to Fizzy, a pixel editor application. The initial build takes around 5 seconds, and then every time I make a change, a rebuild completes in 50–70ms.
For this demo, I had to upgrade Fizzy to Zig’s master branch. This is because while Zig 0.16.0 does have support for incremental compilation, it is missing some important linker features which have since been implemented. This means that if you prefer to stick to tagged releases of Zig, you likely won’t be able to try this out until 0.17.0 drops; sorry!
Fast incremental rebuilds for some random changes to Fizzy
If you’re already convinced and just want to know how to use this, great! Head on down to the last section of this post to find out. But perhaps you’re understandably skeptical that this is applicable to most projects, or, like me, you just enjoy learning how stuff like this works. For all of you folks, let’s dig into the details!
The Zig compiler’s pipeline can be split up into a few different parts, which we’ll look at in order. The first part works at the granularity of entire source files, and basically consists of running the following process in a loop:
If you’re curious, ZIR (Zig Intermediate Representation) is an untyped SSA-form IR—but don’t worry if you have no idea what that means, because it won’t really matter here. All we care about is that we’re converting an entire source file into a different format.
While AstGen runs, it learns about all Zig imports (@import("foo.zig")) in the source file, so we can repeat this entire process on all of the imported files. So by running this process in a loop, we will ultimately discover every Zig source file in the compilation, and will convert them all to ZIR.

File processing pipeline in the Zig compiler
This part of the pipeline actually has several useful properties:
Parse and AstGen are both quite fast on their own: on my laptop, running them both over the entire src/ directory of the Zig compiler (with no parallelism at all) takes around 920msThese properties have two nice consequences.
Firstly, assuming one “task” per source file, this entire process is embarrassingly parallel. That means we can trivially run it on a thread pool by queuing up a task every time we discover a new source file from an import—the only shared state (which we’ll just protect with a mutex) is a hash set keeping track of which file paths we have already seen.
Secondly, and arguably even more importantly, these properties make it very straightforward to implement incremental compilation for this part of the pipeline. All we need to do is cache each source file’s generated ZIR on disk, and only rebuild it when we detect that the file changed.
Both of these optimizations have been enabled by default in Zig for years—they are battle-tested and make this part of the pipeline near-instantaneous in most cases. If you’re using Zig, you can see how fast this is using the progress output on stderr—when it says “AST Lowering”, this part of the pipeline is running. I’d guess that a lot of Zig users only even notice that happening the very first time they run the compiler (because on its first run the compiler needs to do this work for the entire Zig standard library and compiler_rt).
Okay, so, we made this part fast! That’s great, but the bad news is that this was the easy part—lots of compilers can already do this kind of caching. From here, things will get trickier.
The next part of the pipeline is arguably the most important: semantic analysis. This includes both type checking and comptime evaluation.
The job of semantic analysis is essentially to “interpret” the ZIR we produced earlier, emitting compile errors (such as type errors) along the way; and, for runtime functions, building another intermediate representation which can be sent on to later parts of the pipeline.
Before we move forward, a quick terminology clarification. A “container-level declaration” is the Zig equivalent of what other languages call a “top-level declaration”. That term is inaccurate in Zig, because container-level declarations do not have to be at the top level syntactically, but the concept is the same. If I say “container-level declaration”, I basically mean “a function, global constant, or global variable”.
Semantic analysis is the most difficult part of the compiler to handle incrementally. Perhaps unsurprisingly then, this is where language design starts to matter a lot: while I am pretty confident that most modern languages could support incremental compilation similar to how we do, certain design decisions can make that much more difficult. Zig has had its design tweaked over the years (sometimes controversially) specifically so that it is easier to support fast incremental compilation.
The name of the game here is to split up your compilation into a bunch of pieces which you can mostly analyze independently of one another, and, crucially, where the dependencies that do exist between those pieces can be easily modeled in a dependency graph.
In the Zig compiler, we call these pieces “analysis units”, or I might sometimes just say “unit” for short. I’m going to ever so slightly simplify things here and tell you that the Zig compiler has four different kinds of analysis unit:
struct or union type.const declaration.During semantic analysis of a particular unit, we populate a set of other units which this unit depends on. Let’s look at a basic example:
var global_0: u32 = 123;
const global_1: u32 = 456;
pub fn foo(cond: bool) u32 {
if (cond) {
return global_0;
} else {
return global_1;
}
}
Here’s what happens when we analyze the body of the function foo:
cond is not comptime-known, we semantically analyze both branches of the ifglobal_0, in preparation to load from itglobal_0global_0 at runtime, because it is var so does not have a comptime-known valueglobal_1, in preparation to load from itglobal_1global_1 at compile time, because it has a comptime-known valueglobal_1So we end up with this function body depending on the types of global_0 and global_1, and the value of global_1 (since that’s comptime-known). This tells the compiler that if the type of global_0 or global_1 changes, or the comptime-known value of global_1 changes, the function should be re-analyzed.
Dependencies on the body of a runtime function are impossible (at least in the simplified view I’m presenting here). This means that function body analysis units can only have “outgoing” edges in the dependency graph (i.e. they may depend on other units, but other units do not depend on them).
Dependencies on the value of a const declaration only arise due to Zig’s ability to use those at comptime. If not for that language feature, dependencies on the value of a declaration would be impossible, just as it is impossible to depend on the body of a runtime function.
Dependencies on a type’s layout arise, in short, from having values of that type, or from needing to know something about the type’s layout. I’m not going to discuss this any further here, because it’s a bit complicated and quite specific to Zig’s type system, but it’s not fundamentally different.
Okay, so, we’ve told the compiler about when re-analysis of one thing needs to also trigger re-analysis of another thing. However, there’s one more puzzle piece here—source code dependencies. By itself, this dependency graph is useless: what do we actually do when the user asks for a recompile (what we call an “incremental update”)? We don’t know the first thing to re-analyze!
To solve this problem, we track dependencies of analysis units, not only on other units, but also on pieces of source code. In the cases we’ve looked at so far, these are all really simple: in the snippet above, the units “type of global_0” and “value of global_0” both depend on the source code of global_0, the unit “type of global_1” depends on the source code of global_1, and the unit “body of foo” depends on the source code of foo. Whenever any byte of source code in the given region is modified, the dependent analysis unit will be marked as “outdated” and re-analyzed.
Note that in reality, things can get more complicated than each unit depending on one piece of source code. For example, an inline function call in Zig performs semantic inlining, which means that it essentially triggers semantic analysis of a different piece of code but in the caller’s analysis unit. Therefore, inline function calls introduce dependencies from the caller’s analysis unit on the source code of the callee.
Of course, we still need to be able to figure out which regions of source code have changed since an incremental update. For this, ZIR contains hashes for specific “interesting” regions of source code (e.g. the entire source code for each container-level declaration), and those hashes are what you are actually depending on. If the source code changes, the hash changes, and that’s easy for the compiler frontend to detect.
Okay, that was a lot of explaining—now let’s look at some pretty pictures! Here’s some Zig source code:
const lucky_number = 42;
const S = struct { x: u32 };
fn getSomething() S {
return .{ .x = lucky_number };
}
fn testLuck(x: u32) void {
if (x == lucky_number) {
// do something
}
}
export fn entry() void {
const result = getSomething();
testLuck(result.x);
}
…and here’s its dependency graph (with some redundant edges removed for legibility). The nodes on the right represent the source code which has been hashed, while the remaining nodes are all analysis units.

Dependency graph generated during initial build
Now, let’s say we change the first line of the file to read const lucky_number = 43;. First, the compiler lowers the new ZIR for this file. It maps declarations from the old ZIR to the new ZIR based on the declaration names, and compares the source hashes associated with each declaration. In this case, it successfully maps every declaration, and it sees that one source hash changed—the one associated with const lucky_number. Next, it looks at the dependency graph to find everything which depends on that source hash. In this case, it only finds one direct dependency:

Invalidated dependencies after change to source hash
So, the compiler re-analyzes the value of the lucky_number declaration. If our change to the line had been a no-op (e.g. we just added some whitespace), then it would determine that the value did not change, and stop here. But in this case, the value did change! Therefore, the compiler continues this process, by next considering any analysis units which depend on the value of lucky_number, of which there are two:

Invalidated dependencies after change to lucky_number value
The compiler analyzes those two units—the bodies of testLuck and getSomething. There are no dependencies on these units (since they’re function bodies), so the semantic analysis loop stops here. However, semantic analysis of those functions does generate new AIR, which brings us neatly to our next topic: code generation.
Code generation, sometimes called “codegen” for short, is the stage in the compiler pipeline where AIR from semantic analysis is converted to something resembling machine instructions. Codegen doesn’t quite emit machine instructions yet—instead it’s something called MIR (Machine Intermediate Representation)—but there is almost a 1–1 mapping between MIR instructions and machine instructions. There are separate codegen implementations for each target architecture (x86_64, aarch64, etc).
A nice thing about codegen is that just like the whole-file processing earlier, it is an embarrassingly parallel task (at least in builds where you aren’t doing inter-function optimizations like inlining). There is no state shared between code generation of different functions, so we can have a queue of pending functions whose AIR needs converting to MIR, and process that queue across arbitrarily many threads. There’s just one small gotcha, which is that we need to be careful to cap the size of that queue, because if codegen is ever running behind semantic analysis for any reason, the size of the queued-up AIR can add up fast!
In terms of incremental compilation, this phase of the pipeline is actually as simple as it gets, because AIR and MIR both exist at the granularity of individual functions, which is the same granularity incremental compilation works at. This means that there is no need for the compiler to cache AIR or MIR at all! The AIR is thrown away as soon as code generation is done, and the MIR will be thrown away right after it’s consumed by our next stop: the linker.
Incremental linking is kind of a difficult problem, and I suspect is a big reason that no other major toolchain supports this kind of incremental compilation yet. General-purpose incremental linkers aren’t really a thing at the moment, and though wild was originally conceptualized as one, that project seems to have shifted its focus firmly towards cold-link performance over the past couple of years, with no explicit timeframe for incremental linking.
David Lattimore, the creator of wild, has a blog post discussing some of the difficulties of incremental linking. One of those is diffing input objects to figure out what actually changed on an update. However, when you control the entire compilation pipeline, a simpler design presents itself which neatly sidesteps that entire problem: tightly integrating the linker with the compiler.
To begin with, let’s just look at how the linker might work without incremental compilation. Because linking involves a lot of shared state, our linker is entirely single-threaded (maybe we’ll look into multi-threaded linking in the future, but for now we’re keeping things simple). When the linker receives MIR from codegen, it first needs to convert that MIR into the actual machine code. This logic is specific to the codegen backend, but we can’t run it until now because it requires cooperation with the linker. That’s because while emitting machine code, the codegen backend generates relocations—basically, instructions for the linker to overwrite certain parts of the code with specific addresses or values (for instance the address of another symbol). The linker needs to save all of these relocations internally, so we need to be on the linker thread for this.
After generating the machine code and associated relocations, we reserve space for that machine code in the output section (usually .text). We save the machine code in a buffer, save the relocations to apply later, and do some miscellaneous bookkeeping work, such as adding a symbol table entry.
For a non-incremental linker, this would be the end of the story. At the end of compilation, we would assign addresses to every section, write out everything we reserved space for, and apply all of the relocations. Incremental linking is a bit trickier—writing the machine code to the file, assigning addresses, and applying relocations, all ideally needs to happen before we know the full contents of the binary, and we need to be able to update those things later.
A lot of the complexity here is actually just in moving things around. For example, if we want to add a function to the .text section, but there isn’t enough space, we need to expand that section. But the section might be surrounded by other sections, which we can’t just overwrite, so we’ll need to move something—either the .text section itself, or one of the surrounding sections. In doing so, we’re going to change not only file offsets but also virtual addresses of everything we move—this means we’ll need to update symbol table addresses, re-apply relocations, etc. That’s a lot to keep track of! (There’s also a similar problem for segments, one level up.)
To solve this problem, Jacob Young introduced a nifty abstraction into the Zig compiler called link.MappedFile. It memory-maps the output file, but more importantly tracks a tree of “nodes” in that file. The root node covers the entire file, and child nodes refer to specific regions within their parent node. The API user can add nodes, or grow a node to a given size—in both cases, if there is not space in the parent to trivially perform the operation, MappedFile deals with moving other nodes around to make space. Whenever it resizes or moves a node, the implementation sets a “dirty” flag on that node, so that at some point the linker implementation can detect this and apply any necessary fixups, e.g. re-applying relocations whose target moved.

Resizing a node in MappedFile
Right now, MappedFile has fairly primitive logic for node allocation, so sometimes makes suboptimal decisions—but because we’ve abstracted it behind a neat little API, we can improve it independently going forward.
To get to incremental linking, then, we need only slightly change the process I described earlier. After we finish emitting machine code, we create a node in the mapped file, large enough to hold the code—or if this function already existed, we just resize the existing node—and we copy the machine code into it. Allocating this node in the file might (in rare cases) need to move some other stuff in the file around, in which case the appropriate “dirty” flags are set on those nodes. We always set the “dirty” flag for the function’s node itself, so that its relocations will be applied at some point.
Because of the pending relocations, we probably don’t have a valid binary right now—but that’s okay! When the linker thread is next idle (i.e. its work queue is empty), or at the end of compilation if the linker thread remains busy until then, we’ll check all of those “dirty” flags and clean up after ourselves. This could involve work such as assigning new virtual addresses, updating the section headers and program headers, updating addresses in the symbol table, and re-applying relocations.
It might sound like that “fixup” work is expensive. Sometimes, it can be—if you’re creating a dynamic executable and the PLT has to move, that can take a moment, because there are usually a lot of relocations targeting the PLT. However, most of the time, we don’t need to move anything! By using exponential growth factors on nodes (similar to how dynamic data structures like ArrayList work), we amortize this cost and make it extremely rare in reality (at the cost of a slightly increased binary size, which isn’t usually a major concern during development). This design means that you might very occasionally see one update run slightly slower than usual (maybe a few hundred milliseconds?), but I’ve not personally hit this a single time, despite using incremental compilation with this linker near-daily for the past couple of months.
Okay, we’ve made it to the end, and kept everything incremental along the way. Files were lowered to ZIR with a simple per-file cache; semantic analysis of declarations kept track of a dependency graph to figure out what might have changed; code generation re-ran only for updated functions; and our linker wrote new code into the file without changing any other bytes. We just have a few more loose ends to tie up.
Firstly, because of how Zig’s “lazy analysis” feature interacts with incremental compilation, we need to do a graph traversal to figure out which functions/declarations/etc are actually referenced. It’s possible that something was referenced on a previous incremental update (so we compiled it), but has since become unreferenced, which means we need to ignore any compile errors it emitted, not perform symbol exports from it, etc. There are probably some optimizations you can do here, but at least right now, we just traverse the full reference graph on every update. We can get away with this even on big projects, because computers are really fast!
Once we’ve figured out what’s referenced, we can tell the linker every global symbol which is exported from Zig code, so that it can add any necessary entries to the symbol table. We will also report compile errors if there are any, and some other miscellaneous tasks like that. Finally, we call the linker’s flush function, whose job is just to do any remaining linking work before the file is closed. If the linker has any MappedFile node still marked as “dirty”, we’ll need to handle that, but otherwise we want to do as little work as possible—remember, anything we do here is going to happen on every update, so we want to keep it pretty much O(1). Therefore, all that the ELF linker really does here is write out the .dynamic section, and write the entry field in the ELF header.
We then close the file, and the compilation is complete!
Explanations are cool and all, but we can actually see this happening. Tracy is a real-time profiler—it’s designed for games, but you can integrate it into anything. The Zig compiler has optional Tracy integration, enabled using a build flag. (I guess incremental updates kinda resemble frames in a video game if you squint?)
This can occasionally be useful for various compiler performance analysis, but I actually find it really cool to use for incremental compilation, because we can see the different parts of the compiler pipeline clear as day. Let’s take a look at the Tracy output for a change to Fizzy, much like the changes in the video from earlier. This particular update took 37ms (a little faster than the ones we saw in the video), but at first I’m going to zoom in on the first 6ms or so of this 37ms update—I’ll explain why later.

First 6ms of an incremental update, visualized in Tracy
At the start we can see a flurry of activity across all threads—that’s the thread pool doing all of the per-file work. Although we only changed one file, the compiler doesn’t assume that, and instead checks every source file. There’s one small optimization here, which is that because we already know which source files were in the compilation on the last update, we can guess that those files will all still be reachable and so check for changes to all of them. That just means we don’t need to wait for the first file to be processed so that we can discover its imports.
Next we see a good chunk of time (around 1ms) in computeAliveFiles. This function is traversing the graph of file imports to assign every file to a Zig “module” (because it’s possible for a file to move from one module to another between updates). We also use this import traversal to check whether all source files are, in fact, still in the compilation. If any are not—because all imports of them were removed—then we’ll basically just ignore those files for the rest of this update.
Then we have another millisecond in updateZirRefs. This function is responsible for correlating the old and new ZIR of any changed files, and updating all internal references to ZIR instructions to refer to the instruction’s index in the new ZIR rather than its index in the old ZIR. This is a fairly simple task, but the current implementation involves iterating every ZIR instruction we hold a reference to at all and completely rebuilding a hash map’s metadata. This can probably be optimized.
Now we’re done with the single-threaded per-file stuff, and we can finally get onto the meat and potatoes of the pipeline: semantic analysis, codegen, and linking. The “sema_loop” zone contains all of the time spent in semantic analysis—around 1.2ms. We then see that function’s AIR get picked up by codegen on a different thread (the green zones named runCodegenInner), which runs for around 240us. The resulting AIR is picked up by the linker thread and emitted to the binary—this linking work (the purple emitFunction zone and the little green zones next to it) takes around 170us. Overall, this entire part of the pipeline—which by far dominates cold builds—comes in at around 1.6ms for this update. Not bad!
After that’s all done, we’re onto flush. The little purple zone at the bottom-right is a small bit of linking work the frontend requests during flush: regenerating a lookup table which we use to implement Zig’s @errorName builtin. That takes around 50us, and brings us to the end of the 6ms region I’ve zoomed in on. That means it’s finally time to zoom out and see what the remaining 31ms are…

Full 37ms incremental update, visualized in Tracy
Basically all of the remaining time is spent in one function, resolveReferencesInner. Remember a bit earlier I mentioned doing a graph traversal during flush, to determine which Zig declarations are referenced? Well, that’s this function’s job! It’s not inefficient by any means, but that graph is kinda big, so it’s perhaps unsurprising that it starts to matter when we’re trying to go fast.
On the one hand, this seems pretty silly, so much so that I seriously considered trying to improve it before putting out this blog post (after all, a 7ms time is more impressive than a 37ms time). This is amplified when you consider that the reference graph didn’t actually change here. So the vast majority of the duration of this incremental update is being spent figuring out that a graph didn’t change!
But actually, I think this is really cool, because it shows how much efficiency is still left to squeeze out. This 30ms zone is realistically not a big issue, but we can get rid of it nonetheless—firstly by avoiding recomputing this data when the reference graph is unchanged, but also by only recomputing what we need to when the references do change (that problem is called “dynamic single-source shortest path” and is a fairly well-studied problem in graph theory).
Basically, we’re far from done on the performance front! If you want to keep up with what we’re doing in the future, you might consider adding the Zig devlog to your RSS reader, or checking out the release notes when new versions of Zig are released.
Okay, I’ve been rambling about compilers for long enough; let me actually show you how to use this thing. I’ll assume you have a Zig project with a build script, and that it compiles on a recent master branch build of Zig (or, if you’re reading this after Zig 0.17.0 releases, that’ll also work.)
At the time of writing, this will only really work if you target x86_64-linux, because our other code generation and linker backends are not mature enough yet. The majority of the Zig core team runs Linux on x86_64, so by focusing on it first, we’ve sped up our workflows, meaning it’ll be faster for us to add support for other targets—which is now top priority!
The bad news is that right now, this isn’t zero-effort. Eventually it will be—we’ll cache all of the compiler state to disk and automatically reload the last saved state when you run zig build, so incremental compilation will just happen automatically—but we’re not quite there yet. However, the good news is that using it today requires very little work!
The short version is that you just need to run this command:
$ zig build --watch -fincremental
The --watch argument tells the Zig build system to watch the filesystem for changes to your source files, and trigger rebuilds when they happen. The -fincremental argument tells the build system that when it does one of those rebuilds, it should use incremental compilation.
When you run that command, you may notice that even if you have a warm cache, every executable/library/object in your project will be rebuilt anyway. That’s expected behavior, because the existing caches on disk are not compatible with incremental compilation. However, if this is inconvenient, then you can modify your build.zig to ask the build system to only use incremental compilation for a specific compilation:
// expose a '-Dincremental' option
const incremental = b.option(bool, "incremental", "Enable incremental compilation") orelse false;
// if it was given, enable incremental compilation for 'exe'
if (incremental) exe.incremental = true;
Then you can use -Dincremental instead of -fincremental, and it’ll only do the full initial rebuild for that particular step. (The -fincremental option was basically telling the build system to set this flag on every std.Build.Step.Compile.)
Anyway, after the initial build is done, just edit one of your source files, save it, and you should see the results of the rebuild instantly. Assuming there are no compile errors, you’ll find the updated executable in zig-out/ as usual. That’s all there is to it!
If you combine --watch with a build step which runs your program (e.g. zig build run --watch -fincremental), your program will run every time the build finishes. For short-running programs, that’s probably exactly what you want. However, for long-running programs (e.g. graphical applications), be aware that right now, the build system will only be able to trigger incremental rebuilds after the previous build of your program closes. Depending on your personal workflow, that might be fine for you (perhaps you’ll just close the application every time you want a build), but if not, you might prefer to do a normal build and manually run the program from zig-out/. If this is you, don’t worry—we’re already planning a bunch of enhancements to the build system to unlock other workflows!
As with all of the Zig project, incremental compilation is not yet stable. While it works pretty well, there are definitely bugs right now, probably including some false-positive compile errors and even miscompilations. If you run into any problems while using incremental compilation, please do open an issue on the Zig repository if you can—the more bugs we’re told about, the more we can try to get fixed for the next release!
Thank you to all of our users who have helped to try out this feature so far, and to anyone who tries it after reading this post—it’s great to see this working for so many people. Thanks also to everyone who donates to the Zig Software Foundation: it’s a true privilege to be able to spend my time working on cool stuff like this.
And of course, thanks for reading :^)