I feel like I can write powerful code in any language, but the goal is to write code for a framework that is most future proof, so that you can maintain modular stuff for decades.
C/C++ has been the default answer for its omnipresent support. It feels like zig will be able to match that.
I like Zig a lot, but long-term maintainability and modularity is one of its weakest points IMHO.
Zig is hostile to encapsulation. You cannot make struct members private: https://github.com/ziglang/zig/issues/9909#issuecomment-9426...
Key quote:
> The idea of private fields and getter/setter methods was popularized by Java, but it is an anti-pattern. Fields are there; they exist. They are the data that underpins any abstraction. My recommendation is to name fields carefully and leave them as part of the public API, carefully documenting what they do.
You cannot reasonably form API contracts (which are the foundation of software modularity) unless you can hide the internal representation. You need to be able to change the internal representation without breaking users.
Zig's position is that there should be no such thing as internal representation; you should publicly expose, document, and guarantee the behavior of your representation to all users.
I hope Zig reverses this decision someday and supports private fields.
> You cannot reasonably form API contracts (which are the foundation of software modularity) unless you can hide the internal representation. You need to be able to change the internal representation without breaking users.
You never need to hide internal representations to form an "API contract". That doesn't even make sense. If you need to be able to change the internal representation without breaking user code, you're looking for opaque pointers, which have been the solution to this problem since at least C89, I assume earlier.
If you change your data structures or the procedures that operate on them, you're almost certain to break someone's code somewhere, regardless of whether or not you hide the implementation.
> You cannot reasonably form API contracts (...) unless you can hide the internal representation.
Yes you can, by communicating the intended use can be made with comments/docstrings, examples etc.
One thing I learned from the Clojure world, is to have a separate namespace/package or just section of code, that represents an API that is well documented, nice to use and more importantly stable. That's really all that is needed.
(Also, there are cases where you actually need to use a thing in a way that was not intended. That obviously comes with risk, but when you need it, you're _extremely_ glad that you can.)
I agree with this part with no reservations. The idea that getters/setters provide any sort of abstraction or encapsulation at all is sheer nonsense, and is at the root of many of the absurdities you see in Java.
The issue, of course, is that Zig throws out the baby with the bath water. If I want, say, my linked list to have an O(1) length operation, i need to maintain a length field, but the invariant that list.length actually lines up with the length of the list is something that all of the other operations need to maintain. Having that field be writable from the outside is just begging for mistakes. All it takes is list.length = 0 instead of list.length == 0 to screw things up badly.
If you really need to you can always use opaque pointers for the REALLY critical public APIs.
Python is a good counter example IMHO, the simple convention of having private fields prefixed with _/__ is enough of a deterrent, you don't need language support.
Unless the user only links an opaque pointer, then just changing the sizeof() is breaking, even if the fields in question are hidden. A simple doc comment indicating that "fields starting with _ are not guaranteed to be minor-version-stable" or somesuch is a perfectly "reasonable" API.
In Zig (and plenty of other non-OOP languages) modules are the mechanism for encapsulation, not structs. E.g. don't make the public/private boundary inside a struct, that's a silly thing anyway if you think about it - why would one ever hand out data to a module user which is not public - just to tunnel it back into that same module later?
Instead keep your private data and code inside a module by not declaring it public, or alternatively: don't try to carry over bad ideas from C++/Java, sometimes it's better to unlearn things ;)
In other words, we can at best form API contracts in C++ that work 99% of the time.
Of course increasing expressivity is not the end goal in itself for a PL, but I do agree with you that this (and some other, like no unused variable - that one drives me up a wall) design choice makes me less excited about the language as I would otherwise be.
Such a smart guy though, so I'm hesitant to say he's wrong. And maybe in the embedded space he's not, and if that's all Zig is for then fine. But internal code is a necessity of abstraction. I'm not saying it has to be C++ levels of abstraction. But there is a line between interface and implementation that ought to be kept. C headers are nearly perfect for this, letting you hide and rename and recast stuff differently than your .c file has, allowing you to change how stuff works internally.
Imagine if the Lua team wasn't free to make it significantly faster in recent 5.4 releases because they were tied to every internal field. We all benefited from their freedom to change how stuff works inside. Sorry Andrew but you're wrong here. Or at least you were 4 years ago. Hopefully you've changed your mind since.
Not to mention just about every language offers runtime reflection that let's you do bad stuff.
IMO, the Python adage of "We are all consenting adults here" applies.
It was an interesting experience and I was pleasantly surprised by the maturity of Zig. Many things worked out of the box and I could even debug a strange bug using ancient GDB. Like you, I’m sold on Zig too.
I wrote about it here: https://news.ycombinator.com/item?id=44211041
Rust makes doing the wrong thing hard, Zig makes doing the right thing easy.
Off topic - One tool built on top of Zig that I really really admire is bun.
I cannot tell how much simpler my life is after using bun.
Similar things can be said for uv which is built in Rust.
Rust is like a highly opinionated modern C++
Go is like a highly opinionated pre-modern C with GC
Language limitations are more on the SDK side of things. SDKs are available under NDAs and even publicly available APIs are often proprietary. "Real" test hardware (as in developer kits) is expensive and subject to NDAs too.
If you don't pick the language the native SDK comes with (which is often C(++)), you'll have to write the language wrappers yourself, because practically no free, open, upstream project can maintain those bindings for you. Alternatively, you can pay a company that specializes in the process, like the developers behind Godot will tell you to do: https://docs.godotengine.org/en/stable/tutorials/platform/co...
I think Zig's easy C interop will make integration for Zig into gamedev quite attractive, but as the compiler still has bugs and the language itself is ever changing, I don't think any big companies will start developing games in Zig until the language stabilizes. Maybe some indie devs will use it, but it's still a risk to take.
You're not really going to make something better than C. If you try, it will most likely become C++ anyway. But do try anyway. Rust and Zig are evidence that we still dream that we can do better than C and C++.
Anyway I'm gonna go learn C++.
Creating a better C successor than C++ is really not a high bar.
I don't doubt that compilers occasionally break language specs, but in that case Clang is correct, at least for C11 and later. From C11:
> An iteration statement whose controlling expression is not a constant expression, that performs no input/output operations, does not access volatile objects, and performs no synchronization or atomic operations in its body, controlling expression, or (in the case of a for statement) its expression-3, may be assumed by the implementation to terminate.
Thus in C the trivial infinite loop for (;;); is supposed to actually compile to an infinite loop, as it should with Rust's less opaque loop {} -- however LLVM is built by people who don't always remember they're not writing a C++ compiler, so Rust ran into places where they're like "infinite loop please" and LLVM says "Aha, C++ says those never happen, optimising accordingly" but er... that's the wrong language.
Worth mentioning that LLVM 12 added first-class support for infinite loops without guaranteed forward progress, allowing this to be fixed: https://github.com/rust-lang/rust/issues/28728
while (i <= x) {
// ...
}
just needs a slight transformation to while (1) {
if (i > x)
break;
// ...
}
and C11's special permission does not apply any more since the controlling expression has become constant.Analyzes and optimizations in compiler backends often normalize those two loops to a common representation (e.g. control-flow graph) at some point, so whatever treatment that sees them differently must happen early on.
Do note that your linked godbolt code actually demonstrates one of the two sub-par examples though.
For complicated things, I haven't really understood the advantage compared to simply running a program at build time.
I don't think this is a good comparison. You're telling the compiler for Zig and Rust to pick something very modern to target, while I don't think V8 does the same. Optimizing JITs do actually know how to vectorize if the circumstances permit it.
Also, fwiw, most modern languages will do the same optimization you do with strings. Here's C++ for example: https://godbolt.org/z/TM5qdbTqh
Now, one rarely uses typed arrays in practice because they're pretty heavy to initialize so only worth it if one allocates a large typed array one once and reuses them a lot aster that, so again, fair enough! One other detail does annoy me a little bit: the article says the example JS code is pretty bloated, but I bet that a big part of that is that the JS JIT can't guarantee that 65536 equals the length of the two arrays so will likely insert a guard. But nobody would write a for loop that way anyway, they'd write it as i < x.length, for which the JIT does optimize at least one array check away. I admit that this is nitpicking though.
Is this line really true? I feel like expressing intent isn't really a factor in the high level / low level spectrum. If anything, more ways of expressing intent in more detail should contribute towards them being higher level.
Something like purchase.calculate_tax().await.map_err(|e| TaxCalculationError { source: e })?; is full of intent, but you have no idea what kind of machine code you're going to end up with.
In yet other words, tautology.
So I have two lists, side by side, and the position of items in one list matches positions of items in the other? That just makes my eyes hurt.
I think modern languages took a wrong turn by adding all this "magic" in the parser and all these little sigils dotted all around the code. This is not something I would want to look at for hours at a time.
for (one, two, three) |uno, dos, tres| { ... }
My eyes have to bounce back and forth between the two lists. When the identifiers are longer than this example it increases eye strain. Maybe it's better when you wrote it and understand it, but trying to grok someone else's code, it feels like an obstacle to me.I've avoided such manual specification of aliasing because:
1. few people understand it
2. using it erroneously can result in baffling bugs in your code
Compile time function execution and functions with constant arguments were introduced in D in 2007, and resulted in many other languages adopting something similar.
I love Zig too, but this just sounds wrong :)
For instance, C is clearly too sloppy in many corners, but Zig might (currently) swing the pendulum a bit too far into the opposite direction and require too much 'annotation noise', especially when it comes to explicit integer casting in math expressions (I wrote about that a bit here: https://floooh.github.io/2024/08/24/zig-and-emulators.html).
When it comes to performance: IME when Zig code is faster than similar C code then it is usually because of Zig's more aggressive LLVM optimization settings (e.g. Zig compiles with -march=native and does whole-program-optimization by default, since all Zig code in a project is compiled as a single compilation unit). Pretty much all 'tricks' like using unreachable as optimization hints are also possible in C, although sometimes only via non-standard language extensions.
C compilers (especially Clang) are also very aggressive about constant folding, and can reduce large swaths of constant-foldable code even with deep callstacks, so that in the end there often isn't much of a difference to Zig's comptime when it comes to codegen (the good thing about comptime is of course that it will not silently fall back to runtime code - and non-comptime code is still of course subject to the same constant-folding optimizations as in C - e.g. if a "pure" non-comptime function is called with constant args, the compiler will still replace the function call with its result).
TL;DR: if your C code runs slower than your Zig code, check your C compiler settings. After all, the optimization heavylifting all happens down in LLVM :)
fn signExtendCast(comptime T: type, x: anytype) T {
const ST = std.meta.Int(.signed, @bitSizeOf(T));
const SX = std.meta.Int(.signed, @bitSizeOf(@TypeOf(x)));
return @bitCast(@as(ST, @as(SX, @bitCast(x))));
}
export fn addi8(addr: u16, offset: u8) u16 {
return addr +% signExtendCast(u16, offset);
}
This compiles to the same assembly, is reusable, and makes the intent clear.I agree with everything flohofwoe said, especially this: "C is clearly too sloppy in many corners, but Zig might (currently) swing the pendulum a bit too far into the opposite direction and require too much 'annotation noise', especially when it comes to explicit integer casting in math expressions ".
Seems like I will keep using Odin and give C3 a try (still have yet to!).
Edit: I quite dislike that the downvote is used for "I disagree, I love Zig". sighs. Look at any Zig projects, it is full of annotation noise. I would not want to work with a language like that. You might, that is cool. Good for you.
Virgil leans heavily into the reachability and specialization optimizations that are made possible by the compilation model. For example it will aggressively devirtualize method calls, remove unreachable fields/objects, constant-promote through fields and heap objects, and completely monomorphize polymorphic code.
(Note that this is orthogonal to whether and to what extent use of AI for coding is a good idea. Even if you believe that it's not, the fact is that many devs believe otherwise, and so languages will strive to accommodate them.)
I don't love the noise of Zig, but I love the ability to clearly express my intent and the detail of my code in Zig. As for arithmetic, I agree that it is a bit too verbose at the moment. Hopefully some variant of https://github.com/ziglang/zig/issues/3806 will fix this.
I fully agree with your TL;DR there, but would emphasize that gaining the same optimizations is easier in Zig due to how builtins and unreachable are built into the language, rather than needing gcc and llvm intrinsics like __builtin_unreachable() - https://gcc.gnu.org/onlinedocs/gcc-4.5.0/gcc/Other-Builtins....
It's my dream that LLVM will improve to the point that we don't need further annotation to enable positive optimization transformations. At that point though, is there really a purpose to using a low level language?
That is quite a long way to go, since the following formal specs/models are missing to make LLVM + user config possible:
- hardware semantics, specifically around timing behavior and (if used) weak memory
- memory synchronization semantics for weak memory systems with ideas from “Relaxed Memory Concurrency Re-executed” and suggested model looking promising
- SIMD with specifically floating point NaN propagation
- pointer semantics, specifically in object code (initialization), se- and deserialization, construction, optimizations on pointers with arithmetic, tagging
- constant time code semantics, for example how to ensure data stays in L1, L2 cache and operations have constant time
- ABI semantics, since specifications are not formal
LLVM is also still struggling with full restrict support due to architecture decisions and C++ (now worked on since more than 5 years).
> At that point though, is there really a purpose to using a low level language?
Languages simplify/encode formal semantics of the (software) system (and system interaction), so the question is if the standalone language with tooling is better than state of art and for what use cases. On the tooling part with incremental compilation I definitely would say yes, because it provides a lot of vertical integration to simplify development.
The other long-term/research question is if and what code synthesis and formal method interaction for verification, debugging etc would look like for (what class of) hardware+software systems in the future.
One thing I was wondering, since most of Zig's builtins seem to map directly to LLVM features, if and how this will affect the future 'LLVM divorce'.
What's good for the goose should be good for the gander.
Yeah but some language features are disproportionately more difficult to optimize. It can be done, but with the right language, the right concept is expressed very quickly and elegantly, both by the programmer and the compiler.