Zig First Impressions
Tags: zig
Published: 2024-06-29 22:55:45

## Table of Contents

-   [Why Zig?](#why-zig)
-   [What is Zig?](#what-is-zig)
-   [The Good](#the-good)
-   [The Bad](#the-bad)
-   [The Ugly](#the-ugly)
-   [Conclusion](#conclusion)

## 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](https://github.com/delta229/ctl) 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](https://github.com/delta229/bf).

## 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 `type`s 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](https://github.com/ziglang/zig/blob/3e9ab6aa7b2d90c25cb906d425a148abf9da3dcb/lib/std/fmt.zig#L80)!

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.

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

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

You can also painlessly cross-compile for other operating systems or architectures without downloading anything extra!
```bash
# Build an ARM binary from x86 with one command!
$ zig build-exe main.zig -target aarch64-linux -lc
```

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

```zig
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];
}
```

Zig also supports algebraic data types (aka sum types or [tagged unions](https://en.wikipedia.org/wiki/Tagged_union)) through the `union(enum)` construct.

```zig
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 => {}
    }
}
```

## 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.

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

const v: Vec2(f32) = .{ .x = 0.1, .y = 2.5 };
```

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

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

// this is basically short for
// fn foo(comptime T: type, bar: T)
```

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.

```zig
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'
}
```

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](https://zig.news/perky/anytype-antics-2398), if you're curious. You can see an example of the third in Zig's own standard library with the [Allocator](https://github.com/ziglang/zig/blob/3e9ab6aa7b2d90c25cb906d425a148abf9da3dcb/lib/std/mem/Allocator.zig#L17) 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:

```rs
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
}
```

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.

```js
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;
}
```

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):

```zig
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
    }
}
```

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

```zig
// 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;
}
```

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](https://github.com/ziglang/zig/issues/629) the final expression return syntax, replacing it with the much more verbose block label + break

```zig
fn main() void {
    // this doesn't work
    const a = if (...) {
        foo();
        bar()
    };

    // instead do this
    const b = if (...) label: {
        foo();
        break label: bar();
    };
}
```

-   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!