I’ve long thought that Zig was an interesting programming language, potentially more interesting than Rust in many respects given that Zig seems to be targetting a more modern C-like language replacement whereas Rust firmly looks like it is trying to take C++ out back like ol' yeller. Rust is powerful, but the language is complicated, and no I’m not talking about the borrow-checker (a completely genius idea) but the language itself is vast and complex. Try and read a moderately complex Rust crate and it can be mind boggling to work out what is going on.

On the other hand Zig, with a strong ethos guided by Andrew Kelley, has this guiding light that there should be one way to do something in the language, and that is something that I really appreciate in language design.

Last year I attempted to do Advent of Code 2020 in Zig, but the language was just a little too fresh for me to get into. The documentation was basically non-existent, and even getting the tools and working how to use them was too confusing for me. On day one I gave up and switched to Rust instead. This year though I was determined to try the whole challenge in Zig, and what a difference a year has made to the language! The community is now massive, there are GitHub templates for Advent of Code to just get you coding, and the Zig documentation is so rich and detailed that I could pick up some of the basic concepts quite quickly.

So now that I’ve completed Advent of Code 2021, I thought I’d share the good and the bad about Zig, and some summary thoughts on the language.

Note: I’m assuming a base level of understanding about what Zig is here, there are plenty of guides on the language available elsewhere!

The Good

The best thing about Zig is that the language is small. There isn’t even a foreach for like structure and Andrew has stated ‘While loops work, why add another way?' and I really appreciate this approach. It means I am not wondering about what tool to reach for when I want to do something, there is a single tool with a single use. Especially when learning a language (for myself and for anyone else that would want to pick it up) - brevity is key. I think Rust got lost in trying to nicely provide so much of what C++ badly provides that random users of the language looking at any arbitrary code written in Rust suffer for the sheer breadth of the language. Zig’s approach here meant I could read the standard library code and understand what it was doing (even with all the comptime type fun!).

Nullability is fun in Zig - the fact that optionality is built into the language with the ? prefix on types (so ?i32 is maybe-a-32-bit-integer) and that they have combined this with pointers so that you can assume that any pointer (*i32 for instance) isn’t null. This is great for the compiler, great for the optimizer, and I think also great for the user.

How things are brought in from the standard library or general foreign code is interesting:

const std = @import("std");
const print = std.debug.print;

There is a builtin compiler marco @import that does the heavy lifting of pulling in the code, and then you assign this into a const some_var variable. This is really neat because you could call that whatever you wanted (to avoid naming conflicts). Also when you want to pull in definitions from within an imported package you just use the same mechanism of assigning the package.with.a.thing.in-it into a constant variable. Most other languages have a using foo::bar::haz::baz; type mechanism for this, but having it use the same mechanism for a bunch of different things means that you don’t have to switch in your head to another tool. I hadn’t considered this language concept before using Zig, and its a very good idea!

The fact all containers take an allocator on intialization, and you can only get a heap pointer via an allocator is genius in Zig. Memory isn’t free, and allocations are not cheap, and so making getting at heap allocations harder by explicitly getting them through an allocator is a great thing.

Also the error mechanism in Zig is wonderful. Zig has this special prefix for a type (for example !u32 means ‘an error or a u32') and you can cascade errors from deep in Zig code with the try statement. So var x = try foo(); means x is equal to the result of foo() unless there was an error in the result. If there was an error, return from the function with the error now. This meant that you don’t have the messy littering of if conditionals after every function that you typically get in C, but you also don’t have the complete disaster that is exceptions in C++/C#. Rust has a similar mechanism to this, but they use the clunkier Result<T, E>. While Zig has effectively added another thing for the frontend to handle by adding in a ! prefix on the types, the language is certainly nicer for it.

The Bad

There are a collection of things in Zig that I didn’t like. All languages have things that any random subset of users won’t like, so I am not saying Zig should change any of these or anything like that.

Initializing arrays is weird in Zig. Lets say you want to have a 0 initialized array, you declare it like [_]u8{0} ** 4 which means I want an array, of type u8, that is initialized to 0 and is 4 elements long. You get used to the syntax, but it’s not intuitive.

For loops are a bit strange too - you write for (items) |item| {}, which means you specify the container before the per-element variable. Mentally I think of for as for something in many_things {} and so in Zig I constantly had to write it wrong and then rewrite. Also you use the | character in Zig quite a lot, and while this may just be a problem with Apple UK keyboards, actually getting to the | character on my laptop was uncomfortable. When doing C/C++ or Rust, you use the | character much less and so the pain of writing the character was something I never noticed before. Jonathan Blow has gone on the record to say that with his language, Jai, he spent a lot of time working out how easy it would be to type common things, such that the more common an operation in the language, the easier it would be to type. That seems to be missing here (well at least for Apple UK keyboard layouts, I’d need to write Zig extensively on another layout to know whether this was a universal thing!).

Switch statements where you want to have multiple arguments resolve to the same code I wrote as a | b, whereas in Zig it is a, b. Nothing major with this, but I constantly tripped up on this.

Zig test was a bit clunky - you have to specify the file you want to test. So to test src/foo.zig, you’d do zig test src/foo.zig. I wanted something more like Rust’s cargo test that’d find all tests and run them. Maybe Zig does have this but I just didn’t find it?

And how you declare functions is a little strange. Like a function in a struct would be:

const foo = struct {
  pub fn bar() {}
};

Everything in Zig is const x = blah;, so why are functions not const bar = function() {};?

The Ugly

Zig is still a little raw in a few areas. Some compile errors are less than useful. For instance if you forgot to put !T on a return type, but were using try in the body of the function, the compiler error was very confusing. This is only really an issue for new Zig users (like I was when I first hit this), because you quickly learn that when the compiler spits out something less than useful and you are using try, check the return type first. Occassionally Zig would spit out 100’s of lines of notes after an error, giving me flashbacks to the C++ template mess errors you’d get.

The builtin compiler macros (that start with @) are a bit confusing. Some of them have a leading uppercase, others a lowercase, and I never did work out any pattern to them. Is it @as or @As? I still couldn’t tell you without looking at the manual.

The type system in Zig is loose in some ways and tight in others. If Zig can detect the type of the right hand side of a variable declaration, you don’t need an explicit type. But if you had something like var x = 0; you have to specify a type. It’d be nice for users (but obviously harder for the compiler team!) if the compiler would be able to deduce these types too.

But the worst bit about Zig at present is the standard library documentation is broken and non-existent. This is probably the one reason I wouldn’t recommend Zig more generally at present, because I resorted to looking at the source files of the standard library on GitHub to work out what I could do with what provided stuff in the standard library. I know there is a plan that with the new compiler frontend (written in Zig!) to fix this, so its just a time problem.

Conclusion

Overall my gut feeling is that Zig is about ready for developing with for people like myself (coders that don’t mind a bit of pain to a lot of benefit), but it is not quite ready for more general usage. Fixing the standard library documentation would be my biggest priority if I worked on Zig, because I think that is the only thing holding back general usage of the toolchain.

One nugget of knowledge I’ve worked out though - Zig is not a replacement for C. It is another replacement for C++. comptime which while amazingly powerful, already has echoes of the hard to reason about C++ template code or Rust generic mess, and there are still quite a few bits of syntatic sugar hiding the real cost of certain operations (like the try error handling, there is implicit branches everywhere when you use that).

This isn’t to say Zig is any lesser by being a much better C++ replacement rather than a C replacement in my estimation, infact I’d argue that aslong as Zig doesn’t fall into Rust’s trap of constantly adding yet more ways to do the same damn thing and making the language that little bit harder for new people to onboard with, then Zig once it hits a stable language around 1.0 will be my recommended tool going forward.

I really enjoyed doing Advent of Code in Zig, and I think I’ll be writing more software in Zig going forward. I’d highly recommend you check out the language and the community around the language are a great group of people that have been super helpful with my dumb onboarding questions.