DEV Community

Cover image for Zig First Impressions
Nathaniel W.
Nathaniel W.

Posted on

Zig First Impressions

Table of Contents

Why Zig?

I've had my eye on Zig for a while. It first came onto my radar after throwing my own hat into the ring of language design, when I started looking into tons of languages from the most mainstream to the most obscure for inspiration. Until recently, I had never actually used it. So, after taking a break from my own language project, I became curious about what it was like to actually program in Zig. Here are my initial thoughts after my first small project.

What is Zig?

Zig is a statically typed, compiled language intended to compete with C. It gives you a lot of control over memory allocation, with the option to choose between memory allocators and standard library APIs that require you to provide one. It has C-like syntax and also clearly values the idea that "explicit is better than implicit" in both syntactic choices and conventions.

The Good

Let's start with what I like about Zig. Zig was clearly built with the idea of compile-time function execution being a first-class citizen in mind. Slapping comptime before a parameter forces arguments passed to it to be available at compile time, and the ability to pass around types as arguments and return them from functions allows building generic functions and types without the typical <T> syntax found in many other languages.

Furthermore, inline for/while allows you to unroll loops at compile time, if statements with comptime known arguments are evaluated at compile time, and comptime blocks can ensure other statements happen at compile time. It's very powerful. For example, Zig's string formatting isn't built into the compiler: its source code is part of the standard library!

Zig also has really great integration with C code. You can simply @cImport C header files, no need to manually create bindings or generate them with a tool.

const stdio = @cImport({
    @cInclude("stdio.h");
});

pub fn main() !void {
    _ = stdio.puts("Hello world!"); // use puts directly
}
Enter fullscreen mode Exit fullscreen mode

You can also painlessly cross-compile for other operating systems or architectures without downloading anything extra!

# Build an ARM binary from x86 with one command!
$ zig build-exe main.zig -target aarch64-linux -lc
Enter fullscreen mode Exit fullscreen mode

Zig supports optional, result, and slice types as a baked-in part of the language, and disallows null pointers.

fn foo() void {            
    var x: i32 = 10;
    var y: *i32 = &x;
    y = null;               // error: expected type '*i32', found '@TypeOf(null)'

    var z: ?*i32 = &x;
    z = null;               // OK: z is optional and may be null
}

//            v-- slice type (pointer + length combo)
fn half(data: []const u8) ![]const u8 { // ! type means this may return an error
    if (data.len < 2) {
        return error.TooSmall;
    }
    return data[0..data.len / 2];
}
Enter fullscreen mode Exit fullscreen mode

Zig also supports algebraic data types (aka sum types or tagged unions) through the union(enum) construct.

const Foo = union(enum) {
    x: i32,
    y: []const u8,
    z,
};

pub fn main() !void {
    const foo = Foo { .y = "hello world" };
    switch (foo) {
        .x => |int| {
            _ = int; // do something with the int
        },
        .y => |str| {
            _ = str; // do something with the string
        },
        .z => {}
    }
}
Enter fullscreen mode Exit fullscreen mode

The Bad

Earlier, I mentioned that Zig doesn't have generics, or at least, doesn't have special syntax for them. Instead, you can take a parameter with the type... type.

pub fn Vec2(comptime T: type) type {
    return struct {
        x: T,
        y: T,
    };
}

const v: Vec2(f32) = .{ .x = 0.1, .y = 2.5 };
Enter fullscreen mode Exit fullscreen mode

There might also be times when the specific type of the input doesn't matter. In that case, you can use the anytype type.

fn foo(bar: anytype) void {
    bar.frobnicate();
}

// this is basically short for
// fn foo(comptime T: type, bar: T)
Enter fullscreen mode Exit fullscreen mode

As the name suggests, bar will accept an argument of any type, not just ones that support a frobnicate method. If you pass something that doesn't, you'll get an error at compile time.

fn foo(bar: anytype) void {
    bar.frobnicate();
}

const Foo = struct {
    fn frobnicate(_: @This()) void {}
};

fn hello() void {
    foo(Foo{}); // OK
    foo(10);    // no field or member function named 'frobnicate' in 'comptime_int'
}
Enter fullscreen mode Exit fullscreen mode

This outputs an error at the point of use of the unsupported function or operation (NOT where I put the comment). Unfortunately, this means that in large functions, the interface required by an anytype parameter can get completely buried in the function's implementation. More unfortunately, Zig doesn't have any support for interfaces either, so there's no simple way to constrain one of these parameters like you can in most languages with trait/interface bounds, and even in modern C++ with concepts.

Instead, you have three options to do this "right". You can either

  • Do the compiler's job for it using the trait module and manually check for the presence of function names, and output compiler errors
  • If you know the types ahead of time, eat a runtime cost with enum dispatch
  • Eat a different runtime cost with manual vtable construction (!!!)

The first two approaches are shown in detail in this article, if you're curious. You can see an example of the third in Zig's own standard library with the Allocator type. Frankly, all three of these options suck for this simple use-case and require much more boilerplate than should be necessary. Manual vtables are definitely the worst, requiring a ton of boilerplate and being the worst for performance.

So, what do people do when you make it difficult to do the right thing? They take shortcuts, and that's why a lot of the code I've seen so far just uses raw anytype and forces users to deal with it. At least the errors are better than in C++...

The Ugly

Lastly, I want to cover the things about Zig that aren't foundational issues but still bother me nonetheless — a bunch of nitpicks, basically.

Modern programming wisdom has shifted to a "const by default" mindset. This is sometimes reflected in the language design itself, for example, in Rust:

fn foo(
    a: &i32,     /* this is a reference to immutable data */
    b: &mut i32, /* this is a reference to mutable data */
    c: i32,      /* this and all above bindings are immutable (no reassignment) */
    mut d: i32,  /* this binding is mutable */
) {
    let x = 0;          // this binding is constant (cannot be reassigned or mutated)
    let mut y = 0;      // this binding is mutable

    let z = &x;         // this reference is immutable
    let w = &mut y;     // this one's mutable
}
Enter fullscreen mode Exit fullscreen mode

Everything is constant unless otherwise specified. But even in a language like JavaScript, where const doesn't actually make the value immutable (it only disables reassignment), developers are usually encouraged to use it unless there's a reason not to.

function foo() {
    const arr = [];
    arr.push(1); // the array can still be mutated, but at least `arr` will always point to this one array

    arr = []; // BAD, crashes at runtime
    arr.push(2);

    return arr;
}
Enter fullscreen mode Exit fullscreen mode

This is great, and helps to eliminate some simple bugs while not really costing anything. In its documentation and code, Zig agrees, but from a design standpoint, it seems to be on the fence about it. Parameters and captures are immutable, which is good (there is no way to make them mutable even if you wanted to):

fn foo(a: i32) void {
    a = 5; // error: cannot assign to constant

    for ([_]u8{1, 2, 3}) |i| {
        i = 5; // error: cannot assign to constant
    }
}
Enter fullscreen mode Exit fullscreen mode

However, pointers are mutable by default, and const and var are the binding declaration keywords.

// Zig also doesn't have block comments like /* */
//          v--- this is a pointer to mutable data
//                  v--- this is a pointer to immutable data
fn bar(a: *i32, b: *const i32) void {
    var x = 10;     // the shorter keyword is used to define a mutable binding
    const y = 3;
}
Enter fullscreen mode Exit fullscreen mode

It's a strange mix. Zig will at least give you an error (yes, not a warning, and even in debug builds) if you declare a var binding and never mutate/reassign it, but no such diagnostic exists for pointers (as of Zig 0.13.0).

Speaking of errors, this is at least one of the errors that will show up in the editor while you're writing code. Due to the incomplete state of the zls language server, most other errors unfortunately wont. This usually means a lot of trips back and forth between the terminal and the code view -- not a dealbreaker or anything, but pretty annoying when you're used to the alternative.

And finally, a lightning round of irritations:

  • The void return type must be explicitly written
  • Zig got rid of the final expression return syntax, replacing it with the much more verbose block label + break
fn main() void {
    // this doesn't work
    const a = if (...) {
        foo();
        bar()
    };

    // instead do this
    const b = if (...) label: {
        foo();
        break label: bar();
    };
}
Enter fullscreen mode Exit fullscreen mode
  • No closures or even just anonymous function expressions
  • No operator overloading or destructors (defer is an ok-ish substitute)
  • The error type doesn't support adding fields to the errors, just a single error code, meaning you have to resort to out parameters or union(enum)s to return extra information on failures.
  • Bad higher-order function support, especially for slices and optionals
  • Build speed is less-than-stellar

Conclusion

By the tone of this post, it might seem like I hate Zig, but that definitely isn't the case. It's a huge upgrade from C, with some really innovative features and clever design. Comptime, allocator control, and best-in-class C integration make it a great choice for really low-level applications and maintaining/replacing C apps.

However, it's still suffering from some growing pains, mainly subpar tooling, lacking package ecosystem, and middling documentation, and it seems a bit behind the times in certain design areas, especially for such a modern language. Allocators and no destructors also make for a lot of mental overhead when writing programs, making it a hard choice for most high level tasks like CLI applications.

If you're a C developer though, you'll likely feel right at home. It shows a lot of promise and I'll continue to play around with it, but I don't see it replacing Rust as my go-to anytime soon.

Thanks for reading!

Top comments (1)