Posted by ksec 3 days ago
By memory-safety I mean the basic 'no crashes and corruption' version, that's fulfilled by Java, Go etc., but not by C++ and Zig.
I'm a C++ dev among other things, and I have enough experience to know, you really can't hand C++ to a novice dev (even one who uses smart pointers correctly), and expect an app with no memory related crashes/issues.
For this reason only, I generally thing C/C++/Zig is a language for typical professional projects/applications (which are written usually in GC languages nowadays).
I think it's a hard cutoff criteria. Most programmers/orgs simply cannot work with memory unsafe software. Most commerical C++ codebases leak.
This is basically the most important and common 'niche' of SW dev, which for some reason has been somewhat neglected, and I don't really consider Rust a great fit here either, but it doesn't lack anything that would disqualify it.
Swift would be another good choice, but it seems that language is very tied to the Apple ecosystem.
The only (non-Apple) language that understood EXACTLY what these people wanted imo was Object Pascal/Delphi in the 90s to early 2000s but seem to have died out unfortunately.
Ada/SPARK comes close: memory safety is very strong, formally provable. Has no GC. Excellent performance. Very mature.
However that perception tends to mean it’s only heavily used there.
https://github.com/Syzygies/Compare
So you're in a restaurant where you don't speak the language, you can't read the menu, but you see three price points for set meals featuring the house specialty. (Say, "Crossing the Bridge" noodles in Yunnan.) Which do you choose? My tour guide, the author Fuchsia Dunlop, later agreed with me this is obvious: The middle choice.
So you're choosing between Go and C23 as candidate successors to K&R C. They both have "royal blood". One got the name. Knowing nothing more, which do you choose?
The answer is equally obvious. The one that got the name also got the warts.
Minimal rewrite due to the breaking changes introduced in C23 versus K&R C, while the others are a complete rewrite.
Even if the syntax is a bit of a kludge there are now ways to indicate bounds on function arguments.
I've been messing around with a language that I summarize as:
ALOE = Scheme + Smalltalk + Types
https://github.com/dharmatech/2026-09-02-aloe-racket
One of the examples is a very basic computer algebra simplifier:
https://github.com/dharmatech/2026-09-02-aloe-racket/blob/ma...
That simplifier is based on a computer algebra library in Scheme:
You might want to take a look at Odin as well.
For example, write in whatever language you know best, then translate to a more appropriate language using LLMs once the desired behavior can be checked automatically. It is a tactic that works in some cases.
If you’re not stupid it fails because you can find out and then make an informed decision. An informed decision will dominate the suggested strategy in terms of chances of not screwing up.
This really should be obvious.
(In the analogy, your phone has an automatic translator and you use it).
Note that I have made no argument for one language over another.
No.
Find out more.
You could still be wrong but it’s less likely than if you apply hokey heuristics on wilful ignorance hoping it somehow works out sight unseen to knowable problems that can be understood and analysed.
1. Tooling (as an extension to the mentioned IDE support point). Zig and Rust are both praised for their tooling and I think rightfully so. The C/C++ interop story and the cross-compiling story in Zig are great. From the standpoint of a working practitioner though I think Rust is way ahead. Not surprising given that Zig is much younger, but something to keep in mind.
2. Compile Time Stuff: Here Zig is praised and Rust not so much. I think this is undeserved. Rust has much higher aspirations for their compile time features, namely that outcome must be identical regardless when the code runs. This is a very useful property but makes the task much harder and fundamentally incomparable with Zig comptime.
For one of my crates I needed to have a build script make a bunch of lookup tables as separate files for me to `include_bytes!` because at the time I couldn't generate a bunch of floating point conversions in const.
The biggest constraint today on Rust's constant evaluation compared to where you'd expect is that trait implementations can't ever be constant, this obviously means you can't call SomeTrait::function in your constant, even if you can see the implementation of SomeTrait::function and if it were not a trait it'd obviously be constant -- but it also means sugar like Rust's for loop, which de-sugars into trait invocations, can never be constant today.
I think we can expect that to get fixed in the relatively near future, but I'd have said that last year too so what do I know.
If you have C++ experience you'd probably want a lot more. C++ is allowed to allocate inside constant evaluation, and I believe in C++ 26 it's now even allowed to persist the allocation to runtime rather than being required to always clean up during compilation, so that's a much bigger set of crazy things you can do at compile time.
Yep, that was my annoyance.
Const allocation is possible in Rust as an unstable feature. Not sure if you can persist it to runtime, though you can persist a reference which will become a static reference. I think it being unstable is why I needed `include_bytes!`.
As far as I know this is not a concern for Zig comptime.
to expand, if two different host platforms cross compiling to the same target platform have different results, I am almost certain that would be considered a compiler bug.
if you're pointing out that a runtime operation and a compile time operation might not agree, I'd be more interested in understanding when that would ever have any meaningful impact on anything. given the compilation is supposed to be deterministic, the difference can easily be addressed by comptime branching on target architecture in the rare case that it matters for your program.
It's something Rust guarantees (without me having to take care of it e.g. by manually branching) and Zig does not.
I am genuinely interested in a place that this matters for a program, or any practical consequence this has for an end user of the language.
no idea why your response was flagged originally.
As far as I’m aware, they do produce semantically identical results, but something like the specific bit pattern of a NaN value can theoretically vary, and people might do fun things like encoding extra information in those bits.
Nonetheless, if you use a comparison function for which all NaNs are equivalent, different CPU architectures that are standard-compliant must produce bitwise identical results for the same sequence of operations.
Differences appear mainly when the compilers generate different operations or in a different order. Moreover, transcendental functions are computed using various approximations by the standard library, so if you use different libraries on different computers, you will get different results.
However, these are differences caused by software, not by hardware, and they happen even on the same computer when you use different versions of a compiler or of a standard library. Therefore such differences can be eliminated, if desired.
Absolutely, that’s why I wanted to know what types of things aren’t covered yet, and that’s also why I don’t share your certainty that they won’t be resolved later.
From what I understand, there is hope for sqrt but there is no consensus on transcendentals.
Rust is concerned about memory-safety, yes, but the only strict requirement for memory-safety when it comes to const-evaluation is as follows: "The only guarantee the type system needs is that evaluating `some_crate::SOME_CONST` will produce consistent results if evaluation is repeated in different compilation units" ( https://rust-lang.github.io/rfcs/3514-float-semantics.html ).
Beyond that, from a philosophical standpoint, Rust takes great pains to ensure that const functions produce identical results regardless of whether or not those functions are called at compile-time or at runtime. Rust has adopted this stance because it wants to reserve the right to opportunistically evaluate const-capable functions at compile time, as a performance optimization, even if the user has not explicitly asked for it (for that matter, Rust also does its best to const-evaluate non-const functions when it can). Because of this, Rust's assumption is that users would be annoyed if their program's visible behavior depends on whether or not the optimizer has exercised its discretion to evaluate a specific function at compile-time.
However, this is only a guideline, not a strict guarantee. There is one exception to the above rule: "when a floating-point operation produces a NaN result, the resulting NaN bit pattern is some deterministic function of the operation’s inputs that satisfies the constraints placed on run-time floating point semantics. However, the exact function is not specified, and it is allowed to change across targets and Rust versions, and even with compiler flags. In particular, there is no guarantee that the choice made in const evaluation is consistent with the choice made at runtime."
In other words, calling the `.to_bits()` function on a floating-point value that happens to be NaN is allowed to produce a different result at runtime than it does at compile-time (note that all compile-time evaluations are guaranteed to always produce the same result for a given toolchain version for a given target, as required above).
This exception is made because otherwise otherwise it would be basically impossible to support floating-point math at all, thanks to the way various platforms have implemented their floating-point functions in practice.
In contrast, Zig doesn't have such a philosophical compunction against a function's result being determined by whether or not it's being evaluated at compile-time, as shown by the existence of the `@inComptime` builtin. But Zig does still broadly attempt to make comptime deterministic, including going so far as to forbid I/O, though I don't see where any specific guarantees are documented in the Zig reference.
Other parts, like floats are harder. This is where a difference shows. Rust is like: "Sorry, since we cannot uphold our guarantees, no transcendentals for you at comptime ", whereas Zig is chill about that and let you have your transcendentals even if results may differ between comptime and runtime. Different mindsets.
This could be done, for instance, if the standard math library would be dynamically linked into the compiler, so the same library would be available for the compiled program.
And the zig core team is absolutely concerned with bitwise determinism in the compiled artifacts, iirc this is why they rejected the sloppy bun PR to the compiler.
The Zig core team is apparently not concerned enough about the second point to forbid transcendentals at comptime and this is something that'd be hard to take back, because I'd break existing code.
Which means that when cross compiling from x86 to ARM, if lets say, transcendentals provide different results then always the target archs implementations should be used, even if they have to be emulated.
If however, hypothetically, different x86 CPUs produce different results for transcendentals, and we can't control where the user will run our program, then imo the correct solution for the language is to provide a set of knobs for the user to communicate whether they favor accuracy or speed in this scenario. - but we can say there's no 'correct' decision in this case, only tradeoffs.
Forbidding transcendentals is not a correct decision as it adds a ton of compiler complexity (you have to track which functions use them transitively), and baffling UX - the user finds that comptime doesn't work because the function he uses might use transcendentals somewhere down the chain.
The discussion how to deal with transcendentals in Rust is ongoing and at some point there will be a conclusion.
* floating point differences between the build machine and the target. By far the most common
* endiannes - code assumes little median runs on big endian
There’s other more subtle issues that can crop up but those are the big two.
Not saying I agree though - those can happen anyway when you run on two different machines anyway.
In Rust we can expand what is possible at compile time without breaking existing code because we took a very careful approach only stabilizing what we are sure about. Some things will probably never be possible at compile time in Rust.
Zig is much more powerful but that also means they cannot take stuff away without breaking existing code and making comptime more restricted. So it is unlikely Zig will ever become like Rust in that regard, but that is ok - just different approaches.
The code in that section is clear in its purpose and broad outline: "Give me a function and data; if the data is bare apply the function to it; if the data is a container apply the function to each item inside it."
You can absolutely do that with immutable data structures in Zig. You just have to pass an allocator to the function (i.e. instead of calling data.flat_map(f), you call data.flat_map(a, f) where a is your allocator).
That the Zig version of the code does mutation is a matter of programmer choice, not something imposed by the language.
(Also, what do monads have to do with it?)
> But the language quickly forces you to diverge from the functional style, mostly because you’re now dealing with allocators directly, and a genuinely pure functional approach means constantly constructing new structures. That’s either expensive in memory or expensive in the manual bookkeeping needed to avoid it.
So I don’t think he’s trying to say that it’s literally impossible. It felt more like the result of a good faith attempt to understand how Zig itself actually wants to be used, and to compare that to how he’s used to using Rust.
I actually liked that he did it. So many other comparisons want to evaluate one language against the other language’s values. But I don’t want to know how well Zig can do Rust; I want to know how well Zig accomplishes its own goals, and what those goals are.
unsafe impl<I, U, F> InPlaceIterable for FlatMap<I, U, F> where
I: InPlaceIterable,
U: BoundedSize + IntoIterator,
What you're seeing is the graceful goose on the water moving forward at pace. Beneath there is frantic action to make that happen. The Rust surface was more a maintainable immutable operations, but the implementation is a frantic whirling mutation like the Zig.In Zig you end up spending a lot of time writing How to do a thing, where in Rust you only wrote What the thing is and the machine did it. There are edge cases where Zig's explicitness wins for the best programmers, but there just aren't enough of those cases or those programmers for this to net out IMNSHO.
As for me, I’d like to keep contributing to the ecosystem, and I will, whenever I come across a project worth building.”
Idk I don’t write either well enough to have a hand in this but losing out all of this for more Imperative stuff seems like a step back.
“ No functional paradigm
Rust is technically an imperative language, but it draws heavily on functional concepts: zero-cost iterators, lazy evaluation, ADTs, pattern matching, monadic types, traits, closures, and so on. Having also spent time with Haskell and Erlang, I’ve become fairly inclined toward the functional style, and it shows in this library. It leans heavily on FP idioms:
Monadic error control via combinators like Queryable and related types Monadic-style data types like Data<T> with map, flat_map, reduce, and friends Pure, immutable transformations Combinators over iterators instead of loops Closures for local abstraction Declarative macros as a small embedded DSL Sum types and product types”
Even hardware (GPUs) that functional language could trivially exploit, it’s still higher performance to write low level code and manages all the memory imperatively
I'm glad NVIDIA found a way to make GPUs programmable and got us out of the shaders tarpit, but did it have to be C++...
FWIW I've actually worked for 6 months on a large old Delphi project. It was some performance work that, as almost always, mainly required getting the language crap out of the way. In the end I got the job done (100x-1000x speedup) but I wouldn't want to switch back to this ecosystem: Licensing costs, weird language warts there too. A slow moving ecosystem. Ultimately, I just need something that does what I tell it to do, reliably and fast, and that doesn't get in the way.
Thus we can keep filling HN with pointless comments or move on.
Here is an hint, I won't stop having my opinion, so you can keep replying every time I do them, and we will repeat similar discussion threads, or move on and touch grass or something.
I get why, and it makes it a better fit for its obvious “C++ reimagined, cleaner, and better” niche.
Zig has a language server and "The majority of LSP features are supported" (https://github.com/zigtools/zls#features).
That said, I've been re-evaluating my choices lately.
I see you have a blog - it would be interesting if you wrote up the results of your thought process, whichever way you decide in the end.
As a PL nerd, I really enjoy reading well-informed takes from people who have seriously used a language as to its strengths and weaknesses.
So ZLS has to actually run your "build.zig" to see the modules
That is why it breaks when Zig changes the build system.
https://vectree.io/c/zigs-build-system-protocol-module-graph...
That's why having no auto-destructors is a dead-end. This is the greatest mistake of such languages like Zig or Odin.
It's like view span objects in rust. The compiler knowns that a span is logically connected to the parent object and don't allow destroying it when such span exists.
Have a boxed object have implemented drop, then when the box leaves some scope the Box will clean up it's stuff (drop implementation if there is any) and deallocate it's memory using the allocator (which the arena will treat as noop).
This is not fucking rocket science
> That's why an integrated language mechanism preventing such leaks is much better.
No categorical difference, except one is opt-in. You can even design your static analyzer so it analyses the code of dependencies that haven't opted in.
Anyways: its possible, I am building it as a very side project.
github.com/ityonemo/clr
I programmed in rust a bit and can't say I'm an expert, but in my view rust mostly-solved the memory management problem at compile time and without a GC, and it works very well. The biggest con and cost I've always seen repeated so far is that "it's slow to compile", and I get that, if you're past 250 crates the final --release link tends to become noticeable, but there were improvements to incremental compilation.
On the other hand - looking at the syntax from this post - Zig feels a blend of javascript, python and golang syntax that still requires memory management. So a nicer-written C that inherits all the issues from C? From the post: no functional programming, data mutation, memory leak, double-free, memory corruption.
Personally I'd rather trade a couple minutes of final link every time when this is the other option.
As a long-time low-level programmer, and as someone working on a popular mainstream language, I find Zig fascinating, and I also think it addresses a long-standing problem in low-level programming. I'll get to the problem later, but the fascinating part is its use of partial evaluation (comptime) as a single coherent mechanism that replaces a myriad of other partial-evaluation mechanisms (macros, templates/generics, constexprs). That one mechanism is the core of the language, like macros are in lisps, and that design - whether you like it or not - is revolutionary. It's never been done before (other languages have partial evaluation mechanisms that are almost as general, but they're offered in addition to, not as a replacement of, other features).
> rust mostly-solved the memory management problem at compile time and without a GC
"Mostly" does a lot of work here because 1., if you look at the implementation of very efficient, possibly specialised data structures - the very thing you reach for a low-level language for - they typically require unsafe, and 2., it still suffers from the problem C++ has had for decades, which is that over time, as program changes and evolves over years, things tend to drift toward the more general mechanisms that rely on malloc/free on an individual objects, and the program gets slower and slower (huge runtimes like TCMalloc help, but not enough, because they can't move pointers). This problem, of programs that start out fast, but after five or ten years of evolution need to spend a lot of effort to remain fast, is one of the things moving collectors were designed to solve, but they require moving pointers, which doesn't work in low-level languages that are not meant to have an FFI layer between them and the hardware.
To compete with the performance of moving GCs, which allocate through bumping a pointer, like on the stack, and free memory in bulk, low-level languages need to rely on arenas (which work based on a similar principle), and Zig is the first language that makes arenas almost user-friendly and hopefully sufficiently composable to withstand program evolution. Of course, time will tell how well this works in practice.
So sad dlang never gets the credit it deserves. None of these ideas in zig are novel.
Few ideas are new under the Sun. But the inventor and the popularizer are rarely the same. We should be happy that the ideas got more exposure instead of litigating novelty.
Odin and C3 has at least a magnitude fewer people knowing about them. More modest claims, less marketing potential.
So for D, it was to my memory never sold as a silver bullet either. A better C++ certainly, but with enough humility to not market itself as some game changer.
Compare Zig, V and Rust marketing with D’s and you’ll see what I mean.
For example, another language that immediately grabbed my attention (aspirationally; I haven't looked at it closely yet) is https://github.com/aardappel/goose/. That's not because it has arenas, but because it doesn't have anything else.
That IS cool. I loved partial evaluation the first time I heard of it - Futamura projections, here we come! And if it can be used to obsolete language features I never was happy with (macros), so much better!
But until now, I never heard of it as a selling point for Zig. I assumed Zig was just yet another "C replacement but we don't want to deal with Rust's borrow checker". I will certainly have to take a closer look on Zig now. But this should be up and front in their self-promotion!
It is! "A fresh approach to metaprogramming based on compile-time code execution and lazy evaluation" is the second selling point after simplicity: https://ziglang.org
Obviously, they can't call it partial evaluation because not many people know what that is. Zig's approach was eye opening to me. I'm very familiar with how macros are used in Scheme, but comptime is intentionally weaker (unlike macros, it's referentially transparent, so strictly weaker) and I was surprised by just how far it can go. It's not everyday that you see a new kind of a partial evaluation construct, let alone a language that's almost entirely based on it (like Lisp only for comptime).
There's a formal proof asserting that if you keep up the safety invariants within an unsafe region then that will not infect other code, even in the presence of arbitrary other correctly-written unsafe blocks.
This means you can build abstractions on top of these low-level primitives to keep it contained, so consumer code never has to even think about or know there's unsafe blocks in it. The type system lets you build very powerful abstractions so these go a long way.
There's a lot of woo-woo scare quoting around how much you actually have to use unsafe code in Rust. It's fairly uncommon to actually have to reach for them in practice. Most of my usage ends up being things like converting a &[u8] to a &str when I know it's already valid UTF-8 so I want to skip the linear-time validity check. Very rarely do I have to build data structures with complicated pointer juggling, because there's often a library that already does what I need!
> which is that over time, as program changes and evolves over years, things tend to drift toward the more general mechanisms that rely on malloc/free on an individual objects, and the program gets slower and slower
What are you talking about? I've never encountered this and I've been using Rust for 10 years.
I think the idea is that a small program can organize its allocations and data structures to minimize number of calls to malloc, e.g. with preallocated workspace structs, or slab allocation, and similar approaches. But as a program gets bigger, there's a pressure to have looser coupling, to have subsystems with simple convenient APIs which leads to them doing on-demand malloc calls internally, rather than having consumers pre-allocate their needed workspace. Because that kind of workspace management results in more complex APIs and more burden on the consumer.
That said, I don't really believe it either, at least for the kind of codebase where it would matter (scientific computing, in-memory DB server, etc). A codebase that places an emphasis on minimizing heap operations in hot codepaths can do so by consistently using workspaces and allocation-avoiding APIs. I don't think it's so difficult really, but it does take a conscious design decision to do so. But writing something like a web browser in this way could be annoying due to most data having wildly variable sizes, and zig's arena concept would be very handy -- but rust has crates like bumpalo for that purpose.
My personal mantra: "Think in FORTRAN, code in Rust/Julia/C++". But I'm mostly working on HPC-style code where I don't have to do with wildly varying input or output sizes.
Except that's not composable - not only do you need specialised data structures, but all (transitively) allocating calls need to be specialised. That's the exact same issue we have in C++, and that's the issue Zig seeks to address. BTW, just the other day there was a post here about a language with another interesting approach, but I have yet to give it a close look: https://github.com/aardappel/goose/
> But I'm mostly working on HPC-style code where I don't have to do with wildly varying input or output sizes.
There you have it. The problems arise more quickly in concurrent rather than parallel code, and when there are lots of features added over the years that touch the hot paths.
> in-memory DB server
Actually, here there can be big problems (as it's also about concurrency rather than parallelism). Last week a colleague of mine looked at Moka and saw that it could only offer half the throughput as Java's Caffeine at the same latency and RAM footprint (almost; the Java program used 5% more RAM). When he looked into it, he saw that over 40% of the program's CPU was spent on the epoch-based reclamation.
Most crates for containers will be written such that the container types take an optional allocator type parameter that defaults to the global allocator. You can set it and it transparently uses the other allocator.
To improve the ergonomics, you'd define local aliases that use that allocator.
type MyVec<T> = Vec<T, A = MyAlloc>; with_allocator(&arena, || {
third_party_library::do_work()
});
there is nothing stopping you from using custom allocators with your own code or with calls to thirdparty dependenciesbut custom allocators are rarely used in rust because they're simply not needed the vast majority of the time. if your language is not memory safe and you need to manage memory yourself, they're more important. but this isn't the case with rust.
c and zig folks are obsessed with arena allocators particularly because they can group lifetimes of individual objects, reducing the amount of malloc/free calls and thus the amount of use after free, double free, nullptr derefs, or leaks that can occur.
in rust this isn't a concern so custom allocators are only used for performance reasons.
but it turns out that in performance sensitive areas, you generally use custom data structures or those that already have their own allocation strategy baked in, like the generational_arena crate.
most of the time you are not calling thirdparty crates that allocate in performance-sensitive regions. either the crate is designed for this usecase and already uses a performant allocation strategy, or you're writing your own code here.
and in the rare case, you can trivially vendor the crate and pass your own allocator into it, or toggle the global allocator for callers.
but you also need to benchmark first before choosing an allocation strategy because it's not clear that a custom allocator will always guarantee better performance anyways.
and btw zig doesn't guarantee this anyways. you could pull in a dependency that instantiates their own allocator. at least in rust almost all crates use the global allocator as a default which lets you swap it out. if a zig dependency uses their own allocator the only recourse is to fork it.
rust doesn't have a performance problem, so any claims about it's custom allocator support leading to poor performance is unfounded. and thus so are claims about the superiority of zig's approach to allocators.
You say I outright lie for not mentioning the existence of something that doesn't exist??? I guess you're saying it's possible to create such a mechanism (or that some libraries do create ad-hoc ones), but that's not the point.
> there is nothing stopping you from using custom allocators with your own code or with calls to thirdparty dependencies
I didn't say there's anything in the language stopping C++ and Rust from having such a standard library and ecosystem of libraries. They just don't have that yet.
> if your language is not memory safe and you need to manage memory yourself, they're more important. but this isn't the case with rust. c and zig folks are obsessed with arena allocators particularly because they can group lifetimes of individual objects, reducing the amount of malloc/free calls and thus the amount of use after free, double free, nullptr derefs, or leaks that can occur. n rust this isn't a concern so custom allocators are only used for performance reasons.
This is simply untrue. I won't call it an outright lie, as it's probably just a lack of experience with low-level programming.
First, I'm trying to point out the problems we've had in C++, most of which only became apparent when evolving large codebases over time. People who have not had experience evolving large C++ or Rust codebases over years simply don't know about these problems and certainly can't claim they don't exist. Writing smaller programs in C++ or even large but young programs has always been a pleasure. The language is expressive and productive. Some of the biggest issues only arise years later, when the program gets either expensive to maintain or slow.
Second, experienced C and C++ folks cannot be "obsessed" with arenas for the reasons you mentioned because until maybe 20 or even 15 years ago memory safety wasn't a widespread obsession. It was a correctness issue like all others, and its outsized role as the cause of security vulnerabilities wasn't widely known until more recently.
Lastly, you don't pick Rust for safety. Most software in the world today is already written in languages that are at least as memory-safe safe as Rust, sometimes more so. These days, you pick C, or C++, or Rust, or Zig when you want to do something that's largely low-level. Things that are low-level often also need to be reasonably fast, and large low-level codebases that evolve over years tend to suffer serious performance issues because of memory management (because, being low-level, they can't move pointers and so can't use things like a moving GC to reduce the overheads of their malloc/free runtimes; this is why companies with actual experience with long-maintained large low-level codebases make huge runtimes like TCMalloc to help them to a degree, which you also may not have needed yet), and arenas are the primary way to get memory performance similar to what you see with modern moving GCs (and even somewhat better).
Now, you could say that C++ only started moving in that direction with pmr in C++ 17, and that's true. But the need was recognised as early as 2005, traditionally C++ codebases didn't rely on many libraries so interoperability has typically not been a large concern, and the number of large C++ programs that would benefit from such a thing declined over the years because of the low-level maintenance issues I mentioned and the growing availability of fast high-level languages.
My distaste for Rust isn't because I like C++ so much. Even though it's been one of my primary programming languages for the past 25 years, I "hate" it for the very same reasons. Most Rust superfans are people who have not had enough experience with it and they don't know about the problems. Not all, of course, and even C++ has superfans, which is why I said that among the people who are experienced in low-level programming, there are people who like the C++/Rust approach (of trying to make low-level code appear high-level) and people who don't.
We could use rust. But it wouldn’t give us anything.
In general "unsafe" does not compose.
"if you keep up the safety invariants within an unsafe region"
This condition is doing a lot of heavy lifting.
I'm curious why you think that statement is doing heavy lifting. It's much easier to write and verify that a few lines of code are correct than it is to write and verify that an entire program is correct. But that's the norm in C and Zig, and historically people haven't been very good at it. That's why we try to do it as little as possible.
The reason it's not "the norm" is that (especially with spatial safety taken care of), not every line is equally dangerous at all. Still, there's no doubt that more guarantees help, but that is only when all other things are equal. If you pick a low-level language for mostly low-level things, so Rust doesn't offer safety for the trickiest code, and furthermore it makes certain things harder to see because the language is more complicated, then things become much less clear. Obviously, when the vast majority of the trickiest, most important code doesn't need to be low-level, Rust would probably be safer on the whole, but in such situations I see no reason to choose either Rust or Zig. You need to choose a low-level language if the core of what you're doing needs to be low-level.
Is there a word missing before "memory"? Seems odd to specifically call out spatial memory safety when memory safety subsumes it.
You must reason about the invariants in unsafe code on a global level. In particular, you could have unsafe code in crate A, whose data are then used by crate B. It could be fine. But then crate B changes its implementation which now violates the invariant expectations of crate A.
Is this backwards? If B consumes data from A then to me that does not imply that A depends on anything from B; for a more concrete example that sentence reads to me like A is basically "throwing data over the wall" to B and whatever B does with said data is of no relevance to A. As a result, if B changes that shouldn't affect A.
Also for what it's worth I get the impression you and treyd might be talking about slightly different things when talking about whether unsafe code composes. I believe treyd is referring to the RustBelt series of papers [0, 1], for which the statement "unsafe code composes" means (at a high level) that adding a module with a memory-safe API to a memory-safe system will result in a memory-safe system as long as the implementation upholds the safe semantics. Yes, the last bit can be a rather significant caveat, as you said.
What you're talking about seems more along the lines of needing to look beyond the boundaries of unsafe blocks to prove that the unsafe block upholds its invariants, which is also true. I think you only need to check within whatever safe encapsulation boundary is relevant, though, rather than globally.
[0]: https://people.mpi-sws.org/~dreyer/papers/rustbelt/paper.pdf
This is a specifically crafted bad idea, but you could have module A use unsafe to craft a Vec<u8> that is safe to use to read or write, but not to grow or shrink. You declare an invariant that the receiver shalt not grow or shrink the Vec.
If B only reads and write, you're good. But if a future B breaks the invariant, bad things happen. As I said, specifically a bad idea; there's a much better type to use if the thing can't grow or shrink...
No real world example, because I don't think we've run into memory safety issues with unsafe in the Rust code base I work in... but we only use unsafe where it's required (syscalls and other FFI).
To address the problem that once integrity can be violated anywhere, only global analysis can prove that nothing bad happens, we've done two things:
1. We require the application to explicitly permit any integrity violation by a module; i.e. a library can't allow itself to violate integrity. This is a principle we call "Integrity by Default" (https://openjdk.org/jeps/8305968).
2. We try to minimise the need for potential integrity violations (this is very different from Rust, which requires unsafe even for things like benign write/write races, which are fairly common, and various basic data structures). Over the years we've offered safe replacements for things that used to require Unsafe. In other words, clearly demarcating unsafe code isn't enough if it's needed at all in many situations.
It isn't perfect, of course, as some libraries do require unsafe operations for direct interaction with native code or with memory, but their number has been greatly reduced, and they cannot do this without the application's explicit approval. Interestingly, this has annoyed library authors who want to do unsafe things but don't want to application authors to be alarmed because "we know what we're doing," and it's also annoyed some application authors who want to use such libraries and are forced to explicitly add permissions. But I think that the community, as a whole, has eventually accepted this because the harm done to those who don't care is small (they just need to add the permissions), to those who do care it helps a lot, and because fewer and fewer libraries require "integrity-busting" permissions, many applications need to do absolutely nothing and get important guarantees for free.
I know this paper [0] is quite old at this point, but the mention of benign data races reminded me of it. Would you happen to know how applicable it is to modern memory models?
[0]: https://www.usenix.org/legacy/event/hotpar11/tech/final_file...
However, in C and C++ (and Rust) benign non-atomic write/write races are UB (indeed, LLVM also treats them as potential causes of UB). In C# and in Java they are safe (although Java currently only has non-atomic writes on 32-bit machines, but soon they'll be more common when value types are enhanced). LLVM even has a specific construct to support the Java-style memory model (https://llvm.org/docs/Atomics.html#unordered), and Zig lets you use it (https://ziglang.org/documentation/master/#atomicStore).
> What are you talking about? I've never encountered this and I've been using Rust for 10 years.
Okay, but I've been doing low-level programming professionally for 25 years, and have encountered this over and over in large programs (over 500KLOC) as they evolve.
It's just that sometimes some of the abstractions you need to build go outside what the ownership and borrowing system can model. And when you don't need to do that (which is 99% of the time) you also get all the benefits of the ownership/borrow system for free.
I mean, someone can think it's solved for them, but if they're asking why others don't see it the same way and why many expert low-level programmers are at least intrigued by Zig, this is why. I prefer a simpler high-performance high-level language for high-level things, and a simpler low-level language for low-level things, and I dislike the C++/Rust approach of combining them into one complicated language. Some may think you get the best of both worlds; others, like me, think you get the worst of both worlds.
Suppose you're writing a program that's mostly high-level, say some kind of concurrent server, and it's large-ish, say around 1MLOC (most C++ programs I've worked on were significantly larger). Because the language is also a low-level language, it has low-level constraints, so:
1. It needs to use an AOT compiler, and consequently to get good performance you need to use less general mechanisms, such as direct (as opposed to dynamic) dispatch and even manual monorphisation (with generics/templates). These are viral, so they have to be carefully chosen (you can't monomorphise everything or you'll get machine code explosion). Five years later you need to make a big change that requires more generality, and then you either have to reconsider all of your manual optimisations, which is expensive, or go for more general constructs (dynamic dispatch) and the program gets slower.
2. It needs to use machine pointers (i.e. you can't enjoy a moving GC), and so you try to use the stack as much as possible (which you can't really do for anything dynamic), or suffer the high cost of malloc/free on individual objects. As the program evolves, you need to make things more general, and objects that could live on the stack now need to go on the heap, and objects that lived on the heap now may need to be shared among threads, in which case you often add the additional cost of refcounting GC. Of course, you want to use arenas in many cases, but they're very, very hard to use in C++ and Rust.
You'd be better off - performance-wise and maintenance-wise - with a good optimising JIT and a moving GC. This was exactly a problem with many C++ programs that didn't really need a lot of direct hardware interaction - everything worked great for a few years, and then the evolution and maintenance costs became really high (or the programs became slow).
Now suppose you're writing something low-level, i.e. you really need to interact with the hardware and/or OS directly a lot, and want to control everything - where everything is in memory, exactly when it's initialised, exactly when it's freed, exactly which operations are executed and when. But now you have a language that's also high-level, so it has a lot of implicitness that hides from you the things you want to see (and in Rust's case, you lose the safety). Best case scenario, you rely on disciplne and avoid implicit features, but then you also need to avoid much of the standard library.
Anyway, combining high and low level in the same language was C++'s dream: one language for everything. Of course, for a while we didn't know about the maintenance problems, as those appear only years down the line, but more importantly, there weren't really high-performance high-level languages back then. These days, with lessons learnt and with more options, I prefer a language that focuses on being high-level for high-level stuff, and a language that focuses on low-level for low-level stuff. If you really need both kinds, use two languages.
You haven't been doing any Rust though. You seem to think you can extrapolate your C++ experience to Rust. That's preposterous. The actual Rust programmers can't recognize this theoretical problem in their Rust programs.
Most actual Rust programmers haven't maintained a large Rust program for a long time. Now, don't get me wrong - there are many C++ programmers who are fine with it, but many who aren't. What I find annoying is people without much experience in Rust assuming that everyone or almost everyone should like it, even though that's never been true for any language. I'm not saying Rust is bad by any means; in fact, I think it's better than C++ in a few ways. I'm explaining why I don't like it.
And if you look at other comments in this thread, many engineers have this mentality of "just use a crate, it's probably optimised already". They might not have performance problems immediately or obviously but it's more like ten thousand papercuts - a few allocations here and there, a few extra copies here and there and you've got a way slower program than it should have been.
I don't understand your C++ example about it getting slower and needing to "move pointers."
Well, it isn't that and it doesn't require that.
> I don't understand your C++ example about it getting slower and needing to "move pointers."
Because low-level languages need to use machine pointers, their dynamic heap allocations have a high CPU overhead; it's that overhead that moving GCs are designed to reduce, but because they move pointers, using them requires an FFI between these pointers and machine pointers. This is why in low level languages we try to avoid dynamic allocations when we can, but that increases long-term maintenance costs.
Note that AT&T, where UNIX and C were born, the language they were researching as C replacement was Cyclone, not something that is not much different.
Seriously impressive that you’re going to repeat that until the end of time
Because it is a rip off, pretending nothing else has done it before, in fact it could even be called a PL/I ripoff for that matter, which predated C for about a decade, had already had all the bounds checking, proper strings and arrays, was actually used to deliver several IBM OSes, and is used to this day on z/OS. Certainly much larger production deployments than Zig.
Yes I will, as long as I am part of this realm, can be in HN, or whatever is the social media of the day, I started with BBS, USENET, Slashdot and plenty others.
I don’t think it’s an accident that it has succeeded as multicore took over. We are now well past a point that a task that can be split with 70% efficiency into multiple parallel tasks is 5-10x faster than the optimal sequential solution. Expensive multicore machines existed when Rust was a baby but it didn’t really catch on until 4 core was common in consumer hardware. And now I have an ancient laptop with 16 cores.
But Rust is also good for producing correct code to run on those systems. So it benefits twice.
The C interop is a huge win as someone who wants to do more posix/wayland projects.
Kelley first created Zig when hr was working on a digital audio workstation and found most existing languages to be awkward for working with particularly hard real time requirements, but still wanted something more modern than C. Im speculating here, but I believe its advantage over Rust for that specific application is that you have tighter control over exactly when memory is allocated and deallocated, and how data is laid out in it. Rust wants to tie allocation lifetimes to scope in a very fine grained way that I would guess is beneficial the vast majority of the time, but does still make it harder to reason about when you’re about to stall out the CPU while the allocator does its thing.
Rust had like 3 allocating types total. If you aren't working with extremely deeply nested 3rd party types it's trivial to identify when allocations happen. Hell you could throw a lint rule together in like 5 minutes to warn on it if you're really worried. Besides Drop (excluding async) is there even any hidden control flow?
> Im speculating here, but I believe its advantage over Rust for that specific application is that you have tighter control over exactly when memory is allocated and deallocated, and how data is laid out in it.
Rust has almost exactly the same semantics for controlling allocations and deallocations, it just prevents you from screwing it up and not freeing something or using the allocation after freeing it. You still have to pass around your reference in your call stack until you no longer need it.
> Rust wants to tie allocation lifetimes to scope in a very fine grained way that I would guess is beneficial the vast majority of the time, but does still make it harder to reason about when you’re about to stall out the CPU while the allocator does its thing.
It's really not substantially different. You allocate ahead of time or don't allocate at all. The only real difference is you might want to use an Option instead of an uninitialized pointer because it's semantically more correct and harder to screw up.
So, in the case of Zig, every function that wants to be able to allocate or deallocate heap memory needs an explicit reference to an allocator. That means that you can tell whether a function might allocate memory from its signature. It also means that changing a function so that it can allocate is explicitly a breaking change.
That’s a really interesting design decision. And the reasons why someone would or would not want something like that baked directly into the language are so much more interesting than bickering about how technically with proper discipline you can have that kind of control in any non-GC language.
In practice that's not the case, as many objects own a reference to their allocator. It's still explicit, but it might be hidden in the signature, especially if you have some sort of interface that can take an allocating and a nonallocating data structure alike.
However, there's no global allocator in zig. You simply cannot call the language's equivalent of malloc() because it doesn't exist - at least not as a global symbol. That leaves you with three choices: 1) Define a global allocator; is a valid choice and would make a zig program more like C, C++ or Rust in terms of not having to think about scope-level allocation patterns 2) Pass an allocator into that scope (this is the community convention) 3) Create/instantiate an allocator itself inside that scope
(1) would be valid, though may not be idiomatic; global allocator like malloc becomes an opt-in
(2) Expensive and inefficient for most scopes, though not all.
(3) cheap, idiomatic but potential for noise/boilerplate
I mean std.heap.page_allocator is global, and there's only the one, and you can call it from wherever (just as you can malloc). Same with std.heap. c_allocator, which is... malloc! you can also create your own global allocator.
Don't do this in libraries ofc or the ghost of Andrew Kelly will haunt you in hour sleep.
Seriously? Categories of types maybe, but literal types it's more than that.
Even closures allocate if they need to capture their environment.
When you want to have a closure allocate an environment, the closure itself does not: the Box you wrap it in, which is a stdlib type, does.
Zig and Rust are equivalently "low level". Zig isn't any closer to the hardware than Rust is.
> but I believe its advantage over Rust for that specific application is that you have tighter control over exactly when memory is allocated and deallocated, and how data is laid out in it.
Rust gives you all this, too.
Zig's primary (possibly only) advantage over Rust is that it has much faster compilation times.
The industry where it seems strongest positioned is embedded. Will it actually break into that domain? No idea.
It's the lingua franca of software development.
Zig is appealing to the "I'd like to work in a C-like system programming language but I'm not a C veteran" crowd.
Not a small crowd by any means, but will always be a niche.
I haven't actually tried to find out if that Zig promise is real, though. Just some trivial little things. No day job excuse and no personal projects where it would fit.
And inherits all of the benefits of C. C is the foundation of the computing world. "C, but not built 50 years ago" is, by itself, a tremendous value add to a programming ecosystem that has largely abandoned attempts to write a truly performant language in favor of handicapping programmers with fully automated safety.
Rust is a low-level language for people who don't write low-level code. Zig is for those who do.
It's actually a nicer-C that fixes most of the issues from C.
That's the point.
Rust is philosophically different and goes well beyond the "just be a better C".
Microbenchmarks are cool, but better algorithms are where you get faster code.
let mut arena = Bump::new(); // create arena
let foo = arena.alloc(Foo { x: 42 }); // allocate item in arena
bump.reset(); // clear arena
foo.x += 1; // compiler error preventing use-after-freeYes, on paper zig has more features than C, but what zig has is explicitness. C has a big murky space of implicitness. For example, there might be 5 different zig features which can be used at various times you might use a void* in C, buy the conceptual space of void* fully contains(and then some) the spaces of those zig features.
Yeah this was also surprising to me the first time I tried Zig.
ZLS exists, but back when I last used it it was unstable and crashed frequently.
I wrote a small toy JVM. In case anyone interested: https://github.com/Sharktheone/yeeeeem
All of the Handmade "C successor" languages seem to have this obsession, including not only Zig but Odin, C3 and Jai.
For some toy problems you can do clever allocator tricks and get a huge perf win. For example Jai and Odin both seem to really want you to write code which can throw away a "per-frame" arena periodically so they're not paying to track allocations in the arena because they're all thrown away at the same time.
But a lot of real world software just isn't that simple. This doesn't make such features worthless, it just means they're one of a thousand tools the experienced developer could want in their toolkit, not really deserving headline status.
Allocators have been in wide use long before the 2020s but I agree there does seem to be a resurgent interest lately. Although I would argue it's part of a more broad trend of focusing data driven design. Which makes sense because accessing main memory is one of the slowest things your program can do.
They do, but not necessarily together with generic, standard containers.
When you find yourself wanting a nonstandard allocator, you usually want it because you want it to have some interesting property. It's not necessarily trivial to fit that into the interface of something like `std::vector`, or `std::unordered_map`, etc.
Here's my take as a game developer: 99% of use cases for custom allocators are scratch allocators for doing stuff within a frame. 95% of those are much easier to serve by just amortizing allocations by storing things in an `std::vector` (or equivalent) that gets cleared every frame. You can use linear storage to back many interesting data structures, including queues, ring buffers, priority queues, binary heaps, etc., and that's more than enough for a large number of systems in a game.
The overwhelming majority of the time, more complex data structures (like hash maps etc.) have a longer lifetime than the current frame, because the whole point of using them in the first place is to amortize lookup time across frames.
The important part is a language where the standard library isn't special, and Rust has this property, too. So in domains where things like per-frame allocators are useful, you can still have them. That capability just isn't cluttering up the more common path where that isn't useful.
I haven't used the relatively new C++17 polymorphic_allocator, though, maybe it fixes this.
What does that mean?
Zig doesn't force or even tends to prefer one way or another. If I want unassuming heap allocations that can be reclaimed in any order, there's an allocator for that. If I want an arena to discard at the end of something like a request or a video frame, there's an allocator for that. If I want to use a fixed backing buffer for the allocations, there's an allocator for that.
The point is that the standard library doesn't assume one or the other, which seems good if the problem is that "real world software just isn't that simple" in the more general sense that there's no one-size-fits-all allocation strategy.
I also disagree that this is "the focus" of Zig. Technically speaking it seems like a rather minor design choice for the standard library.
The automatic solutions are usually pretty good and usually the right place to start. But if performance is a priority, you want options.
BTW, “per-frame arena” is part of a general pattern of a repeated interval of work doing significant allocation. This is really common in software of all kinds… servers that process requests (like web servers and database servers) and typical command line tools.
I haven’t tried to replicate this for myself. And, even assuming for the sake of argument that it was definitely true back then, a lot can happen in 20 years. But still, it does speak to wanting options when performance really is critical.
But I think C3 shows it can be taken further.
You can generalize this far beyond per-frame semantics. Think per-http-request, per-pubsub-message.
Per each, you can create a new virtual.Arena, set it as your context.temp_allocator and use it for the entirety of the request or message. Afterwards, throw it away.
Our HTTP server may have dozens, or hundreds, or thousands of simultaneous requests. But we've decided to give each a specific arena with a size chosen in advance so that we can use arenas. This means that the request to check a CSS file hasn't changed (it has not) and the request to modify the holiday of an employee whose manager is changing mid-way through their annual leave are both given, say, a 10MB Arena. No problem for that CSS check, some text parsing, one OS call, an ALU operation, done. But alas the SQL Transaction for that holiday change used up so much memory now the text parsing code "temporary" allocation in code to queue email for both managers fails and it unwinds the whole HTTP request. 500 error, rewrite your Odin program.
Arenas make lots of sense for video games, but their applicability for these other applications is much more dubious.
It's a fairly common pattern in non-video contexts to preallocate arenas of different sizes for different workload pools (e.g. different routes for a server).
It's also fairly common to allocate per-work-item (e.g. request/session/batch job) arenas for "general-purpose" scratch allocations that are small (your header parsing, auth context retrieval, etc.) and then default to a global manual/refcounting allocator for one-off large data actions like your holiday change. Since most business applications are returning summary/small aggregates over the large data action (in this example, something like "holiday updated successfully", not the entire history of the PTO table or the database connection's internal state), the copy cost of moving data between the global dynamic allocator and the arena for result transmission tends to be small.
That's not a terrible approach in some situations. If the large majority of code only needs the scratch arena and/or some per-handle allocators stored on e.g. the database connection pool, it can work well. But if, over time, the amount of bookkeeping required to maintain data tagging/movement between arenas/allocators becomes severe, it's worth stopping, stepping back, and considering that you've kind of walked backwards into inventing a shitty generational GC.
Given that both of these are intended for game development, wouldn't that make the most sense?
Sometimes using a global one isn't the best thing (it's often a good idea to specialise base on allocation size, reuse and lifetimes), but I've used them quite a bit in C++ over the past 16 years doing HPC for graphics, rendering and simulation, so calling them only useful for "toy problems" likely shows you just haven't found a need for them in what you've been doing.
this goes back decades, its not just a feature of the 2020s. as a systems programmer I always want this, and the idea that allocator state should be completely hidden and implicit is shortsighted.
but yes, it is a bit onerous to pass around allocator(s). the real complaint that I have is that if 'malloc' is global and a compiler primitive, then we can do things like coalesce allocations and have compiler managed lifetimes when appropriate.
explicit allocators are an important lever, its not clear to me that we could never find a way to make them possible without the minor downsides.