/now /links /thoughts

zig cookbook

23 Oct 2021

Zig is fun. I’ve been using it to build a game engine over the last few weeks, and it’s a great fit for that type of problem. Dealing with OpenGL’s insanity has never been easier :)

Zig certainly beats C at its own game, since it’s more explicit and careful about things like memory management and pointer alignment, but at the same time is more ergonomic and safe by adding optional types, slices, error sets, tagged unions, debug printers, namespacing, and really nice metaprogramming.

But if you’re here, you’ve probably already had your interest piqued by (some) of these features, so I’ll save the gushing for another time.

The language is still very much in a beta-level state. You can write useful programs, but expect to deal with compiler bugs, broken stdlib functions, lackluster documentation, and other rough edges.

I’m writing up this post to address part of the documentation issue. To be fair, the main documentation (the Zig Language Reference) is relatively complete and you should skim through that first if you haven’t already. The stdlib docs, on the other hand, are very inadequate. The suggested solution is to read through the source. I’ve done that, and wanted to document some common recipes to save other folks (and my future self) some time when searching for common patterns.

The examples below were tested against zig (0.9.0-dev.1343+75cecef63) (released around Oct 2021).

Compare two strings

std.mem.eql(u8, string_a, string_b);

Ignore a result

(a.k.a. work around the error: expression value is ignored and error: unused local variable compiler errors)

// Example 1
// x isn't used anywhere else yet
var x = 123;
// this line is a no-op
_ = x;

// Example 2
// We don't care about the return value of this function, but want to make the compiler happy
_ = functionThatReturnsSomething()

_ = tells the zig compiler that we’re explicitly ignoring a value. If you assign a value to _ it’s a no-op. If you assign the return value of a function to it, the return value is ignored.

Multiline comments

There are none. Use an editor that supports block selections or regex search and replace ‘^’ with ‘//’ 😛

Read the contents of a file in the current directory into a []u8

var file = try std.fs.cwd().openFile(filename, .{ .read = true });
defer file.close();

var buf = try allocator.alloc(u8, try file.getEndPos());
_ = try file.read(buf[0..buf.len]);
defer allocator.free(buf);

There’s no encoding info, because zig doesn’t have utf-8 support out of the box. It’s just a slice of bytes.

Add functions to a struct

pub const MyStruct = struct {
    data: u32,

    pub fn staticFunc() u32 {
        return 42;
    }

    // zig convention: Self = @This()
    // Grabs the type info for the current struct and puts it into
    // a convenient to use comptime variable `Self`.
    // This way, we don't have to litter `self: MyStruct`
    // everywhere. Also makes renaming the struct later easier.
    const Self = @This();

    pub fn getData(self: Self) u32 {
        return self.data;
    }

    pub fn addToData(self: *Self, amount: u32) void {
        // NOTE: we've grabbed a pointer to `self` since we'll be mutating it.
        self.data += amount;
    }
};

Zig’s struct methods work like Python’s class methods. If you call a struct method on an instance of the struct, it passes that instance as the first argument. If you call the method on the type itself, it’s treated as a static function. e.g.

var static_result = MyStruct.staticFunc();
var s = MyStruct{ .data = 3 };
s.addToData(3);
var data_result1 = s.getData(); // implicitly passes `s` as the first argument.
var data_result2 = MyStruct.getData(s); // equivalent to previous line

Get command line arguments (argv)

const allocator = std.heap.page_allocator;
var args = std.process.args();

// skip the binary name, i.e. ARGV[0]
// (returns true if it skipped an arg, but we won't bother checking here)
_ = args.skip();

// the `.?` syntax unwraps the optional. (we expect a value, if it's null this will panic).
var arg1 = args.next(allocator).?;
print("{s}\n", .{arg1});

Parse a dynamic JSON value

var parser = std.json.Parser.init(allocator, false);
var tree = try parser.parse(
\\{"key": "value",
\\ "arr": [1,2,3],
\\ "obj": {"x": 1, "y": 2}}
);
print("{s}\n", .{tree.root.Object.get("key").?.String});
print("{}\n", .{tree.root.Object.get("arr").?.Array.items[0].Integer});
print("{}\n", .{tree.root.Object.get("obj").?.Object.get("y").?.Integer});

Using C libraries

// main.zig
// include the header file in your source
const c = @cImport({
    @cInclude("epoxy/gl.h");
    @cInclude("epoxy/glx.h");
    @cInclude("glfw3.h");
});
const std = @import("std");
const panic = std.debug.panic;

// then use the structs/functions in the `c` namespace
pub fn main() void {
    if (c.glfwInit() == c.GLFW_FALSE) {
        panic("Failed to init GLFW", .{});
    }
    defer c.glfwTerminate();

    var window = c.glfwCreateWindow(800, 600, "Ghost", null, null);
    c.glfwMakeContextCurrent(window);
    c.glEnable(c.GL_DEPTH_TEST);
    // ... etc ...
}

Then in your build.zig, include the paths to the header files and add the necessary linking info:

// build.zig
const Builder = @import("std").build.Builder;

pub fn build(b: *Builder) void {
    const mode = b.standardReleaseOptions();
    const exe = b.addExecutable("main", "main.zig");
    exe.setBuildMode(mode);
    exe.addIncludeDir("/usr/include/GLFW/");

    exe.linkSystemLibrary("glfw3");
    exe.linkSystemLibrary("epoxy");
    exe.linkSystemLibrary("c");

    exe.install();
}

Run your program with:

zig build && ./zig-out/bin/main

Add a package to your project

zig doesn’t currently have a package manager. External libraries are usually included with git submodules.

git submodule add https://github.com/kooparse/zalgebra

You can add the package to the project by updating build.zig:

exe.addPackagePath("zalgebra", "zalgebra/src/main.zig");

Then import it into your project with:

const zalgebra = @import("zalgebra");

Compile-time config options (conditional compilation)

If you want compile time configuration (to specify different configurations or enable/disable features), update your build.zig with the following:

// enable_party_mode option is hardcoded here for simplicity, but you could 
// pull this from a build flag, file, coin flip, etc. It's just a zig value 
// that can be set at build time.
const enable_party_mode = true;
const build_options = b.addOptions();
build_options.addOption(bool, "enable_party_mode", enable_party_mode);
exe.addOptions("build_options", build_options);

Then import the build options in your source:

const build_options = @import("build_options");

// ...

// This if statement will evaluate at compile time and either include or disable the code
// depending on the value of enable_party_mode.
if (build_options.enable_party_mode) {
    startParty();
}

Way nicer than #ifdef and friends, eh?

Note that all the branches of the if statement are analyzed by the compiler, so you can’t have invalid code in the if branch.