zig / under the hood
DUE 000 DECK 000 STREAK 0d LESSONS 0/14 saved in this browser only
0x0C
Part II · Zig

comptime & generics

One feature replaces macros, templates and generics: running ordinary Zig at compile time.

Running code while compiling

There are normally two separate times in a program’s life: compile time, when the compiler turns your source into a program, and runtime, when the program runs. Zig lets you run ordinary Zig code at compile time too, using the keyword comptime. The results are baked into the program, so they cost nothing when it runs. This one feature does the job that other languages need several tools for: C’s preprocessor macros, C++ templates, TypeScript and Java generics, and C++’s constexpr.

Compile-time values

Some values are always known at compile time: number literals, types, and constants declared at the top level of a file. You can also force a calculation to happen at compile time:

const std = @import("std");

fn square(n: u32) u32 {
    return n * n;
}

const a = square(12); // top level: computed by the compiler

pub fn main() void {
    const b = comptime square(12); // forced to compile time
    const c = square(12); // an ordinary runtime call
    std.debug.print("{d} {d} {d}\n", .{ a, b, c });
}

A more useful example is building a lookup table during compilation:

const squares = blk: {
    var table: [16]u32 = undefined;
    for (&table, 0..) |*slot, i| slot.* = @intCast(i * i);
    break :blk table;
};

The compiler runs that loop and stores the finished 16 numbers in the executable. At runtime, squares[5] is just a memory read. The compiler protects itself from infinite loops: compile-time code has a limit on how many branches it can take. For bigger tables you can raise it with @setEvalBranchQuota.

Generics: functions that return types

This is where comptime gets powerful. Since types are values at compile time, a function can take a type as a parameter and return a brand-new type. That’s how Zig does generics, with no separate generics syntax at all:

fn Stack(comptime T: type, comptime capacity: usize) type {
    return struct {
        items: [capacity]T = undefined,
        len: usize = 0,

        const Self = @This();

        pub fn push(self: *Self, value: T) error{Full}!void {
            if (self.len == capacity) return error.Full;
            self.items[self.len] = value;
            self.len += 1;
        }

        pub fn pop(self: *Self) ?T {
            if (self.len == 0) return null;
            self.len -= 1;
            return self.items[self.len];
        }
    };
}

How to read it:

  1. Stack is an ordinary function. Its parameters are marked comptime because they must be known while compiling: a type, and a capacity.
  2. Its return type is type. The body builds a struct using T and capacity and returns it.
  3. const Self = @This(); gives the struct a name it can use for itself, since it has no name of its own. @This() means “the type I’m inside”.
  4. Stack(u8, 4) runs the function at compile time and produces a real type: a struct holding a [4]u8 and a length. Stack(f32, 100) produces a different one. Using it:
var s: Stack(u8, 4) = .{};
try s.push(1);
try s.push(2);
const top = s.pop(); // a ?u8 holding 2

Calling Stack(u8, 4) a second time gives back exactly the same type, because the compiler caches the result. You’ve already used this pattern without knowing it: std.ArrayList(u8) is a function call that returns a type.

anytype: accept anything, checked at compile time

A parameter declared as anytype accepts an argument of any type. The compiler makes a separate copy of the function for each type you actually call it with, and checks each copy. If you pass something that doesn’t support what the function does with it, you get a compile error at the call. Inside, you can ask for the type with @TypeOf(x) and examine its structure with @typeInfo:

fn describe(x: anytype) []const u8 {
    return switch (@typeInfo(@TypeOf(x))) {
        .int => "integer",
        .float => "float",
        .pointer => "pointer",
        else => "something else",
    };
}

That switch runs at compile time, so each copy of describe contains only the one branch it needs. Code that examines types like this is called reflection.

inline for: looping over types

A normal for runs at runtime, and every iteration has to work with the same types. inline for unrolls the loop at compile time, so each iteration can handle something different, such as the fields of a struct, which can each have a different type:

const Point = struct { x: i32, y: i32, visible: bool };
const p = Point{ .x = 1, .y = 2, .visible = true };

inline for (std.meta.fields(Point)) |f| {
    std.debug.print("{s} = {any}\n", .{ f.name, @field(p, f.name) });
}
// x = 1
// y = 2
// visible = true

@field(p, f.name) reads a field by its name, where the name is a compile-time string. This is how std.json can serialize any struct you give it, and how printing knows what’s inside your types.

When to reach for comptime

As a beginner, the main uses are:

  • Generic containers and functions, like fn Stack(comptime T: type).
  • Precomputed tables and constants.
  • Functions that work for many types (anytype), still checked by the compiler. Don’t use it to be clever. If a plain function does the job, a plain function is better.

Recap

  • comptime runs ordinary Zig during compilation; results cost nothing at runtime.
  • Top-level constants are always computed at compile time.
  • Generics are functions taking comptime T: type and returning a struct. @This() names it.
  • anytype makes a checked copy of a function per argument type; @typeInfo inspects types.
  • inline for unrolls loops at compile time, for example to walk a struct’s fields.
Try it

Write fn Matrix(comptime R: usize, comptime C: usize) type with a mul method whose return type is computed from both matrices' sizes, so multiplying mismatched matrices is a compile error rather than a runtime one.

11 flashcards for this lesson

not started
See the questions
  1. How does Zig do generics?
  2. What does @This() return?
  3. Which features from other languages does comptime replace?
  4. What is anytype?
  5. Why use inline for?
  6. Are container-level const initializers evaluated at compile time?
  7. What does @typeInfo(T) give you?
  8. What's the difference between compile time and runtime?
  9. What does @field(p, "x") do?
  10. What's the difference between compile time and runtime?
  11. What does @field(p, "x") do?