strongly-typed-thoughts.net


Ah, Zig. I have a love-hate relationship with this one. A “new” (reading: appeared a couple years ago,
already — yes, already), language with high ambitions. Zig was made to run at low-level, with a simple design
to solve many problems C has (macros, allocators, error handling, more powerful types like baked-in tagged
unions and bitsets, a better build system, no hidden control flow, etc.). The language claims to be the C
successor, and today, many people claim that Zig is simpler and even safer than most languages out there —
even Rust! — allowing to focus more on the technical challenges around your problem space rather
than — quoting from the Zig mantra — your language knowledge. I think I need to put the full mantra
because I will reuse it through this article:

Focus on debugging your application rather than debugging your programming language knowledge.

We will come back to that.

I had already written about Zig a while ago when I
initially approached it. I thought the language was really interesting and I needed to dig deeper. That blog
article was made in July, 2024. I’m writing these lines in February, 2025. Time has passed, and yet I have been
busy rewriting some Rust code of mine in Zig, and trying out new stuff not really easy or doable in Rust, in
Zig, just to see the kind of power I have.

Today, I want to provide a more matured opinion of Zig. I need to make the obvious disclaimer that because I
mainly work in Rust — both spare-time and work — I have a bias here (and I have a long past of Haskell projects
too). Also, take notice that Zig is still in its pre-1.0 era (but heck, people still mention that Bun,
Tigerbeetle, Ghostty are all written in Zig, even though it hasn’t reached 1.0).

I split this article in two simple sections:

  • What I like about Zig.
  • What I dislike about Zig.

What I like

Arbitrary sized-integers and packed structs

Zig has many interesting properties. The first one that comes to mind is its arbitrary-sized integers. That
sounds weird at first, but yes, you can have the regular u8, u16, u32 etc., but also u3. At first it
might sound like dark magic, but it makes sense with a good example that is actually a defect in Rust to me.

Consider the following code:

struct Flags 
  bool clear_screen;
  bool reset_input;
  bool exit;
;

// …
if (flags.clear_screen || flags.reset_input) 
  // …

That is some very typical need: you want a set of flags (booleans) and depending on their state, you want to
perform some actions. Usually — at least in C, but really everyone should do it this way — we don’t represent
such flags as structs of booleans, because booleans are — most of the time — 8-bit integers. What it means is
that sizeof(Flags) here is 3 bytes (24 bits, 8 * 3). For 3 bits of information. So what we do instead is
to use a single byte and perform some bitwise operations to extract the bits:

#define FLAGS_CLEAR_SCREEN 0b001
#define FLAGS_RESET_INPUT  0b010
#define FLAGS_EXIT         0b100

struct Flags 
  uint8_t bits;
;

bool Flags_contains(Flags const* flags, uint8_t bit) 
  return flags.bits & bit != 0;


Flags Flags_set(Flags flags, uint8_t bit) 
  flags.bits 

Flags Flags_unset(Flags flags, uint8_t bit) 
  flags.bits &= ~bit;
  return flags;

That is obviously very error-prone: we use CPP macros (yikes), bits are not properly typed, etc. Zig can
use its arbitrary-sized integer types and packed structs to automatically implement similar code:

const Flags = packed struct 
  clear_screen: bool,
  reset_input: bool,
  exit: bool,
;

This structure has two sizes: its bit-size, and its byte-size. The bit-size represents the minimum
number of bits it uses (3), and the byte-size represents the number of bytes required to hold the type
(1). We can then use it like so:

if (flags.clear_screen or flags.reset_input) 
  // …

This is an awesome feature, especially because lots of C libraries expect such bitfields, for instance
in the form of a u32. You can easily and naturally convert the Flags type to a u32 with
@bitCast(flags) — you need to ensure the booleans are in the right order (big endianness here in Zig
if I recall correctly).

Note: in Rust, we don’t really have a nice way to do this without requiring a dependency on
bitflags, which still requires you to provide the binary value of each logical boolean in binary,
usually done with const expressions using 1 << n..

Generic types are just functions at the type level

As a Haskeller, this is also something that makes a lot of sense to me. A typical struct Vec<i32> in
most languages is actually a function taking a type and returning a type in Zig;
fn Vec(comptime T: type) type.

Although more verbose, it allows a lot of flexbility, without introducing a new layer specific to the type
system. For instance, specialization can be written in the most natural way:

fn Vec(comptime T: type) type                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ constructing invalid value: encountered an unaligned reference (required 4 byte alignment but found 1)
  
   | ^
   = note: BACKTRACE (of the first span):
   = note: inside `main` at src/main.rs:10:31: 10:35
   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

The message has some weird mentions in (alloc565), but the actual useful information is there: a pointer is
dangling.

So no, I strongly disagree that Zig is safer than — even — unsafe Rust. Anyone telling you otherwise is
either purposefully lying, or ignorant. And I think I need to mention the misconception that you need to
drop to unsafe often in Rust. This is simply not true. Some libraries — especially interfacing with C
libraries — do use unsafe to make FFI calls (usually, they just do that). unsafe might be required for
some very specific tricks required to implement safer abstractions, but you are not supposed to write a full
library or application in unsafe.

Just test your software correctly!

Again that argument… UB cannot be tested and requires statical analysis — or some kind of runtime protections
that is currently not implemented in Zig — and coverage in a langage that is built off lazy compilation everywhere
is probably not something I will discuss here…

Lazy compilation and compilation errors instead of warnings

Heck, I thought I would dodge this one. So… yeah… this is a bit embarrassing, but Zig implements lazy compilation.
The idea is that you write code, and it doesn’t compile it. That sounds so exotic that the standard library
has a helper function to mitigate that weird design decision (std.testing.refAllDecls).
You must use it in a very natural way:

test 
  std.testing.refAllDecls(some_module_of_yours);

It’s a common idiom I have seen in many places (Ghostty 1
2 3
4; TigerBeetle
1 2;
vulkan-zig hmm).

So… if everyone really mitigates this “feature”… was it really worth it? In the end, it makes sense not to
include code that is not participating in the final binary, but… have you thought about refactoring? Have you
thought about systems that add code that will be used later? That happens a lot and I was bitten so many
times while writing some disjoint sections of code and having to spend much more time later when gluing
everything together — because the code was actually never checked!

And there is the opposite problem. Zig makes some assumptions on what is important, so obviously, a parameter of
a function that is not used should be a hard error. It lazy-checks functions you wrote and ignores them if you
don’t use them right away, but refuses to compile arguments you ignore?! I mean, I get why (not using an
argument could hide a bug), but a warning would suffice.

I haven’t found a way to disable that linter and make it a warning, and I think it’s unlikely to ever happen.

No destructors

This one I could get, but not implemented the way it is. Zig doesn’t have any sound way to ensure proper
resource management — just like C. See the previous section about communicating intent properly. Zig requires
the call site to deallocate properly, and you have to mention the deinit logic in your documentation.

defer – and errdefer, which is for a different usecase, but it’s not really important here — is a tool you
can use at call site to implement resource cleanup, whether it’s memory deallocation or file descriptors close.
The concept has been around for a long time, and as mentioned in the previous sentence, it’s not automatic. The
caller must know that they have to call defer. The documentation might forget to mention it and the user
might forget to call it. On memory cleanup, if you are lucky and your tests run that code, you will get a traced
leak. For more complex resources such as GPU queues, database handles, etc., well, it’s probably a leaked file
descriptor?

I’m not entirely sure whether destructors are the best solution to this problem, but they allow to ensure that
the code calls the cleanup routine. There are alternatives — explored in ATS, probably too complex
for now, requiring linear types and/or proofs to force the caller to get rid of the resource — Rust could have
something along those lines, since it has move semantics and an affine type system; I don’t think people will
trade Drop for linear types though.

It’s a bit a pity, to be honest, to see such a design in Zig, because it does have the infrastructure to do
better in my humble opinion. For instance, this won’t compile:

fn foo() i32  return 123; 

// …
pub fn main() !void 
  foo();

Because the return value of foo is ignored. To make that compile, you need to explicitly ignore the
returned value:

pub fn main() !void 
  _ = foo();

So it’s a bit weird to see that Zig can prevent compilation if you do not use an argument or the returned
value of a function, but doesn’t provide the proper tooling to force the user to correctly deinit
resources. If you pull the problem a bit more, it shows that the design of Zig — in its current state —
doesn’t permit that, since you would need linear types/values (i.e. a value must be used once and only
once). I would have loved something like a # linear marker that would cause a linear resource to be dangling
if it’s not eventually consumed by a function taking it with the marker as argument:

const Resource = struct 
  …

  pub fn init() #Resource 
    …
  

  pub fn deinit(r: #Resource) 
  
;

Obviously, as soon as you see that, it causes issues with the Zig type system, because you can still bit-copy
values, so you duplicate a resource and might cause double-frees — but copying should be disallowed in a linear
type system. So Zig cannot have linear types/values without changing all the properties of such types, and
purely linear values are often not enough; we eventually want to weaken the restriction (relevant type system)
to be able to use the resource value in a controlled way, via, for instance, borrowing. Which leads to more
complexity in the language, so clearly not something we will ever see in Zig.

No (unicode) strings

The standard library doesn’t have a good support for unicode strings. There is a discussion opened by
Drew DeVault about improving strings and unicode handling.
I share the link so that you make your own opinion about what to think of all that, but not having a
string type and recommending users to “iterate on bytes” is a big no to me. It’s the open door to a wide variety
of issues / corrupted data. People in the thread even recommend using .len on []u8 to get length of strings
(never do that, unless you are doing Advent of Code).

It will never happen, whatever your arguments.
You are left with user-land support, such as zg.

Conclusion: simplicity rhymes with unrestricted power, which rhymes with…

I get it. Zig has the ambition to replace C, and as such, doesn’t want to have to deal with complex abstractions.
It trades memory safety and soundness for simplicity. If I’ve been around Zig and actually writing Zig code that
much, it’s because I wanted to check whether
memory safety is a red herring myself. In the
end, maybe Zig is right. Maybe we can achieve enough with a simple language, and deal with the remaining issues
with other approaches. There is nothing wrong with that.

However, I think it’s too soon to make any useful conclusion. I really want to have a look at reports about
projects that are entirely written in Zig (Ghostty, TigerBeetle, etc.) after they have matured enough. It’s great
that those projects are successful with Zig; I’m honestly happy for them. But a robust and scientific approach
requires us to go further than just assumptions and feelings. I do think we have data about CVE issues (we
all know the 70% number, right?), and it took time and lots of software history to have enough hindsight. I do
think that hindsight is required on Zig to know whether it’s actually contributing to more reliable software. Very
personal opinion: given all the points I mentioned, I really doubt it.

I don’t think that simplicity is a good vector of reliable software. At most, it’s a happy side-effect. It’s
not a requirement, and should remain a secondary mission. What the industry needs is to identify problems (we have)
and designs solutions that solve those problems. Anyone has the right to select a subset of those problems (even Rust
can’t solve everything) and solve those specifically, ignoring the others or pushing their resolution to
user-land / process etc. But here, I think there is a big misconception.

Zig does enhance on C, there is no doubt. I would rather write Zig than C. The design is better, more modern, and
the language is safer. But why stop half way? Why fix some problems and ignore the most damaging ones?

Remember the introduction:

Focus on debugging your application rather than debugging your programming language knowledge.

Do you see why this is such a poignant take? Is it really better to spend time in gdb / whatever debugger
you are using, or having your users opening bug issues than having to spend a bit more time reading compiler
error messages? That mantra seems to make it like it’s a bad thing to have a compiler yell at you because you
are very likely doing something you don’t really intend to. Why? It’s a well known fact that bugs don’t
write themselves. At some point, a developer wrote some code expressing a solution that was, in fact,
actually written a different way, in dissonance with the initial intent. This is part of being a human. So
why would you complain that a compiler tells you that what you are doing is very likely wrong? Because in a
few instances, it was unable to see that what you were doing was, actually, fine? You base a full language
experience based solely on some exceptional occurrences / issues? And trust me, I also do have those moments
with Rust from time to time (especially with the lack of view types and partial mutable borrows).

Seeking simple is just not the right move to me. To me, a more robust approach is ensuring people can’t shoot
themselves, while seeking simpler. Simpler doesn’t mean simple; it means that you design a solution,
whatever the complexity, and tries to make it as simple as possible. Rust is honestly not that hard, but it is
definitely not a simple language. However, for all the problems it solves at compile-time, it’s definitely simpler
than all other approaches (e.g. ATS). And it’s not unlikely that it will get simpler and simpler as we discover
new ways of expressing language constructs.

I think my adventure with Zig stops here. I have had too frustration regarding correctness and reliability
concerns. Where people see simplicity as a building block for a more approachable and safe-enough language, I see
simplicity as an excuse not to tackle hard and damaging problems and causing unacceptable tradeoffs. I’m also
fed up of the skill issue culture. If Zig requires programmers to be flawless, well, I’m probably not a good
fit for the role.



↑ My second opinion on Zig

zig

Wed Feb 5 00:25:00 2025 UTC



Source link