DEV Community


Posted on • Updated on

Zig wrinkles

I'm learning Zig, and it's looking great so far. There are some wrinkles I'd like to see fixed though, even if they're quite minor in the grand schemes of things. That said, I've only included items that at least some others have agreed is something of an issue.

Allocator copies (footgun)

Zig allocators follow a clever interface idiom based on @fieldParentPtr. While flexible, it can lead to subtle bugs:

pub fn init(allocator: *std.mem.Allocator) @This() {
    var instance = MyThing(T){
        .arena = std.heap.ArenaAllocator.init(allocator),
    instance.items = std.ArrayList(Item(T)).
    return instance;
Enter fullscreen mode Exit fullscreen mode

When init returns, a copy is made. Zig currently (0.7.x) guarantee copy elision only when struct literals are returned directly (thanks to ifreund for pointing this out). So essentially &instance.arena.allocator now points to a temporary, leading to memory corruption.

One solution is to make an instance method (passing self), but that makes the call site ugly. In my particular footgun case, it was solved by moving to ArrayListUnmanaged, where the allocator is passed in to every list operation. That makes it possible to remove the problematic init(&instance.arena.allocator)

Long term solutions are discussed in and

Scoping in loops (footgun)

Looping in Zig sometimes lead to unnecessarily polluting the parent scope. This has been a source of bugs in other languages. For instance, C99 fixed this partially by allowing the init-clause in for loops to be a declaration.

The currently suggested fix is to manually scope with {}:

{var i=0;
while (i < 10) : (i += 1) {
Enter fullscreen mode Exit fullscreen mode

...but this gets ugly in a hurry, especially with nesting. I doubt people will use this idiom much.

And the thing is, functions evolve. Maybe it's not necessary to scope i initially, but then you add another loop down the line, and reuse the variable by mistake. This can lead to extremely hard-to-find bugs, especially if it happens in a rarely executed path.

There are examples in the std library where the author has carefully reinitialized loop variables because there are multiple while loops at the same level using the same variable. This instead of manually scoping. This is an example where things can go wrong during future changes/refactoring.


  • An initializer clause, maybe one of these (though the first one apparently can't work for... reasons):
    • while (var x = 1; i < 10)
    • while ({var x = 1;} i < 10)
  • Someone mentioned a with-clause, like with (...declarations...) while ...

I personally prefer some sort of initializer clause. C++17 even has that for if, if (init; expr)

Namespace variables look like instance variables

This is something that only beginners are likely to run into. I did, and I discussed it on with marler8997 who was kind enough to write up an issue:

Basically the problem is that "static fields" look like instance variables, var x = something;, while they're in fact shared between all instances.

As marler8997 pointed out, you need to make two mistakes to run into this, but this is actually quite doable as a beginner. First you have make a namespace declaration (unclear to me what the technical term is) instead of an instance field. Second, you have to forget to put "self." in front of the variable name. However, if you're in the mindset that you've been making an instance variable (that's what it looks like) and have enough muscle memory to omit self when referencing "var declarations", get ready for a debugging session.

No error context (nice, but also annoying)

In layered applications, there is usually an obvious way to communicate errors originating from lower layers, such as writing to log files or displaying UI elements. Great error messages means having as much context as possible. Which file lacked permissions? What message did the mail gateway fail with? Many languages have a way to emit errors with context, usually a formatted string. Java has exceptions, Rust has with_context, etc.

Zig doesn't provide a solution here, it's just bare error unions. This is efficient, and sufficient in most cases, but leaves it for you to device a solution for propagating custom error messages when it's needed. Sometimes this is easy, sometimes it's not.

Reference capture syntax (confusing)

while (items) |*item|
Enter fullscreen mode Exit fullscreen mode

This gives you a pointer to item, but * makes it look like items is a sequence of pointers that's being dereferenced (especially to C programmers)

It can be argued that it mirrors a pointer declaration, but item is not a type! I thus think |&item| makes more sense (there may be parsing/semantic issues I don't know about)

Also, why is it called "by reference"? References don't seem to be a language construct. It's just a pointer, right?

Verbose casting of comptime_int/comptime_float literals (annoying)

As an example, in expectEqual, the expected result is the first argument, but it's anytype. This leads a large number of annoying casts of literals:

expectEqual(@as(f64,150), accumulate(@as(f64,1), list, mul));
Enter fullscreen mode Exit fullscreen mode

Changing the argument order, and the cast is no longer required (because of where anytype is placed in the signature of expectEqual)

expectEqual(accumulate(@as(f64,1), list, mul), 150);
Enter fullscreen mode Exit fullscreen mode

I think primitive suffixes, like 150f64, could be a pragmatic middle ground. Still a cast, but a readable one:

expectEqual(150f64, accumulate(1f64, list, mul));
Enter fullscreen mode Exit fullscreen mode

Things I miss

  • Lambdas, so I can do accumulate(1, list, (a,b) => a*b), though fengb points out this issue, where closure captures is out of scope.
  • Some sort of vtbl sugar (interface, protocol, trait). Useful for widget frameworks, virtual file system interfaces, and other places where type substitution make sense. Rolling this yourself is possible, but also verbose and error prone. Worst-case is if everyone rolls their own, and they don't compose or interoperate.
  • Multiple dispatch, to help solve the expression problem frequently encountered in parsers and such. The Julia language has shown how powerful and composable this construct is.
  • Select and cancelation of async operations. These are well-known deficiencies that will hopefully be fixed before 1.0. I'd like to be able to await one-of or all-of the results, just like all and any in Javascript promises.

Discussion (0)