210 comments

[ 3.6 ms ] story [ 224 ms ] thread
I don't know why this article keeps showing up on HN every other year but since it's up, I took the time to read it and fix all the outdated facts.

Notably, when it was written before there was not a package manager available. Now there is, so I rewrote that section. It should finish deploying in a couple minutes.

Since you’re updating it anyway, the very first example says "D has @property functions, which are methods that you call with what looks like field access, so in the above example, c.d might call a function.”

It is true that `c.d` might call a function, but it’s not because of @property functions (which exist but are discouraged), it’s because the parens are optional to call a function/method if there are no arguments.

Philosophically speaking, is it really hidden control flow if a function call just happens to look like a attribute access?
I've only seen flow control used in the context of conditional branching, so if the function contains no branches, I don't see why it would. Hidden overhead or indirection, sure, but not necessarily flow control, for the same reason e.g. indirect pointer access isn't.
function calls are, themselves, branches.

underneath there is literally assembly to take current state, push it to the stack, then jump into another location in memory and start executing.

And then when done, do the reverse. jump back to another memory location and pop everything back off the aforementioned stack and into registers.

Yes, but that's not a conditional branch, and that's the distinction I've always seen made for flow control versus indirection
try/catch/finally is also considered control flow and it's not conditional, although, unfortunately I've seen people use it as a control flow mechanism.

interrupts are also control flow but, again, there are no conditionals there.

control flow is about the order of execution, one common way to get there is using conditionals, but it's not the only way.

Having said that, even if you disagree with Andrew's wording, the overarching idea remains. You always know explicitly if the flow changes.

try/catch seems very conditional to me. If there is an error condition, jump to the error handling code. Or am I missing something?
I could also describe a JMP instruction as "if you got here, jump to there", but just because I can doesn't necessarily mean I should.
But Try, catch and throw are in themselves not conditional, they are fancy gotos with bookkeeping. Those constructs don’t typically deal with conditions.

However I think the notion that “flow control” has to do only with conditionals is not a common one. FWIW the definition on Wikipedia, which aligns with my experience is not this restrictive… the lowly goto is flow control. Basically anything that controls the program location is flow control.

Yeah, but raising the error is the conditional in there. Which, to me, makes the try catch conditional as well, but that may be a case of differing semantics.

Yeah, flow control isn't just conditional jumps. You can kinda treat branchless code as a form of flow control as well.

> You can kinda treat branchless code as a form of flow control as well.

That's the common understanding of flow control, the other poster is right in that it's not typically just conditionals. There are a few posters who were under that misapprehension, but that's never been the common understanding of it, it's just that conditionals are typically where people worry over flow control because otherwise it's linear.

I haven’t usually used such a restrictive definition, flow control is any mechanism that directs program location. https://en.wikipedia.org/wiki/Control_flow

And flow control and indirection are not two concepts I’ve ever grouped together as complementary either. A function call is not something I’ve ever heard as “indirection”.

When reading code in a low level setting you may want to know whether one line calls a function or simply reads a field from memory. Not that it would matter that much, but it's a valid point for some. Reading a field is usually expected to not induce any control flow
> When reading code in a low level setting you may want to know whether one line calls a function or simply reads a field from memory.

Unless you ditch optimizations completely, it is hard to know without looking at the generated asm. Of course you can make educated guesses.

The idea in D is to make the following unnecessary:

    struct S {
        int m_field;
        int field() { return m_field; }
        void field(int c) { m_field = x; }
    }
just to future proof the field access. Don't bother with the access functions until they are actually needed.
The actual feature that languages should implement is lenses, prisms and optics which are like properties except made properly composable and enabling high-level optimizations, for a similar efficiency to "bare" field access.
I’ve never heard of those. Do you have a link explaining them?
I found a use for the overriding field access once, when there was a large data structure bit-packed into a byte array. I didn't really need to convert it to a different data structure, but to just extract some values, and pack it back to the byte array.

So I overrode the field access that operated directly on the byte array.

PS: It was in Kotlin, using @get/@set, but it doesn't matter for the philosophy.

Yes. That can affect performance and also cause spooky action at a distance. Suppose someone less careful put some code in the accessor function mutates global state. Do you want to be checking all of your objects all the time to see if they are doing something counterintuitive?
Interesting that this is seen as a negative, C# has getters/setters that look like property access, but I don’t think I ever heard anyone complain about this.
zig and c# each scratch a different itch.
Yeah, of course. But the issue would still be the same in this case.
While the issue is the same, context can make things different. In Ruby, I love proxy objects returned from accessors. In Rust, I wouldn't even support adding accessors to the language. Different goals and objectives.
C# inherited it from Delphi, so there's plenty of experience showing that this is not an issue in practice. Thing is, if you don't have accessor methods (or some equivalent syntactic sugar as in D) in a OO language, you end up with them anyway, just ad hoc like Java's get/set methods. And then you still have to think about which methods that look like they're pure queries might actually mutate shared state.

If anything, I would argue that explicit accessors make it clearer because in e.g. C# you can't write a method that looks like a getter (but mutates state) by accidentally naming it as such. If someone made something a property, that's because they thought its semantics are property-like, which includes not mutating global state. Sure, people will still get that wrong occasionally, just as they run mutating "get" methods, but it is surprisingly rare.

> because they thought its semantics are property-like,

Suppose you include a logging statement in your accessor, which hides a race condition. You're gonna tear your hair out.

Suppose you do the same in your getWhatever() method instead; what's the difference? If every property access is an explicit method call, the mental overhead on the API client is the same in practice, since most of those method calls are still semantically property-like and are treated as such.
(comment deleted)
C++ Builder also has them in their extensions to C++. My guess is that it looks convenient for languages that have a de facto GUI framework/library. In Delphi/Object Pascal, C#, C++ Builder, it becomes convenient to, for example, change a button's text as if assigning to its text value. And it is often taken for granted how the form's UI just "magically" updates. I'm pretty sure classic VB's control "properties" work that way as well.

When I was new to their corresponding GUI frameworks, the dot + property notation was appealing because it looked clean. However, as I got more experienced I'm no longer seeing the advantage of saving two parentheses characters at the expense of potentially hiding function calls where anything can happen.

I'm not sure why D joined the club given it doesn't have a corresponding de facto GUI framework, and its users even discourage it.

"If a tree falls in the woods and no one is around to hear it, does it still make a sound?"

Attribute access is reading a value, invoking a function is different (as mentioned, using a stack, etc.), although it is certainly possible a function only reads and returns a value.

All that to say my answer is yes, providing flow control mechanisms makes it flow control... and falling trees make a sound!

Probably because it's a sort of call to arms to those languages to try and improve things where Zig is currently on top :D. Some of those can't be fixed (e.g. hidden control flow, hidden allocations) but some can (e.g. I think D has an alternative allocator API, it can now compile C/C++ code as well, and I believe its package manager is trying to add support for system dependencies as well).
D has a pluggable GC, if that's what you mean. It makes trying out various GC strategies easy without modifying the compiler.

D also supports the following memory allocation strategies:

1. RAII

2. stack allocation

3. malloc/free

4. custom alloctors

Each has its tradeoffs, you can pick the most appropriate one. The D compiler itself uses all of them :-/

Hi Andrew. I once saw a video (at least I think it was a video) where you suggested the possibility of Zig reaching language stability in 2025. Is that still accurate?
The Bun team I guess doesn't care and went ahead with 1.0 launch a few months ago. If they are willing to bet it all on zig, I'm sure it will be around for a while. It's quite mature as it stands now. Personally, I think the road to zig 1.0 should be made methodically and without any timeline.
I don't know that this is an endorsement of zig rather than an indictment of bun.
Total endorsement - thanks for forcing clarification.
I don't recommend choosing technologies this way. Elm is one of many recent, prominent examples of how years of promising language work can still leave production users stranded, both with backwards-incompatible changes that are deal-breakers for many projects, and then stalling further updates anyway even for the users still able to use the latest version.

I'm not saying Zig will go the same way, I hope it succeeds. But success in industry funding is not a guarantee, successful transition to large team contributions is not a guarantee, and even if all of that does pan out, by then the language may be markedly different.

Look at Rust in 2012 vs its 1.0 in 2015 and ask yourself if you'd want you and your team to have to make so many changes to production code. Ask yourself how many unknown costs you are willing to risk in exchange for whatever you believe is the actual upside of using Zig for a particular project.

Sure, somebody has to break that cycle by making those bets, but be sure before deciding to be among them. Somebody else making that bet only changes the odds so much.

Except I’m referring to an ecosystem built on top of zig. Not Zig proclaiming to solve the web’s problems. There’s two teams here that say “we think zig is good”. One, the language team (your Elm reference), the other, the bun team (and all that js ecosystem). I’m pretty confident in saying that it’s not just some plaything or niche language. Then again, I can’t predict the future and it could very well be that bun and zig both blow up. Same could be said for Rust.
I know what you mean but it's not that simple. The ecosystem clearly depends on the language in a very real way. If Zig changed enough to be a deal breaker for Bun, or even stopped being maintained at all, then Bun is stranded until they can move to a completely different language.

(I'm not even willing to entertain the notion that they would fork Zig and maintain it long term. Even if they somehow did which would be nuts on its own, they'd still be cut off from the rest of the ecosystem and lose out on that front.)

My best case for Zig is that it offers a worthwhile option to clean up existing C & C++ projects without incurring, or at least deferring, the more substantial costs of rewriting outright in Rust, bindings or no. "Maintain it in Zig" is a sensible value proposition, or at least it will be once that Zig code has a compatibility promise so it's not trading one maintenance cost for another.

Look at how costly it was for the ecosystem to migrate from Python 2 to Python 3, even despite how many participants in that ecosystem were themselves large, well-funded corporations. Situations like that are why Go and Rust have made permanent 1.0 compatibility promises even at the cost of some tech debt which they know will only accrue further. Zig will too, but it hasn't yet, and anybody adopting it now risks unknown migration costs.

> Then again, I can’t predict the future and it could very well be that bun and zig both blow up. Same could be said for Rust.

Sure, lots of things are possible, but ask yourself which is more likely. That's all I'm saying here. Rust has already been where Zig is now and is in a meaningfully different place today: a stability promise and substantial industry adoption and contribution. It no longer depends on any one person and a lot of investment is going in to its continued success.

Anything can still fail, but there aren't that many examples of languages outright failing after reaching this point in industry adoption. There are many examples of languages outright failing by pushing backwards-incompatible changes on the industry right in the middle of what should have been a healthy growth curve. There are also many examples of simply not offering enough marginal value to justify their marginal costs.

The title here is "C++, D, and Rust" and, I'm sorry to say, but one of these is not like the other ones. Zig will have a lot of work to do in order to end up more like Rust than D, especially when memory and thread safety are a big part of why the industry is adopting Rust in the first place. Notice how Chromium does not have a Zig branch just like it never had a D branch. It was all C++ until Rust offered clear improvements to safety in exchange for its adoption costs.

“Notice how Chromium does not have a Zig branch just like it never had a D branch. It was all C++ until Rust offered clear improvements to safety in exchange for its adoption costs.”

Chromium has a rust branch because “Rust has X feature”, not because zig does too and there was never a decision: rust or zig.

That’s apples and oranges my friend. All I’m saying is Zig, while lagging behind its corporate sponsored behemoths, is going to be around for quite a while. There’s a lot of game dev happening with zig too. Bun is just an easy target to use as an example.

I do agree with your argument about “why choose X over Y to rewrite your codebase” but to clarify, I never made such comparisons. I wouldn’t recommend zig for a c++ refactor. Maybe a rewrite if it was small. Definitely for green field development. A stability promise is still just a promise. Backwards compatibility is a valid argument to make. Will my code from a decade ago compile today? Zig still has some growing to do before that is true.

They also made a 1.0 release without Windows support, so that already says a lot about what they care about.
Great, concise summary.

Note: the original article title could perhaps be updated to include Golang as it is mentioned a few times.

What about the Go try/catch claim? There is no try/catch in Go. Returning errors and values is closer to Rust and Zig than D and C++.
Go has panics. You can unwind them, and recover from them. Using these for control flow is heavily discouraged and therefore it's rarely ever an issue, but they are a type of exception handling, or at least very close to that.
Rust has panics too, which also unwind, and you can also recover from them; they are also a type of exception handling. Unless the project was compiled with panic=abort, in which case they don't unwind at all and you can't recover from them; the existence of the panic=abort option means one cannot reliably use them for control flow, which makes it harder to abuse. But even then, you still have to make sure your code is "panic safe" when writing library code (usually through clever use of RAII guards, instead of expecting the code flow to always reach the end of a function).
Also worth noting is that while they are discouraged, the very same Go devs who discouraged this pattern chose to use it in the standard library for basic things.
New people will constantly be finding zig, and they will constantly be wondering why they should pick up zig, they will find that reference, and share it to educate others!

You can see this happening with the duplicate stories that show up on Hacker News; in general, it's a good thing:

“There are only two kinds of languages: the ones people complain about and the ones nobody uses.” ― Bjarne Stroustrup

Does the requirement to pass around an allocator end up causing a "what color is your function" problem?
No if anything it solves that. It's inversion of control.. the caller chooses the bahavior not the callee.

Using async/await as an example, you have to have an async implementation (red) of a function and a non-async implementation (blue). In this case, the callee chooses the behavior.

If instead you had a function that took some sort of runtime as a parameter that it could use to run downstream code synchronously or asynchronously, then you'd have something comparable to Zig's allocator. The red and blue functions would instead be one function where you pass a different runtime.

Adding additional function argument is quite a stretch to be called as color change. For application code you could use global allocator though.
(comment deleted)
From a syntactic perspective, no, because there is an Allocator vtable interface in the stdlib. Anything that implements alloc, resize, and free can be used interchangeably in any function that takes a `std.mem.Allocator` argument. You don't need to type different things on your keyboard in order to handle different allocators within a function.

From a code organization perspective, slightly, only in the sense that must ensure that you free memory using the same allocator that allocated it.

This page seems only halfway there. These are not quite terminal arguments. No hidden allocations, standard lib is optional, simplicity - all cool! But the question is, when are those the right tradeoffs?

For example, I could see these being attractive in embedded work. I could see the simplicity becoming a headache in other contexts, like making abstracted all-purpose numerical libraries a la Eigen.

Maybe I am wrong about those particulars! But I would like the arguments completed: Zig has X distinctive features, which you should prefer if you do Y.

This is relevant to my interests right now: I am working on scaling up some scientific algorithms for astronomy research which have been well prototyped in Python, but which need to be faster. I am currently, unhappily, doing my work in C++ after abandoning Rust for being too immature in its CUDA and SIMD support, while also feeling pretty complex. Would Zig do well enough for me? I want a little more ink on the page to help me think this through.

I think the opinions on how a language is used should be left to the developer of in question. If zig said "Zig has X distinctive features, which you should prefer if you do Y" would alienate everyone doing Z and C and D. If ruby did that, we would never have rails. "Ruby has easy coding features, which you should prefer if you do scientific programming". Rails wouldn't exist.

So, read the article and determine - "Does Zig with X features solve my use case of me trying to build Z after trying with Y"?

(comment deleted)
I read a point about lowering activation energy on JDK website about making language more approachable. Though Zig is lower level language and much of use for typical enterprise devs like me it does have vibe of low activation energy.

I feel a lot of useful software will be written in Zig once it is picked by young/upcoming clever developers.

Yeah. How little do I have to know to be able to do a little? It really matters.

But then, it also matters that it not trap you. It's easy to only have to learn a little when a little is all there is. But you don't want to use a language like that for something major, because it won't do enough for you.

So what you need is something like what the UI people call "progressive disclosure". Are there any languages that do that well?

It happens that a little is all there is, and you can do everything with it. Languages large enough to need progressive disclosure have made mistakes that prevent things from being as simple as they are.
A better question is, why zig when you could use jai instead?
If Blow released it openly then I would consider it.
Last time I looked Jai was in a closed beta.
Jai has no embedded support.
no 32-bit embedded support—as long as the target platform is 64-bit it'll work just fine for such purposes.
That's silly. Embedded developers rarely work on 64-bit target platforms. Much more common are 8-bit micros such as AVR or PIC with the 32-bit STM32 when you need more power.
jai feels like a set of easy but not very deep sugar extensions around c. There is no big though story about aliasing, ABI and call optimizations in jai unfortunately. There are dozens of similar projects (V lang, caught caught, C3, ...) from authors frustrated by C but without stable foundation.
What exactly does Jai bring to the table?
> If you never initialize a heap allocator, you can be confident your program will not heap allocate.

This is true for the standard library, where I can trust that no hidden allocations will be made. If I pull in a third party package, nothing guarantees that that lib won't init a new `std.heap.GeneralPurposeAllocator(...)`, right?

No, and it would be impossible to prevent as well: obviously library code can do anything it wants, including asking the kernel for memory.

But this is one of those things which just wouldn't happen in practice: custom allocators are so embedded in the Zig philosophy that I cannot imagine competent Zig programmers writing a library that did something like this. It's just not what you do in Zig.

It's not impossible to prevent. Some languages do just that using capabilities. You can get capabilities by declaring them in your main function, for example, and only passing the capabilities you want to code downstream, such that it becomes literally impossible for any code to get IO access or allocate memory, for example, if the code is not explicitly given that capability. I believe Pony and Unison are examples of languages that do that (not for allocation, admittedly as they are both GC'd, but the concept would work in a language like Zig).
The thing you're talking about is just not possible in a low-level, runtime-less language like Zig. Like, utimately Zig. libraries need to generate arbitrary assembly to run on your architecture (like if you want to write a driver or something), so you can't stop someone from just writing a thing that does the syscalls itself.

The kind of "capabilities" style languages you are talking about almost always have either a runtime that handles the actual syscalls, or they don't have the capability to compile directly to the assembly you need, everything has to pass through some library. Zig does not fit into either category: it has no runtime, and the whole point of the language is to be a low-level C replacement.

Right, but that would be unidiomatic and the third party package should be criticized for doing that. Proper form would be for the allocator to be chosen by the user of the package, supplied as a parameter.
> Examples of hidden allocations:

> Go’s defer allocates memory to a function-local stack.

Not really hidden if there's keyword indicating it's happening, no? I guess you could argue that "defer" isn't called "defer_and_allocate", but on the other hand, the docs make it clear that it allocates.

I think it’s fair to say that knowing what allocates in Go requires in-depth knowledge of its internals, well beyond the `defer` example. Every possible allocation is not obvious to the typical Go programmer. It is obvious in Zig.
Although with a language like Go, I would think programmers should expect that allocations occur frequently, and rely on the GC and language design to ensure decent performance or whatever is being asked for. It won't hold true all the time and sometimes more knowledge is needed, but the baseline wouldn't involve that.
Lack of destructors/RAII is the deal breaker for me. I know that this causes a function call to be made when a variable goes out of scope, which is "hidden control flow" and therefore banned by the language design.
Same with lack of vector operators for me. I've asked a lot :(
We have them.... for SIMD vectors.
This forces us to use homogeneous 4D vectors for 3D ops ({vec3, 0} for directions and {vec3, 1} for points), which basically wastes registers when we're doing the right thing with the right vec3 types on a e.g. 32-wide vector machine.

If Zig really wanted to do the best job here, it should recognise that we're semantically not using this 4th padding dimension if we're not doing 4D projective stuff. If it really is going to force you up to 4D space, then it should be doing compile time checks e.g. sorry, you tried to add point plus a point which makes no sense, unlike vectors, or vectors and points.

This might become more important if Zig to GPU code translation becomes a thing...

Right, clearly SIMD vector primitive ops are not what you want for a 3d math lib.
If you can avoid all hidden control flow, that's actually a great choice. Rust couldn't do this originally because it needed panics, which must auto-destruct objects on the stack.
No it is not - because it means you don't get RAII and so you have to clutter your code with calls to destruct things. In real code there are often several paths that things could be destroyed in and so there is a lot more manual code. Code that we know from experience people often forget to write. (memory leaks are a thing)..

Okay, you can solve the forget problem with static analysis. But since in most cases I don't care about destruction so long as it happens, when reading the code I prefer this hidden.

Abstraction means hiding details and done right is a great thing. Yes abstraction often is abused to hide the wrong thing, but that isn't the fault of abstraction as a concept.

I prefer explicit blocks for automatic object destruction to RAII. For example,

  withThing { thing => doStuffWith(thing) }
This makes it clear to me that the object is only valid within this block. There is no copy constructor madness where I might completely lose track of where the object might be alive and in scope.
Note that C++'s design of RAII and Rust's design have important differences.
The problem with blocks is that you can forget them.
Rust does keep track of where the object is alive and in scope, even if that happens to be decided dynamically, e.g. an object may only exist in a condition branch or as the result of a loop running at least once.

Objects also won't be silently copied unless they are types that explicitly marked as trivial to copy (i.e. bytewise).

It then also guarantees cleanup if the object actually does exist.

Rust already does what many people want for deterministic, safe cleanup, which like C++ includes RAII, but unlike C++ is not limited to RAII.

(comment deleted)
> But since in most cases I don't care about destruction so long as it happens

But you might want to care about where in the code the destruction happens. This is quite normal in a low level language. Of course I do agree that it should be easy to avoid leaking resources, but static analysis can ensure this in most cases as you point out.

RAII always gives me cleanup at the last possible moment. Sometimes I might want to force the cleanup sooner, but later than this always means never which is not what I want.
That's indeed one of the downsides of RAII in C++ (unless the type supports zero-cost moved-from objects), but it's not a universal issue. Rust's implementation lets you transfer ownership of an object ("move" it) from one variable to another. Afterward, the old variable will be uninitialized, and the new variable will hold the object, with its scope determining when the object is destroyed.

If the new variable has a smaller scope, then the object will be destroyed sooner than it would have been otherwise. In particular, to assist in early cleanup, the standard library has a drop(value) function, that takes ownership of any value and immediately lets it fall out of scope, destroying it.

This is especially useful for things like destroying RAII mutex guards to immediately unlock the corresponding mutex.

Everything's a tradeoff. Zig's insistence on banning hidden control flow does force you to write destructors, yes. But that has led to the Zig standard library (and wider idiomatic culture) having standard allocators as parameters. So in practice you can just choose an arena allocator and stop worrying about cleaning things up yourself.
In real code in Zig you'd just use "defer" so you still write that destructor call only once, and it would normally immediately follow the declaration-and-initialization code.

If you're writing the kind of things where you don't care at all about destruction so long as it happens, why even consider something as low-level as Zig? There are many other languages already covering that niche.

A benefit to making some things annoying to do is that people will do less of them. For example, if there is no RAII, more types will be written in such a way that they do not require deinit to be called.

As an aside, solely for the purpose of making Zig usable as an environment for people whose primary professional responsibility is not really software (quantitative researchers, data scientists, etc.) I would prefer if RAII was somehow very painful to implement for a type rather than impossible. That's the status quo for dynamic dispatch.

Honestly, most everything listed on the page as an advantage of Zig, is a disadvantage from my point of view.

I'm sure Zig has its use cases. For what I write, I not only don't care if there's a hidden function call or error handling, I see those as 100% necessary for a modern language.

Needing to handle errors inline is a huge mess for anything nontrivial. It distracts from the logic that's important at that point in the code. Being able to override an accessor to do something instead of being a raw access is incredibly useful; a tiny change and rebuild is all that's required to track information that you would otherwise need to rewrite an entire app to support.

If you're writing extremely low level code and libraries, especially embedded, then fine, minimizing hidden behavior is important. Being able to operate without a standard library is also important in that case. Outside of that niche, though, there are few places I'd call those "features" of Zig an advantage.

> Needing to handle errors inline is a huge mess for anything nontrivial

Maybe try zig first before you make such a blanket statement?

Zig's error handling is unobtrusive. You can just write `try` if you want to propagate errors instead of handling them in some specific way.
Yeah zig just doesn't offer enough advantages over "I'll just use c++ as a better c with raii, containers, safe string class, template functions, and very simple classes/powerful structs (no inheritance), and a threading standard library"
Lack of proper interfaces/traits is also a deal-breaker. One appears to need to do un-readable hacks to get a basic interface in Zig.
Interfaces/traits is syntactic sugar for a function dictionary/vtable. It could probably be done via Zig's compile time subset.
Standardized language-defined syntactic sugar is deeply important - esp for something as important as interfaces/traits - which are the foundation blocks for setting component contracts in libraries and modules.
Not if the standardised way is worse than the naive way, like in C++. Then I will rather take manual virtual calls, like in C: https://elixir.bootlin.com/linux/latest/source/include/linux...

It isn't that much more work and it's not something you end up doing terribly often. I think destructors are a much more important to help day to day development and avoid bugs.

In Rust, traits can be used via a vtable, but also (and the vast majority of the time) as regular old statically dispatched functions.

That doesn't mean Zig couldn't do something similar if they want to, just pointing out that it's not always the case that it means dynamic dispatch.

I didn't mention run-time dynamic dispatch, only a function dictionary (which C++ calls a vtable). Using traits for bounded subtype polymorphism is much like having a dictionary of functions that can be resolved completely at compile time, making it possible to use monomorphization and static dispatch.
Okay. It's clear we're using some words in very different ways (this is not subtype polymorphism in Rust either) but I see what you're getting at.
Another problem for me was the lack of error "payloads" (i.e. you can't give some values, like even an error message, with your errors - they're just pure enums without any context) which basically means you need to invent your own custom error system if you don't want your error reporting to suck... but I believe they were going to fix that (not sure if they've done anything yet as I haven't been following too closely, would be happy to be corrected).
I just pass a "diag" struct to functions that may need to return an error context. It was weird at first but then it dawned on me how much less friction I experienced being able to create error variants on the fly. There's also something to be said for seeing all (or most) of the relevant logic inline vs jumping around trying to make sense of separate definitions.
I'm confused by "No hidden control flow"

To me control flow is "if", "switch", "?:", etc. What article describes are abstractions. Abstractions are not bad. They, just like anything else, can be abused. Maybe one may argue are easy to abuse or tend to be abused. But "Zig has no abstractions" is hardly a selling point.

On the other hard there are statements like "defer" and "try" which actually are very hidden and unusual control flow statements. Why the naming? Who the hell knows. I see try, I look for catch/except/finally, but there are none. Try in Zig means something else. "defer" is literally "try/finally", but less explicit about the scope.

"A Portable Language for Libraries" "A Package Manager and Build System for Existing Projects" "drop-in GCC/Clang command line compatibility with zig cc"

Is it still so after ditching LLVM?

> To me control flow is "if", "switch", "?:", etc. What article describes are abstractions.

Thank you for articulating this, I also don't understand why the author puts operator overloading in the same bucket as exceptions and `defer()`. Yes, `+` might call a function, but it will never affect the control flow of the parent function. Not more than a regular function call, at least.

I think you have a different interpretation of “hidden” than Andy.

He is talking about control flow that is not visible to the programmer at all. In Zig, you can understand the control flow of a particular code snippet just by learning Zig. In some other languages, operator overloading means that when you are reading unfamiliar code, you can never be sure what functions might be called.

>> operator overloading means that when you are reading unfamiliar code, you can never be sure what functions might be called.

This just means abstraction were abused. Which as I said, they can be. Nevertheless most popular programming languages (C++, C#, Python, Java, etc.) have overloaded operators, you can write "hello " + "world" (or equivalent) in any of these languages.

Where does the memory for the combined string come from? Even if Zig had operator overloading it's not clear how a runtime `string1 + string2` could work.
In practice what would the actual code look like (pseudo-code is fine). How would I concatenate strings at runtime using `+`, once with one allocator and then again with another allocator?
>> once with one allocator and then again with another allocator?

I would:

1. Prohibit concatenating strings with different allocators. Strings with the same allocator can be concatenated.

2. Provide StringBuilder for concatenating anything in an effective manner. It's not only about different allocators, but supporting C-style strings and reducing number of allocations.

So '+' is only for strings with same allocator, same UTF encoding, same everything. I'm really not fond of implicit type conversions.

> operator overloading means that when you are reading unfamiliar code, you can never be sure what functions might be called.

How so? It’s clear that `a + b` calls the `+` function.

It means that control flow is determined by the code you're looking at.

"if" and "switch" determine control flow, but you can see the control flow when you're reading that code.

In languages like C++ and Java, if you have a sequence like:

    foo();
    bar();
whether execution continues to bar() after foo() returns depends on the implementation of foo(), which you can't infer from the callsite. If foo() throws an exception, bar() won't execute.

In Zig, the compiler forces you to handle exceptions (errors) at the callsite, so you can see from looking at function calls at the callsite what the potential control flow looks like.

If you saw the foo(), bar() sequence in Zig, you'd know that bar() always executes after foo(). If it were possible for foo() to return an error, that code wouldn't compile unless the callsite handled the error explicitly with a try. The try tells the reader that there's a potential control flow depending on whether foo() throws an error.[0]

[0] https://ziglang.org/documentation/0.11.0/#try

In Rust, foo() might panic but this is only supposed to be used for truly unrecoverable errors. For most recoverable failure conditions you're supposed to use foo()?; which makes it clear that execution might not proceed to bar();
> In Zig, the compiler forces you to handle exceptions (errors) at the callsite, so you can see from looking at function calls at the callsite what the potential control flow looks like.

Damn, if only Java had something like that. Where you can see from function declaration whether it can throw something. Like if you could check exception. We could call it checked exceptions. And community would certainly like them.

That still doesn't show you at the call site.
Yeah but in Zig there is flexibility how you want to handle the error opposed to Java. You don't need to rewrap everything in RuntimeException and error sets allows to specify extended set of errors without much boilerplate.
I don’t think you can ever argue that a small (and only non-runtime exception, right?) signature at the top of the function makes it easy to see what lines throw.

Even if a function is small, each and every line could be the thrower and not allow the rest to execute.

Java exception specifications are annoying for two reasons. Firstly, there's no syntactic sugar to easily rewrap and propagate. Secondly, there is no way to generalize your error handling logic - i.e. for one component to say "I want to use something that implements this interface, and I don't care what errors it throws, but I do want to propagate them all". Zig doesn't have either problem.
Java has @SneakyThrows. Fight me.
Java has a whole system of annotations that seems designed to make it impossible to figure out what code will actually be run, apparently designed by someone who thought lisp macros were too explicit.
Which is a hack that amounts to throwing one's hands up and declaring, "we couldn't make it work properly".
Who couldn't make it work properly, the language designer, the stdlib writers or the Java programmers?

There is no way to make e.g. InterruptedException "work properly" as a checked exception since there is nothing intelligent one can do about it that's specific to this exception class. Rewrapping as RuntimeEx is just as much weaseling out but using more code and messing up stack trace for no good reason.

Also, thanks for answering my request for a fight. Winter is boring.

The language designers, because they wanted to have checked exceptions without the complexity of genericizing them.

Exceptions that aren't meant to be handled really shouldn't be exceptions in the first place, but that's a separate design issue.

This is why I will never stop trying to insert throws declarations into code I write. For some reason, Java developers seem to like hiding the fact their function can throw exceptions.

The syntax problem gets mentioned a lot ("we'll just end up catching and rethrowing it anyway") but at that point you can just add throws to the calling method.

Can't foo() call std.os.exit()? Then you don't know that bar() will always execute after foo() in Zig.
Yes, but I don't really think of that as hidden control flow. I know that if foo() returns, bar() will execute next, but foo() might not return (due to panic, program shutdown, or infinite loop).

From my experience, when there's surprising behavior in software, I've frequently been surprised by silent exceptions, but I can't think of any time when I was surprised by control flow because a function intentionally shut down the program.

Basically your argument is that strncat is more readable and easier to understand than std::string::operator+
I guess it's probably clearer if it's clarified as "no hidden function calls". It looks like the author wanted to generalize it because of no language level exception handling, but this could be misleading since platform level exception handling can override anything which makes it technically false.
> What article describes are abstractions. Abstractions are not bad.

Abstractions are still possible, the only difference is the syntax with which they're presented. For instance, you need an "add" function for your addition abstraction, rather than overloading the + symbol.

The only thing missing in Zig is the syntactic sugar of such abstractions; the argument being that such sugar obscures more than it illuminates.

I would rephrase it as "no hidden function calls". Hidden function calls can make it a nightmare to debug code. It happens way too often in C++ code, less often in C#. But here's a C# example to illustrate the problem: https://web.archive.org/web/20021003203744/http://www.geocit...
As I said, abstractions can be abused. OK, your terminology, hidden function calls can be abused. Does not mean not having them is good.
Having fewer footguns is a good thing. When you inherit a codebase, if it is written in a language that has fewer footguns than one that does, that can make a big difference.
By this logic assembler is the best, as the most explicit language. I believe "number of footguns" is a metric people use for C++ exclusively. C++ is a mix of a few generations of a few languages, but most importantly several cultures. C++ is not bad. Qt code which uses STL here and there and a few unwrapped C libraries is bad. In that situation you really don't know what a "+" does in a particular case.

In Python or C# situation is quite different. For example I struggle recall a single case from my experience when property was confusing or was used in a confusing manner.

defer has one important difference to try/finally - it places the cleanup code right next to initialization, which makes things easier to read overall.
Developer may place defer close enough, it does not have to be close. RAII is better by this logic, but too "hidden" by Zig standard I guess.
I don't think the point is about abstractions... it's how much you can understand about how the code executes by reading it.

Abstractions are fine, of course. In Zig, though, they should be explicit and clear. E.g., the only way to call a function is by name, using the function-calling syntax. Control flow depends only on keywords and syntax directly in front of you.

I think they do assume you known the language -- if you don't then I guess a lot might be unclear when reading code! -- but on the other hand, it's on the simpler, smaller side.

All examples are abstractions, so if point is about something else, maybe article needs different examples.
Aren't all computer languages entirely composed of abstractions, essentially?

I don't think you can infer anything from the examples being abstractions.

> Is it still so after ditching LLVM?

Zig hasn't ditched LLVM, nor does it really plan to. What's planned is for the main Zig executable to not directly depend on LLVM, that way contributors to the compiler can still develop on the code base without needing to set up and compile LLVM on their systems. Zig does plan to eventually supersede LLVM's functions with their own custom backends, but in the short term, this only means using the new backend as a debug compiler (for faster compilations, to allow for a better developer feedback loop) and it won't be until the distant future that LLVM could feasibly be dropped as a dependency entirely, if the devs ever decide to actually go through with that.

These discussions usually focus on features. How about a simple "because you tried Zig and you enjoyed writing it more than language X"?
That's not really a substantive case in favor of using the language.
The lack of operator overloading is a bummer for mathematics code, eg involving matrices, vectors, tensors, complex numbers etc.
Unless you don't need full fledged operator overloading to do math. For example there is @Vector.
I am a big rust fan, but I'll say I don't think a language should need to justify its existence and whether it's redundant or not. Zig exists because its developers and users want it to. People like coding in it, that's all the justification it needs.

I'm glad people make new programming languages without asking permission from the world whether it provides enough value to exist.

(TBC, I realize this page is more about answering questions about differences between zig and other languages, emphasizing its strengths, I just think the framing of the question is unfortunate)

That page could be titled "Advantages of Zig," and it seems to me rather normal that a developer might ask "What are the advantages of Zig/Rust/Python/D over other languages?" and each language has a cost/benefit analysis that is different than what other languages made.

I do think justification is useful though - Why should one use Rust, for instance? Well, one obvious answer is that it places a high priority on memory safety, and so, if memory safety is a critical concern, Rust should be something to look at.

I am also thinking of the xkcd comic on specifications, and how creating a new one merely confuses the already crowded space, but I'm having trouble making the corollary here.

I'm really unlikely at this point to use zig or even less likely to use rust. But as an embedded firmware guy I'm happy to see people working again to advance the state of the art of actual low level compiled languages. That stopped in the early 90's when we went off the rails with Java and C++ OOP madness. So it's nice to see it again.
This is a good way to look at it. Definitely would say with the size of the landscape makes pro/con comparisons a necessity.
I agree and as a person working full time in Rust there are a lot of things in Zig I'm jealous of. I'm now at the "somewhat disillusioned" phase of working in Rust and if I wasn't so heavily buried in Rust projects (both personally and professionally) I'd consider starting a project in Zig. Well, mostly Cargo and Crates.io are my complaints around Rust. But also the lack of progress on allocator-api, and portable SIMD.

I would, however, really miss ADTs/pattern-matching if I made the switch to Zig.

Because D's tooling and ergonomics are awful. But how are Zigs?
> D's tooling and ergonomics are awful.

Can you elaborate?

I don't really agree about the simplicity: to me, C3 feels like it's closer to C, and C is just simpler to learn.

Although among those new statically compiled languages, zig would be my favorite choice.

I just wish it was just even simpler.

> C is just simpler to learn

But there is a huge gap between ability to learn C and ability to write working software in C.

I've inherited a small (500 loc) C codebase with ldpreloadable library. Guess how many crashes I've fixed already? 7! And it's a security sensitive code supposed to run under root.

There’s some tension between the first section about what Zig guarantees and later sections about having great interoperability with C. If you have a lot of C dependencies because it’s easy, what guarantees can you have? Doesn’t the C code need a system allocator?

Maybe this could be resolved by distinguishing between writing new libraries in Zig and building applications. New libraries can be clean and reusable even if the applications are a hodgepodge of dependencies.

For libraries, reuse versus rewrite is still a question. With any new language ecosystem that provides new guarantees, there’s a drive to build a new set of libraries within the ecosystem to get those guarantees. This is an enormous effort. An end goal of making libraries more reusable, to stop rewriting them, seems in tension with the means of getting there, which is by rewriting rather than reusing libraries.

Also, if you write a library in Zig, aren’t you limiting your audience?

> Also, if you write a library in Zig, aren’t you limiting your audience?

The Zig compiler has a C backend so at worst you could just compile your library to C so that anyone with a C compiler can use it. However you can also export C-compatible functions, which is roughly analogous to having an `extern "C"` interface for your C++ library. The consumer would need a Zig compiler, of course, if they wanted to build from source.

The standard library being designed to allow freestanding binaries makes Zig a much better language for systems software such as kernels, EFI applications, etc. than Rust.

Rust's addition of operator overloading is also a perplexingly bad decision. I don't think there is a single worse design decision in C++ or Rust than allowing someone to redefine what + means on some arbitrary type.

Eh, I think operator overloads make a lot of sense in the right context. If you created a type that’s a kind of mathematical object, you want a mathematical syntax for it. However, they are horrible when abused.
> Rust's addition of operator overloading is also a perplexingly bad decision. I don't think there is a single worse design decision in C++ or Rust than allowing someone to redefine what + means on some arbitrary type.

Why is that? Won’t it be more ergonomic than needing to call, say `.add`, when you want to do something clearly similar to addition. It’s just sugar, no?

In Java, there is no operator overloading, so if you want to compare 2 strings by contents rather than their memory addresses, you have to use a `.equals` method. Comparing 2 strings by their contents is the most common use case, so it not being the default is a design flaw (similar to how you need to `break` out of a `case` in most languages).

If you can’t overload the addition operator, then why have a whole language construct that can only be used on primitive integers and floats?

Thing is, Rust has full support for hygienic macros so operator overloading could've been added as part of that. You'd just have to write, e.g. int_expr![a + b] or whatever, but that would've made the syntax fully extensible.
Macros are easy to spot, the whole point of operator overloading is that it's a trojan horse. It might do simple addition, it might do a heap allocation and talk to a printer.
When writing systems software like a kernel, clarity is far more important than ergonomics. The basic operators aren't functions, they're translated directly into opcodes based on the types of the operands. Operator overloading allows them to be either opcodes or functions, and if you think about the amount of work that goes into a function - saving registers, creating a stack frame, pushing addresses, multiple branches - versus a single opcode, as a kernel developer you really don't want this to be hidden behavior based on whether something has been overloaded or not. In the best case the compiler will optimistically inline multiple instructions, in the worse case it will call a function before you've even set up a stack.
SIMD intrinsics are also translated directly into opcodes. There's very little reason to have floating point ops represented by special syntax rather than ordinary functions like fadd() fsub() fmul() fdiv() fmuladd(), other than mere legacy. (And they might not even be simple opcodes in a soft-float implementation.) I mention floating point specifically because that's where even something basic like the order of operations can affect the outcome, so the extra precision actually matters.
What if your target cpu doesn't have, say, floating point operations, or integer division. Will Zig refuse to compile any code that uses these operations? Will it inline the emulation code? Or gasp generate a call out to a library function?
This is an extremely common issue to deal with when creating a kernel, which is, again, why levels of indirection in code and hiding context only makes life more difficult for a systems developer. The kernel may need to enable accelerators, switch between arm and thumb, enable an fpu, clear cache lines, etc. A lot of decisions will be made by the compiler, but based upon parameters passed in at build time, and a lot of it will be arch specific code interwoven in assembly. And there are tons of times when a compiler will generate things you don't want, forcing you to add pragmas and so forth.
> Why is that? Won’t it be more ergonomic than needing to call, say `.add`, when you want to do something clearly similar to addition. It’s just sugar, no?

Especially since, in a typed language, this function would be desugared at compile time and (probably) aggressively inlined, making it hardly different from the compiler builtins for adding floats and ints. Unless, of course, you're doing something like allocating to concatenate strings.

Really, though, it's more of a developer common-sense issue than a language one.

Native ints support math operators because math operators are far more readable than method calls. And if you're doing something that requires a custom numeric type, like base-10 floats or fixed-width, method calls don't get any more readable than math operators there either. A language where you can't overload numeric ops is a language with a strong disincentive against using better numerics. And this is without getting into having a single source of semantic equality, whether it be `==` or otherwise; not having that is probably the single worst design decision of Go.

This complaint is always made in the context of some pathological case where a library author tries to do clever things with operator overloading; if any such libraries exist in the first place, you can guarantee that you are not using them by simply not using any libraries with fewer than 10k downloads. The horror stories that fill HN comments pretty much never make it into real code.

See my other comment about the necessity of clarity in a systems language. Also in my opinion readable code is code that is easily understood with as little context as possible. Operator overloading is the opposite, it hides behavior based on the types of the operands. Instead of thinking, that * will turn in to an smul, we now have to check for and read the implementation of * for every type. A function makes it clear that there is a call site and the types of its operands, and the name can more clearly convey the meaning of what is to be done than *.

What you are referring to as readability is actually terseness, which I think is a lousy metric to optimize for, especially for systems software where correctness is important and people will read code a lot more than they will write it.

I get where you're coming from, but I think it doesn't have to be more complicated than: "Are the two values standard Rust number types? If yes, they do simple multiplication on an asm level. Otherwise, check the relevant Mul implementation."
I disagree, consider a generic that is constrained by std::ops::Add. If you want to write generic functions with this type, you have to contend with types that might do simple addition or do allocations with potential side effects.
As I understand it, generics are just a particular mechanism for interfacing with multiple types, and C manages without. For the same program, could a programmer ever be more unaware about which types are parameterized and which specific implementations are called in Rust than in C? I don't have much knowledge of C, admittedly, so this isn't a rhetorical question.
Why would you constrain a generic with ops::Add, if you didn't want to specifically allow for generic implementations of +? If you just want to be generic over built-in integers, it would be as easy as a "trait Integer: Add + Sub + Mul + TryFrom<i32> + ... {}" that's implemented by the standard integer types and sealed off from outside implementations.
The whole point of generics is that you don’t know and don’t care what the type is.
> I don't think there is a single worse design decision in C++ or Rust than allowing someone to redefine what + means on some arbitrary type.

This has perplexed us for D. Experience with C++'s iostream's operator overloading meant running away screaming. Another terrible thing is people would code up DSL's using operator overloading, such as a Regex language. The horror there is the source code looks like ordinary C++ arithmetic, but it is actually doing Regexes.

So, how to allow operator overloading for arithmetic, but not for other porpoises?

1. Only allow overloading of arithmetic operators (i.e. no overloading of unary *) and [ ]

2. Only allow < overloadable, instead of < <= > >=. This enforces symmetry.

3. Don't allow overloading of && || ?:

4. A strong Compile Time Function Execution feature which enables DSLs in the form of string literals

5. Develop a culture of operator overloading is for arithmetic

This has worked well.

> Another terrible thing is people would code up DSL's using operator overloading, such as a Regex language.

There's nothing wrong with this if your language has proper hygienic macros. Then you can have all of math_expr![ … ], float_expr![ … ] (dangerous! order of operations may affect results), regex_expr![ … ], or even stream_concat_expr![ … ] all using the same operators while meaning completely different things and preserving complete extensibility. They would even be composable since each macro invocation would desugar its own operators and leave those in other contained macros unaltered.

Macros form their own hell, hygienic or not. The reason is inevitably these evolve into one's personal, undocumented, quirky, unmaintainable language.
This sounds great if you’re stuck inside the C-derivative mindset block. I.e. the best you can do.
> Rust's addition of operator overloading is also a perplexingly bad decision.

You know, I don't even have a side on this never ending debate... but it's perplexing that similarly intelligent people, with similar interests and backgrounds can both make so confident blank statements such as this, one way or another! IMO that is a pretty good indication that there's no right answer, it's simply a matter of preference... and the fact that people still feel like they're right and the people who disagree with them must be making "perplexing bad decisions" is, for lack of a better word, hilarious.

> Rust's addition of operator overloading is also a perplexingly bad decision.

Yes. The poor man’s custom operators.

If there is no string concatenation operator (for run time), how do you concatenate strings at run time with Zig?

Depends on what you work on, I guess, but in my area of expertise string operations like concatenation are bread-and-butter.

Easy string (and array) concatenation is a godsend:

    string b = "betty";
    string s = "hello " ~ b;
You allocate a big enough buffer, and copy the strings in the buffer, same as in C.
You probably use fmt instead. You provide a format string, an anonymous struct (a tuple) containing the values to be formatted, and a writer or byte slice, and you get the formatted string written to the writer or the byte slice.
I use D, and there is nothing in that article that's convincing enough for me to switch

    $ time make build-game

    real    0m0.387s

    user    0m0.309s

    sys     0m0.077s

    $ time make build-sgame

    real    0m0.334s

    user    0m0.245s

    sys     0m0.057s
Complete full rebuilds of my game and game server on linux (on windows it is the double)
Only C++, D and Rust?

What about Ada, Pascal, Swift, Nim, Crystal, Carbon, Jai, etc.

only so much time in the day/familiarity?
> C++, D, and Go have throw/catch exceptions

I believe Go does not.

I stopped reading the whole article after that bit and came here to find the one comment that called it out. =)
panic/recover allow to implement something like exceptions.
You and I both know that is nothing like exceptions.
Thanks to having compilers as part of my major I know that exceptions come in many forms.
Oh right...

  Person A claims that X is true.
  Person A is an expert in the field concerning X.
  Therefore, X should be believed.
Error handling != exceptions

https://news.ycombinator.com/item?id=4159672

Oh right, the claim to authority kind of reply.

If it quacks is a duck.

It does, even if convoluted.

You can use poor man's exceptions in Go via panic/recover pattern.

Zig : because language design is better when encouraged by diversity, as so many things are.
I think the other important thing would be who is using zig in production besides Bun & TigerBeetle ?
We use zig to trade cipher tokens derivatives.