Skip to content

Zig Metaprogramming#

Overview#

Zig's metaprogramming is driven by a few basic concepts:

  • Types are valid values at compile-time
  • most runtime code will also work at compile-time
  • struct field evaluation is compile-time duck-typed
  • the zig standard library gives you tools to perform compile-time reflection
  • examples:
  • multiple dispatch

    Zig
    const std = @import("std");
    
    fn foo(x : anytype) @TypeOf(x) {
        // note that this if statement happens at compile-time, not runtime.
        if (@TypeOf(x) == i64) {
            return x + 2;
        } else {
            return 2 * x;
        }
    }
    
    pub fn main() void {
        var x: i64 = 47;
        var y: i32 =  47;
    
        std.debug.print("i64-foo: {}\n", .{foo(x)});
        std.debug.print("i32-foo: {}\n", .{foo(y)});
    }
    
  • generic Types

    Zig
    fn Vec2Of(comptime T: type) type {
        return struct{
            x: T,
            y: T
        };
    }
    
    const V2i64 = Vec2Of(i64);
    const V2f64 = Vec2Of(f64);
    
    pub fn main() void {
        var vi = V2i64{.x = 47, .y = 47};
        var vf = V2f64{.x = 47.0, .y = 47.0};
    
        std.debug.print("i64 vector: {}\n", .{vi});
        std.debug.print("f64 vector: {}\n", .{vf});
    }
    

Compile Time Execution#

  • Blocks of code may be forcibly executed at compile time using the comptime keyword. In this example, the variables x and y are equivalent.
Zig
test "comptime blocks" {
    var x = comptime fibonacci(10);

    var y = comptime blk: {
        break :blk fibonacci(10);
    };
}
  • Integer literals are of the type comptime_int. These are special in that they have no size (they cannot be used at runtime!), and they have arbitrary precision. comptime_int values coerce to any integer type that can hold them. They also coerce to floats. Character literals are of this type.
Zig
test "comptime_int" {
    const a = 12;
    const b = a + 10;

    const c: u4 = a;
    const d: f32 = b;
}
  • comptime_float is also available, which internally is an f128. These cannot be coerced to integers, even if they hold an integer value.

  • function parameters in Zig can be tagged as being comptime, meaning value passed must be known at compile time

Zig
fn Matrix(
    comptime T: type,
    comptime width: comptime_int,
    comptime height: comptime_int,
) type {
    return [height][width]T;
}

test "returning a type" {
    expect(Matrix(f32, 4, 4) == [4][4]f32);
}

Gotchas/Surprises#

  • no peer type resolution in comptime execution
  • all comptime values do not obey usual lifetime rules;
  • have "static" lifetimes (can think of values as garbage collected)
  • anytype struct fields are allowed
  • turns the struct into a comptime type
  • NOTE: allows the type of the field to be mutable

    Zig
    const ArgTuple = struct {
      tuple: anytype = .{},
    };
    var arg_list = ArgTuple{};
    for (args) |arg| {
      if (@TypeOf(arg) == ?u21) {
        if (arg) |cp| {
          arg_list.tuple = arg_list.tuple ++ .{ctUtf8EncodeChar(cp)};
        } else {
          arg_list.tuple = arg_list.tuple ++ .{"null"};
        }
      } else if (@TypeOf(arg) == u21) {
        arg_list.tuple = arg_list.tuple ++ .{ctUtf8EncodeChar(arg)};
      } else {
        arg_list.tuple = arg_list.tuple ++ .{arg};
      }
    }
    

  • can use comptime var to create compile time closures

  • uses anonymous struct literals to avoid compiler caching
  • can be buggy in complex cases
  • Look at zorrow, a simple rust-like borrow checker implemented using this technique

Reflection#

  • Types in Zig are values of the type type, only available at compile time
Zig
test "branching on types" {
    const a = 5;
    const b: if (a < 10) f32 else i32 = 5;
}
  • can reflect upon types using the built-in @typeInfo, which takes in a type and returns a tagged union.

  • tagged union type can be found in std.builtin.TypeInfo (info on how to make use of imports and std later)

Zig
fn addSmallInts(comptime T: type, a: T, b: T) T {
    return switch (@typeInfo(T)) {
        .ComptimeInt => a + b,
        .Int => |info| if (info.bits <= 16)
            a + b
        else
            @compileError("ints too large"),
        else => @compileError("only ints accepted"),
    };
}
test "typeinfo switch" {
    const x = addSmallInts(u16, 20, 30);
    expect(@TypeOf(x) == u16);
    expect(x == 50);
}
  • can use the @Type function to create a type/reify from a @typeInfo. @Type is implemented for most types but is notably unimplemented for enums, unions, functions

  • anonymous struct syntax is used with .{}, because the T in T{} can be inferred. In this example we will get a compile error if the Int tag isn’t set

Zig
fn GetBiggerInt(comptime T: type) type {
    return @Type(.{
        .Int = .{
            .bits = @typeInfo(T).Int.bits + 1,
            .signedness = @typeInfo(T).Int.signedness,
        },
    });
}

test "@Type" {
    expect(GetBiggerInt(u8) == u9);
    expect(GetBiggerInt(i31) == i32);
}

Generic Types#

  • Generic types are specified through explicit parametric type constructor functions

  • Returning a struct type is how you make generic data structures in Zig. The usage of @This is required here, which gets the type of the innermost struct, union, or enum. Here std.mem.eql is also used which compares two slices.

Zig
fn Vec(
    comptime count: comptime_int,
    comptime T: type,
) type {
    return struct {
        data: [count]T,
        const Self = @This();

        fn abs(self: Self) Self {
            var tmp = Self{ .data = undefined };
            for (self.data) |elem, i| {
                tmp.data[i] = if (elem < 0)
                    -elem
                else
                    elem;
            }
            return tmp;
        }

        fn init(data: [count]T) Self {
            return Self{ .data = data };
        }
    };
}

const eql = @import("std").mem.eql;

test "generic vector" {
    const x = Vec(3, f32).init([_]f32{ 10, -10, 5 });
    const y = x.abs();
    expect(eql(f32, &y.data, &[_]f32{ 10, 10, 5 }));
}
  • The types of function parameters can also be inferred by using anytype in place of a type. @TypeOf can then be used on the parameter.
Zig
fn plusOne(x: anytype) @TypeOf(x) {
    return x + 1;
}

test "inferred function parameter" {
    expect(plusOne(@as(u32, 1)) == 2);
}
  • Comptime also introduces the operators ++ and ** for concatenating and repeating arrays and slices. These operators do not work at runtime.
Zig
test "++" {
    const x: [4]u8 = undefined;
    const y = x[0..];

    const a: [6]u8 = undefined;
    const b = a[0..];

    const new = y ++ b;
    expect(new.len == 10);
}

test "**" {
    const pattern = [_]u8{ 0xCC, 0xAA };
    const memory = pattern ** 3;
    expect(eql(
        u8,
        &memory,
        &[_]u8{ 0xCC, 0xAA, 0xCC, 0xAA, 0xCC, 0xAA }
    ));
}

Examples#

Generic Types through Functors#

  • The function returns a type, which means it can only be called at comptime. It defines two structs:

    Zig
    fn LinkedList(comptime T: type) type {
        return struct {
            pub const Node = struct {
                prev: ?*Node = null,
                next: ?*Node = null,
                data: T,
            };
    
            first: ?*Node = null,
            last: ?*Node = null,
            len: usize = 0,
        };
    }
    

  • main LinkedList struct

  • Node struct, namespaced inside the main struct
  • structs can namespace functions and variables
  • useful for introspection when creating composite types
    Zig
    // To try this code, paste both definitions in the same file.
    const PointList = LinkedList(Point);
    const p = Point{ .x = 0, .y = 2, .z = 8 };
    
    var my_list = PointList{};
    
    // A complete implementation would offer an `append` method.
    // For now let's add the new node manually.
    var node = PointList.Node{ .data = p };
    my_list.first = &node;
    my_list.last = &node;
    my_list.len = 1;
    

Dynamic specialization#

  • type anytype binds to anything
Zig
fn makeCoupleOf(x: anytype) [2]@TypeOf(x) {
    return [2]@TypeOf(x){ x, x };
}
  • allows specialization based on call types
Zig
fn ReturnType(comptime T: type) type {
    comptime var info = @typeInfo(T);
    if (info == .Int) {
        info.Int.bits /= 2;
        return @Type(info);
    } else {
        return T;
    }
}

pub fn sqrt(x: anytype) ReturnType(@TypeOf(x)) {
    const T = @TypeOf(x);
    switch (@typeInfo(T)) {
        .ComptimeFloat, .Float => return @sqrt(x),
        .ComptimeInt => {
            if (x < 0) {
                @compileError("sqrt on negative number");
            }
            return T(sqrtInt(u128, x));
        },
        .Int => return sqrtInt(T, x),
        else => @compileError("not implemented for " ++ @typeName(T)),
    }
}