Exploring comptime

Let's create a sum function that takes in a slice of numbers (any type of number!!) and returns the sum of all the numbers! How can we do that?

heheh i see you Java/C++ programmers reaching for your < and > hehehe

const std = @import("std");

fn sum(comptime T: type, values: []const T) T {
    var result: T = 0;
    for (values) |value| {
        result += value;
    }
    return result;
}

pub fn main() void {
    const some_i32s = [_]i32{ 1, 2, 3, 4, 5 };
    std.debug.print("sum of i32s: {}\n", .{sum(i32, &some_i32s)});

    const some_f32s = [_]f32{ 1.0, 2.0, 3.0, 4.0, 5.0 };
    std.debug.print("sum of f32s: {}\n", .{sum(f32, &some_f32s)});

    const some_u64s = [_]u64{ 1, 2, 3, 4, 5 };
    std.debug.print("sum of u64s: {}\n", .{sum(u64, &some_u64s)});
}

Woah, what is comptime? And why is the type of T like type itself?

What is comptime?

I'm sure many of us have heard of macros. They exist in languages like C, Rust or even Lisp (in quite a different form), and they serve as a way of executing some code at compile-time instead of runtime.

We might have also heard of generics in Java and Rust, or even templates in C++ (not sure if I'm committing a sin to lump these together), in order to write code that works across all types with certain constraints.

If you're a user of Go, you might've also used go generate to write repeated code for you.

Well, Zig has a solution that encompasses all three use-cases mentioned above, and that is comptime! What the comptime feature in Zig allows you to do, is simply write Zig code (not any other special language ala C++ template metaprogramming) that is executed at compile-time, instead of runtime!

Pre-computing values

Looking at our earlier example, our sum function is pure, and can be pre-computed. Let's try to get Zig to precompute the results instead of computing the results at runtime.

Of course, the Zig compiler might have also realised this and done the optimisation already, but for the sake of this being an example of pre-computing values, let's treat this as an optimisation!

const std = @import("std");

fn sum(comptime T: type, values: []const T) T {
    var result: T = 0;
    for (values) |value| {
        result += value;
    }
    return result;
}

pub fn main() void {
    const some_i32s = [_]i32{ 1, 2, 3, 4, 5 };
    std.debug.print("sum of i32s: {}\n", .{comptime sum(i32, &some_i32s)});

    const some_f32s = [_]f32{ 1.0, 2.0, 3.0, 4.0, 5.0 };
    std.debug.print("sum of f32s: {}\n", .{comptime sum(f32, &some_f32s)});

    const some_u64s = [_]u64{ 1, 2, 3, 4, 5 };
    std.debug.print("sum of u64s: {}\n", .{comptime sum(u64, &some_u64s)});
}

Wow! Do you notice what changed? By simply adding the comptime keyword in front of our call to sum, the function was called at compile-time instead of runtime.

Generics

Our sum function is already a pretty good example of using comptime to create the effect of generics. Let's go one step further and create a data structure that supports generics.

const std = @import("std");

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

pub fn main() void {
    const vector_of_i32s = Vector2D(i32){ .x = 1, .y = 2 };
    const vector_of_f32s = Vector2D(f32){ .x = 1.0, .y = 2.0 };

    std.debug.print("vector_of_i32s: {any}\n", .{vector_of_i32s});
    std.debug.print("vector_of_f32s: {any}\n", .{vector_of_f32s});
}

Notice how we can return structs from functions in Zig! This definitely wouldn't work at runtime, since types don't have a representation at runtime (unless we use runtime reflection). So Zig is actually running the Vector2D function at comptime here, and treating the resulting structs as types to be constructed.

Remember that this is all Zig code, which means we can go one step further and make the length generic as well!

const std = @import("std");

fn Vector(comptime T: type, comptime len: usize) type {
    return struct {
        values: [len]T,
    };
}

const Vector_i32_2D = Vector(i32, 2);
const Vector_f32_4D = Vector(f32, 4);

pub fn main() void {
    const vector_of_2_i32s = Vector_i32_2D{ .values = [_]i32{ 1, 2 } };
    const vector_of_4_f32s = Vector_f32_4D{ .values = [_]f32{ 1.0, 2.0, 3.0, 4.0 } };

    std.debug.print("vector_of_2_i32s: {any}\n", .{vector_of_2_i32s});
    std.debug.print("vector_of_4_f32s: {any}\n", .{vector_of_4_f32s});
}

Here, both parameters to Vector play a part in defining what kind of struct the resulting type will be!

Last updated