Zig is verbose and low-level and, to like it, you have to appreciate simplicity and what it means to have control over tiny details. If you don't like either thing, then Zig will not bring anything particularly interesting to the table for you.
I'd once considered the idea of having "space" as a type. Space is an array of bytes, and it cannot be read or written. Constructors take in "space" and turn it into an object. Constructors have the privilege of casting "space" to something else. Destructors take an object and turn it back into "space". Destructors can cast their object to "space", and this destroys the data. This separates construction, which is a typed thing, from allocation. It can be used with malloc/free type allocation, garbage collection, or fixed allocation. You'd probably encapsulate this with a generic depending on what type of allocation you are using.
C++ probably would have been cleaner if it took that route, but C++ added both generics and allocators as afterthoughts.
However, it alone does not compensate for all the other missing niceties. e.g. Just dealing with strings is not straightforward compared to Rust/C++.
But this is what C++ actually does! C++ ctors run after memory has been allocated (on the stack, on the heap, from a pool, etc.) Or maybe you mean something different?
Such a separation is only useful in specific situations, such as the one the original article mentions. It could be useful in avionics or industrial control, where you often avoid memory pools and have arrays of specific kinds of objects instead.
However C++23 has introduced compiler aware functions to start specific lifetimes out of raw memory pools.
I did not know about std::start_lifetime_as. Looks very useful! It's crazy that it took so long. Considering that one of the primary design goals of C++ was backwards compatibility with C (too a large extent), I always found it crazy that malloc'ing objects would be undefined behavior. In practice, this has always worked, though. At least now we have a standard-blessed way to do these kind of things.
As a side note, does this mean that you can finally cast an array of bytes to an object pointer (e.g. for deserialization) without violating strict-aliasing rules? The usage example in section 1.2 of the linked paper seems to imply this, but it does not mention aliasing anywhere...
"Taking a Byte Out of C++ - Avoiding Punning by Starting Lifetimes - CppCon 2022"
Of course, C++ does not have lifetimes (in the Rust sense), but your original claim was that C++ does not seperate allocation from construction.
> Such a separation is only useful in specific situations, such as the one the original article mentions.
It is actually quite common. You don't even have to reach for advanced things like memory/object pools. For example, if you want to implement a dynamically sized array type (std::vector), you typically want to increase the storage independently from the number of objects.
But then again, maybe you are talking about something different here...
---
Also, I may need to clarify some things about C++ allocators. They are not used to allocate storage for the object itself, but rather control how an object allocates memory for its subobjects. Keep in mind that the object itself may exist in a different kind of storage than its subobjects. For example, the object may live on the stack, but its subobjects must be allocated dynamically.
> but your original claim was that C++ does not seperate allocation from construction.
At least that's how I interpreted it.
union { MyObject space; }; // just a bunch of bytes
MyObject * myObject = new(&space) MyObject(); // invoke the constructor
myObject->~MyObject(); // invoke the destructor
If you want RAII use some wrapper like Optional.edit: not sure why they didn't just bless placement new with constexpr powers though.
Isn't this the whole point and the reason why manual memory management is still a thing - and actually getting more important with the ever widening CPU/memory latency gap?
There simply is no silver bullet for memory management, you can either have high performance or automatic memory management, but not both.
(vastly simplified of course - but in languages with automatic memory management - no matter if garbage collection or refcounting - you need to spend so much effort to reduce memory management overhead that it is usually less work to do it all manually in the first place)
For example, Protocol Buffers are trees of objects. Originally they were simple and had recursively-owned, heap-allocated nodes. But it turns out that traversing and deleting a big graph of heap-allocated objects is expensive, and arenas are more efficient. But arenas make you give up recursive ownership.
What does that look like now?
Vec::with_capacity_in(10, arena_allocator)
... obviously user-defined types don't know these allocators exist and so may not provide a way to pick which allocator is used, whereas in Zig this is "always" how it worked.
For stable Rust, until/ unless allocator_api is stabilized, you will need to replace the global allocator for your code with one that has the desired behaviour. That applies to everything using an allocator, but on the other hand you lose considerable flexibility.
https://manishearth.github.io/blog/2021/03/15/arenas-in-rust...
Ongoing refactoring and maintenance costs are also higher in low-level languages due to this reason.
RAII is the way.
That has not been the case in the vast majority of applications I've written across a number of domains using garbage collected languages.
Yes, there is runtime overhead to GC. But for many many programs, the cost of the overhead is acceptable and you can spend all of your time worrying about the application domain and not worrying about allocation.
Inevitably some kind of object pooling comes into play in most of these cases and this is arguably a much worse solution to the basic problem. If you know you're going to be landing in this space I don't think there's much purpose to pretending you're better off using a GC that you'll be trying to avoid anyway. It's far easier to sketch out your allocation strategies and do things deliberately.
Fast software allocates and deallocates in bulk, so the straw man of individual `malloc` and `free` isn't exactly relevant. Arenas, bump allocators, etc. are what is actually being discussed as an alternative.
(As a side note I think this makes RAII a somewhat peculiar solution to the issue as well. RAII implies objects themselves controlling allocation and deallocation and this is just not very useful at all if you want to make something that behaves properly.)
This is trivial to profile under any platform that has remotely sane tooling, and usually it is trivial to fix as well.
Object pooling is a very last resort thing to do, I really can’t think of any project I have worked on where there wasn’t another solution to performance problems.
This has never been my experience in 10+ years of middleware development in GC languages in large teams. The problem is just deferred to production where you need to analyze heap dumps and run a profiler to hunt down excessive allocation issues and why the GC is running all the time.
Sometimes, troubleshooting and correcting many of these issues take multiple more man hours than coding the service in the first place! The usual solution of "throw more hardware at it" only works until mgmt starts complaining about costs and uptime.
I have spent more time debugging memory issues in Java/C# projects than in my first large scale project in C++ where the lead architect strictly mandated the use of a custom allocator for everything.
The parent comment says: "(vastly simplified of course - but in languages with automatic memory management - no matter if garbage collection or refcounting - you need to spend so much effort to reduce memory management overhead that it is usually less work to do it all manually in the first place)"
That suggests they are claiming that all users of GC languages will eventually spend more effort dealing with memory than if they'd skipped a GC in the first place.
I am making an existence proof counterargument: I have worked on many many programs in GC languages where at no point in the program's lifecycle did I need to spend much time worrying about memory.
I believe the claim "all programmers will spend more time dealing with memory in GC languages" is false.
I have absolutely not made any claim that "no programmer will spend more time dealing with memory in GC languages". There are certainly programs that are better written with manual memory management. I've written plenty.
My point was only that there are also plenty of programs where that's not true.
That’s only true for workloads where you can optimize the memory layout/allocations. This may not be true in general, where you will end up implementing a shitty GC that will definitely perform worse than a properly written one.
Also, the cost of a proper GC is heavily overblown in my opinion. Especially when value types are available.
Then there are benchmarks where Nginx is faster than Caddy by factor of 3.
I very much prefer for this reason reference counting. Yes, time can be wasted fighting leaks through cycles, one still have avalanches of releases problems, the performance can be bad with certain usage patterns. But solving these problems is at least local and does not required rewriting the whole application.
One workaround is giving the threads their own private reference counter (making sure they're on different cache lines), and updating the shared reference counter only when their private reference count reaches 0. Not only do you stop the fighting, you don't even need atomic operations to update the private reference counts anymore.
The only hairy issue then becomes multiple processors needing to update the reference counter, where the count has to go in an out of the various processor's cache. One possible way to resolve that is to have a per-thread reference count, and only change the globally shared reference count when a per-thread's reference count would reach 0.
I feel that there are too many reserved words in Zig, and some of them are too long (comptime for instance).
Maybe that's just me being too picky about surface level concerns, but that kind of stuff really bothers me for some reason.
Other than that, I do like some parts of Zig. The way templates are designed seems superior to C macros and C++ templates, having your build scripts be in the language you are building is something more languages should do, the compiler is impressive in some ways... I suppose in theory all of that should make up for the poorly chosen keywords and whatnot.
I disagree with other commenters that think Zig is simple. At least compared to C, which I think is fair given Zigs target audience. C feels simpler despite all the undefined behavior and extensions to the standard and portability issues. Somehow I think it's down to the reserved words that were chosen.
Everything is superior to C macros, that’s like the worst thing ever created. Honestly, C is not a good language but if it would have had at least a proper macro system it could at least be remotely usable, e.g. having proper generic data structures.
I do have a hard time to think of use cases other than embedded microcontroller work where that matters nowadays. That said, I suspect that while they could have written TigerBeetle in Rust (no_std does not assume the existence of an allocator), it would have been more difficult.
People keep saying things like this and software keeps getting more bloated and shittier. It is difficult to believe there isn't a correlation.
The reasons for bloat is not garage collection.
My point is, knowing when to think/not think about allocating and its relevant costs is the proper way to program in a high level language. Only care about it when you are at a part that runs in a hot loop or very often, etc. Pretty much as per the second part of the often partially quoted “premature optimization…”.
The really awesome news is that for those of us who still develop software whose requirements mandate this kind of control - better tools like Zig (and Rust) are making things easier but without requiring things like a GC.
And in this respect Rust could be vulnerable, because Cargo.toml makes it way-easy to pull in a huge tree of transitive third party dependencies without even thinking about it.
Another example: realtime audio applications. There are rather strict rules about what you can and cannot do in the audio callback. In particular, you must not use the system memory allocator (because it may block). Instead you have to pre-allocate memory or use real-time safe memory pools. For this reason, the vast majority of (realtime) audio code is still written in C and C++.
In fact, you probably loose way more by not being able to write as many/great optimizations in a low-level language than what you lose on a slight overhead — e.g. Java’s Graal JIT compiler is nowadays performing better (written in Java itself) than the “original” C2 compiler which is written in C++. But this is mostly a maintainability question.
Secondly, we were talking about how Zig makes allocation first-class. This is a prime use case: specifying your own custom arena-based allocator.
The case against garbage collection is where latency is key. P95, P99 latency always suffers under any kind of garbage collector, no matter how advanced. You can minimize pauses, you can move them around, you can play with different collector aspects, but in the end... compare to explicit allocation/destruction/lifetime mgmt, it's going to suffer.
I don't think compilers generally have these latency concerns. Throughput matters, but not the odd unpredictable pause.
There's only so many application domains where that is super important: Certain kinds of high traffic servers, database engines [not so much query parsing & planning, but execution, data structures, buffer mgmt, networking], operating systems, and games. Also embedded systems where a tight memory profile is important.
I like Rust (and Zig) but sometimes when people have a good hammer, they go looking for nails where there aren't any...
(All that said, I feel like some tree/DAG-heavy problems in garbage collected languages could benefit from static (and runtime) reference analysis tools that look a lot like Rust's borrow checker, purely for code sanitation/tracing.
Also, GC tracing and some of the programming patterns GC encourages can cause havoc on L1 caches reducing throughput as well as a side-effect, so there's that)
Mainly I was speaking from the experience of trying to write more complicated parser & tree transformation pieces in Rust and finding the ownership stuff a hassle.
The kind of page buffer allocation done inside databases is kind of a different nature than the kind where you, e.g. override the default allocator in STL collections, or pass a different allocator into your standard collections library in Zig.
It's generally such that only specific data structures -- usually BTrees or HashTables meant to store relations/tables/indexes -- are managed this way. And so they're usually built from scratch around an explicit specific page buffer mgmt system. In some systems (like Umbra or LeanStore) this might be somewhat murkier in that it might use pointer swizzling etc behind the scenes, but it's still usually the case that the data structures used for relation storage are tied directly to the DB's own page buffer implementation anyways.
Now, other parts of the application stack may benefit from having a custom allocator in the same way as any performance critical system might, but that's of a different nature.
(That said, Rust is also not easily suited to the kind of "pointer-swizzling" behind the scenes bait-and-switch with memory that e.g. LeanStore does with C++. I've tried, and, while it's possible, the language in general gets in the way and you're `unsafe` all over the place anyways.)
Anyways, all this to say, TLDR given that all serious databases do explicit memory / buffer pool mgmt ... and build data structures specific to them, I don't see any intrinsic advantage to Zig for this purpose really? Other than it's a decently modern systems programming language that mostly stays out of your way.
But... I can see major advantages to using Rust: more developers, bigger community, larger ecosystem, safer for memory mgmt elsewhere in the stack, safer for concurrency, etc.
The real problem with Rust is hostile syntax for unsafe code that one often needs when doing low-level programming.
And you're really going to argue that defer,errdefer is more complex than RAII?
You write like it was a something surprising, but you know Zig is often described as a replacement for C..
enum variants are overused in Rust and are highly expensive in memory IMHO. One tends to run into problems like: https://github.com/serde-rs/json/issues/635 in Rust projects when used at scale. Value is an enum variant: https://docs.rs/serde_json/latest/serde_json/value/enum.Valu...
Comment from that issue: "For example, ElasticSearch returns a 8,683KB document, I deser it into Value and the next RAM reading gives me delta of 98,484KB of RAM use. That's more than 10x the original size."
Compare that with a similar issue in simdjson (written in C++), where people are complaining about parsing 4GB documents into a tree !!! https://github.com/simdjson/simdjson/issues/128. serde-json has trouble moving a bullock-cart while simdjson is moving a container ship.
I don't like some parts of Zig - I think not having RAII in the language is a terrible mistake. But I do love that anything and everything that can be allocated needs to be passed an allocator. I do think they could have introduced an allocation context for RAII cleanups though instead of forcing the programmer to manually coding defer base cleanups - this is a major source of errors even for advanced programmers.
I think this is misleading. `serde_json` is designed to parse JSON documents directly into your user-defined structs. Thus the raw `Value` type isn't optimized for direct manipulation.
That said, what do you expect? The `Value` enum has the `Value::Array` variant that contains a `Vec<Value>`, which is 24 bytes in itself. One for the heap pointer, one for the length, and one for the capacity. Because of alignment requirements the enum discriminant must thus also be aligned at the 8-byte boundary. This gives us a 32-byte type.
It is possible to reduce the variant to 16 bytes by making the value type `Box<[Value]>` instead. Ignoring the `Value::Object` variant for a second, that would make `Value` a 24-byte type. However, you've now also made the type immutable. It's a tradeoff. 8 bytes for mutable values.
Again, do remember that deserializing into `Value`s is _not_ the primary path when working with JSON through `serde_json`.
Enum variants should come with a STRICT warning in the Rust book and Rust reference that their real-world use should be incorporated very carefully. Most proponents of Rust tend to never mention their costs or caveats. They are most certainly NOT a zero-cost abstraction and tend to trip up lots of programmers.
"Thus the raw `Value` type isn't optimized for direct manipulation."
Maybe this statement this should be explicitly mentioned in the documentation: DO NOT USE `serde_json::value::Value` for moderate or large sized JSON inputs in production! Stack overflow answers merrily recommend the use of `Value` to a get a piece of data out.
This is more or less a solved problem.