flooey.org

Zigging through Advent of Code

Jan 6, 2024

Advent of Code is the highlight of December for me and, as I do every year, I completed it in a language I had never used before. This year’s selection was Zig, which describes itself as “a general-purpose programming language and toolchain for maintaining robust, optimal, and reusable software”. (Previous languages I’ve used include Crystal and Common Lisp.)

Fundamentally, Zig is intended as an upgrade to C. It has a lot of features of more modern languages, like optional types, an explicit error channel, defer, a richer type system, less undefined behavior, and so forth, but maintains C’s memory management model, pointer types, structs as the fundamental record type, and a focus on simplicity.

I’ll go in depth in a bit, but the short version of my experience is that I like a lot of what the Zig designers have done, but the language is greatly hobbled by C’s memory management model, which is cumbersome to use in practice, despite Zig’s improvements.

Let’s dig in.

Types

To start on a high note, Zig’s type system is what you want from a low-level language. It starts with a few simple constructs (structs, enums, and unions), and then expands it a little bit with optional types and error unions. There are no complex types, no inheritance, no intersection types. It has something that looks like method calls, but they’re syntactic sugar for calling a regular function, there’s no dynamic dispatch.

I found this really pleasant to work with. For the domains that Zig is targeting, you don’t need inheritance or interfaces or anything fancy, you mostly need records, and the records work well. Unions are honestly even a little more than is necessary (I never used them), and you can implement unions yourself with structs at the cost of a bit of memory, but they’re useful things to have around and some domains really need them.

My one gripe with the type system is that generics aren’t really built into the language. You can implement generic types by having a function that returns a specialized type at compile type (which is a cool feature), and that works fine, though like C++ you end up with one copy in your binary per specialization rather than a single generic implementation. But generic functions have to take a type as an argument, and because of that, you lose the ability to do generic type inference, which is annoying in practice. As a simple example, to compare two strings, you have to call std.mem.eql(u8, string1, string2). The u8 there is telling the eql function that the string1 and string2 arguments should be type []u8. In almost any other language, you could infer that from the fact that string1 and string2 are indeed []u8s, but Zig requires you to repeat it. It’s not a huge deal, but it adds annoying friction to using generic functions.

Simplicity

Zig is a small, simple language. It has very few constructs you have to learn, and most of those constructs are straightforward. I’ve written a little under 5k lines of code for Advent of Code and I feel like I pretty much know the whole language.

This has some downsides (like generics not having their own syntax, as mentioned above), but mostly it makes everything incredibly straightforward. If you know how to call functions, assign variables, and use structs, you’re like 90% of the way through learning everything in the language.

Along the same lines, Zig has a single standard library module. It’s called std. It has some organization (like std.math and std.mem), but those are all resolved from a single import statement. Also, importing a module produces a struct with all its contents, so you do const std = @import("std"). Again, if you understand assignment, you understand imports.

None of this is revolutionary, but you can tell the designers have put a lot of effort into making everything as small as possible, and it is really helpful. I looked through code in the standard library on a regular basis (see below on documentation), and there was never a construct that I didn’t understand, which is empowering.

Memory Management

Zig’s memory management is taken almost directly from C, and it’s by far the biggest downside to using the language.

This isn’t specifically about manual memory management, though they’re related, but you can make manual memory management work fine. The problem is that Zig leaves managing memory ownership and object lifetimes to the programmer and gives you almost no tools to help. Because of this, there is a lot of valid Zig code that is in fact unsafe and will execute incorrectly, hopefully just crashing due to memory corruption, but possibly doing something worse. For example, these are all things I did during Advent of Code (where my longest program was ~250 lines):

And value semantics make this worse, since it means every time you move something into or out of a container, you have to worry about aliasing. Realistically, I think it’s effectively impossible for humans to properly track memory ownership for anything but the most trivial codebases.

That’s not to say Zig is doing nothing here. There are a number of improvements, but they’re all marginal compared to the pain of tracking ownership and lifetimes. For example, instead of null pointers, Zig has optional types. You also don’t allocate memory via a global malloc function, you call functions on allocators that you take as an argument, which means library functions that don’t take an allocator shouldn’t be allocating heap memory. Slices are much easier to use correctly than C arrays. The richer type system makes it a lot harder to misinterpret memory. defer makes it easier to deallocate memory correctly. All nice.

(Though while I’m here, Zig has moved almost everything to postfix operators — ptr.* dereferences a pointer, opt.? unwraps an optional, etc. — but the address-of operator is still written &obj. Why is it not obj.&? I don’t get it.)

I think Rust has fundamentally gone in a better direction on this. If you want to eliminate most memory safety bugs, you need to track ownership and object lifetimes properly, and Rust has a story for that where Zig simply doesn’t.

Integers

Oh, how I hate Zig’s integer handling. This is a place where I think Zig has landed in entirely the wrong place with in its safety/convenience tradeoff, and it makes doing all sorts of normal things incredibly annoying.

Here’s a motivating example: How would you write a loop that iterates backwards over a slice? Here’s how I would expect to do it.

var i = s.len - 1;
while (i >= 0) : (i -= 1) {
    process(s[i]);
}

Except in Zig, that loop is broken, because s.len is an unsigned integer, so instead of finishing the loop, the final decrement will trigger integer underflow and your program will crash. (BTW, this is something I think Zig gets right: if you don’t mark an operation as desiring over/underflow, it triggers an error.)

So how do we write it in Zig? Well, you might try this:

// Explicitly type i as a signed integer
var i: i32 = s.len - 1;
while (i >= 0) : (i -= 1) {
    process(s[i]);
}

Does that work? Nope. In general, this doesn’t compile, because usize can’t be implicitly promoted to i32. (In some circumstances — when s is a compile-time value — it actually will allow the initialization of i, since it knows that s.len is in fact a constant that fits into an i32.) Okay, fine, let’s be explicit.

var i: i32 = @intCast(s.len - 1);
while (i >= 0) : (i -= 1) {
    process(s[i]);
}

Still doesn’t work! You see, slices can’t be indexed by signed integers, because they might be negative. As far as I can tell, you have these options:

// Option 1: Cast everywhere
var i: i32 = @intCast(s.len - 1);
while (i >= 0) : (i -= 1) {
    process(s[@intCast(i)]);
}

// Option 2: Be off by one
var i = s.len;
while (i > 0) : (i -= 1) {
    process(s[i - 1]);
}

// Option 3: Loop forward and subtract
var i: usize = 0;
while (i < s.len) : (i += 1) {
    process(s[s.len - i]);
}

// Option 4: Break explicitly
var i = s.len - 1;
while (i >= 0) : (i -= 1) {
    process(s[i]);
    if (i == 0) {
        break;
    }
}

All of these are worse than the original. And more error prone, too! (Did you notice that there’s a bug in option 3?) One puzzle required treating a map segment as an infinitely repeating map segment, which led to this terrible function:

fn mod(n: i64, d: usize) usize {
    return @intCast(@mod(n, @as(i64, @intCast(d))));
}

In any other language, this could have been n % d (ignoring modulo returning negative numbers in some languages), but no, you can’t do that in Zig.

I honestly think that unsigned integers are overrated and it’s fine to only provide signed ones. But even if you’ve made the decision to have unsigned integers, the real problem here is that Zig won’t let you index a slice by a signed integer, and I think that’s wrong. It’s something that comes up too often, and having to work around the restriction isn’t worth the cost. After all, there’s nothing that prevents you from indexing a slice with too high a value (except bounds checking, which would catch the negative case as well), but unless you’re going to have extremely rich types that can express that a variable holds a specific range of integer, you can’t embed that into the type system, so why try to do it for the lower bound?

Documentation

Zig’s documentation is very immature. For a lot of simple stuff, it mostly works, but it frequently leaves out important details that matter once you actually try to use something for real work.

For example, sometimes variables are implicitly const. There’s nothing in the documentation that clearly states that this is part of the type system. Even now I’m unclear on when it happens, other than that function parameters are implicitly const. But sometimes you go to mutate a variable and it generates an error (always with the cryptic message note: cast discards const qualifier as a postscript). At that point, I was mostly reduced to turning things into pointers until it went away, since I knew there was no way I could know what was causing it.

This kind of thing is all over the place. The language has both the modulus operator % and the builtin function @mod, with no explanation of why. AutoHashMap has no top-level documentation at all, for instance to tell you how it hashes keys or whether there’s a restriction on the key type. A bunch of AutoHashMap functions take a ctx parameter, but don’t describe what it does. The related AutoArrayHashMap type helpfully says “See AutoContext for a description of the hash and equal implementations”, where AutoContext appears to not be an exported symbol (I certainly can’t find it in the docs).

None of this is a deal-breaker, especially since the documentation helpfully links to the source code and, as mentioned above, the simplicity makes the language very readable. But it makes the whole endeavor seem like its authors don’t actually take it seriously. And if they authors don’t take it seriously, I’m not sure why I should.

Conclusion

Despite all the complaining, I actually rather liked Zig by the end. But I also wouldn’t recommend anyone use it for anything more than toy code unless they were already planning on using C, in which case Zig does seem like a significant upgrade. The cost of dealing with memory issues in application codebases is too high to recommend it. But I would love to see a small, simple language like Zig that handled memory in a better way.