Comptime

內容

Programming has obvious abilities to increase productivity through automated manipulation of data. Metaprogramming allows code to be treated as data, turning programming’s power back onto itself. Programming close to the metal has perhaps the most to gain from metaprogramming as high level concepts need to be mapped precisely to low level operations. Yet outside of functional languages I’ve always found the metaprogramming story to be a disappointment. So when I saw that Zig lists metaprogramming as a major feature, I took interest.

Honestly, my first experience writing something using Zig’s comptime were pretty rough. The concepts seemed foreign, and figuring out how to achieve the outcome I wanted was difficult. Then with a perspective shift, everything clicked and suddenly I loved it. To help speed you along that path of discovery, presented below are 6 different “views” of comptime. Each view focuses on a different way you can translate existing programming knowledge into working with Zig.

This is also not a full guide on all you need to know to write comptime. It’s more focused on providing a breadth of strategies, stretching your understanding in different ways that together lend a fuller picture of how to think about comptime.

For clarity, all of the examples are valid Zig code, but the transformations done by the examples are only conceptual in nature. They are not how Zig is implemented.

View 0: You can ignore it.

It’s pretty weird to say I love a feature, then immediately declare you can ignore it. However, I’m starting with what I truly think is Zig comptime’s superpower. The third item in zig zen is Favor reading code over writing code. Being able to easily read code is important for all sorts of reasons, as it is how you build a conceptual understanding that is required to debug or make modifications to that codebase.

Metaprogramming can quickly land itself into the realm of “write only code”. If you’re trying to work with macro based metaprogramming or code generation, there’s now two versions of the code: the source code and the expanded code. The extra layer of indirection makes everything from reading to debugging the code more difficult. If you determine the behavior of the program needs to change, you need to determine what the generated code needs to be, and then have determine how to get the metaprogramming to produce that code.

In Zig, none of that overhead necessary. You can simply ignore that portions of the code are run at different times, conceptually mixing runtime and comptime. To demonstrate this, let’s step through two different pieces of code. The first is some normal runtime code to set a baseline for stepping through code, and the second utilizes comptime.

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; for (array) |value| { sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; for (array) |value| { sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

array: [3]i64 = .{1,2,3};

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; for (array) |value| { sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

array: [3]i64 = .{1,2,3}; sum: i64 = 0;

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; for (array) |value| { sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

array: [3]i64 = .{1,2,3}; sum: i64 = 0; value: i64 = 1;

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; for (array) |value| { sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

array: [3]i64 = .{1,2,3}; sum: i64 = 1; value: i64 = 1;

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; for (array) |value| { sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

array: [3]i64 = .{1,2,3}; sum: i64 = 1; value: i64 = 2;

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; for (array) |value| { sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

array: [3]i64 = .{1,2,3}; sum: i64 = 3; value: i64 = 2;

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; for (array) |value| { sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

array: [3]i64 = .{1,2,3}; sum: i64 = 3; value: i64 = 3;

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; for (array) |value| { sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

array: [3]i64 = .{1,2,3}; sum: i64 = 6; value: i64 = 3;

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; for (array) |value| { sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

array: [3]i64 = .{1,2,3}; sum: i64 = 6;

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; for (array) |value| { sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

array: [3]i64 = .{1,2,3}; sum: i64 = 6;

array's sum is 6.

PreviousResetNext

Press next to step through the program, observing the changing state. This one is simple enough: sum a list of numbers. Now let’s do something weirder: sum the fields of a struct. It’s a contrived example, but demonstrates the point well.

const MyStruct = struct { a: i64, b: i64, c: i64, }; pub fn main() void { const my_struct: MyStruct = .{ .a = 1, .b = 2, .c = 3, }; var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } std.debug.print("structs's sum is {d}.\n", .{sum}); }

const MyStruct = struct { a: i64, b: i64, c: i64, }; pub fn main() void { const my_struct: MyStruct = .{ .a = 1, .b = 2, .c = 3, }; var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } std.debug.print("structs's sum is {d}.\n", .{sum}); }

my_struct: MyStruct = .{.a = 1, .b = 2, .c = 3};

const MyStruct = struct { a: i64, b: i64, c: i64, }; pub fn main() void { const my_struct: MyStruct = .{ .a = 1, .b = 2, .c = 3, }; var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } std.debug.print("structs's sum is {d}.\n", .{sum}); }

my_struct: MyStruct = .{.a = 1, .b = 2, .c = 3}; sum: i64 = 0;

const MyStruct = struct { a: i64, b: i64, c: i64, }; pub fn main() void { const my_struct: MyStruct = .{ .a = 1, .b = 2, .c = 3, }; var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } std.debug.print("structs's sum is {d}.\n", .{sum}); }

my_struct: MyStruct = .{.a = 1, .b = 2, .c = 3}; sum: i64 = 0; field_name: []const u8 = "a";

const MyStruct = struct { a: i64, b: i64, c: i64, }; pub fn main() void { const my_struct: MyStruct = .{ .a = 1, .b = 2, .c = 3, }; var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } std.debug.print("structs's sum is {d}.\n", .{sum}); }

my_struct: MyStruct = .{.a = 1, .b = 2, .c = 3}; sum: i64 = 1; field_name: []const u8 = "a";

const MyStruct = struct { a: i64, b: i64, c: i64, }; pub fn main() void { const my_struct: MyStruct = .{ .a = 1, .b = 2, .c = 3, }; var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } std.debug.print("structs's sum is {d}.\n", .{sum}); }

my_struct: MyStruct = .{.a = 1, .b = 2, .c = 3}; sum: i64 = 1; field_name: []const u8 = "b";

const MyStruct = struct { a: i64, b: i64, c: i64, }; pub fn main() void { const my_struct: MyStruct = .{ .a = 1, .b = 2, .c = 3, }; var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } std.debug.print("structs's sum is {d}.\n", .{sum}); }

my_struct: MyStruct = .{.a = 1, .b = 2, .c = 3}; sum: i64 = 3; field_name: []const u8 = "b";

const MyStruct = struct { a: i64, b: i64, c: i64, }; pub fn main() void { const my_struct: MyStruct = .{ .a = 1, .b = 2, .c = 3, }; var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } std.debug.print("structs's sum is {d}.\n", .{sum}); }

my_struct: MyStruct = .{.a = 1, .b = 2, .c = 3}; sum: i64 = 3; field_name: []const u8 = "c";

const MyStruct = struct { a: i64, b: i64, c: i64, }; pub fn main() void { const my_struct: MyStruct = .{ .a = 1, .b = 2, .c = 3, }; var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } std.debug.print("structs's sum is {d}.\n", .{sum}); }

my_struct: MyStruct = .{.a = 1, .b = 2, .c = 3}; sum: i64 = 6; field_name: []const u8 = "c";

const MyStruct = struct { a: i64, b: i64, c: i64, }; pub fn main() void { const my_struct: MyStruct = .{ .a = 1, .b = 2, .c = 3, }; var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } std.debug.print("structs's sum is {d}.\n", .{sum}); }

my_struct: MyStruct = .{.a = 1, .b = 2, .c = 3}; sum: i64 = 6;

const MyStruct = struct { a: i64, b: i64, c: i64, }; pub fn main() void { const my_struct: MyStruct = .{ .a = 1, .b = 2, .c = 3, }; var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } std.debug.print("structs's sum is {d}.\n", .{sum}); }

my_struct: MyStruct = .{.a = 1, .b = 2, .c = 3}; sum: i64 = 6;

array's sum is 6.

PreviousResetNext

The comptime example here, compared to the array sum example, is pretty much a non-event. That is the entire point! An executable of this code is as efficient as if you hand wrote a sum function for the struct’s type in C, yet the code reads like something you might write in a language with runtime reflection. While this isn’t how Zig actually works, this also isn’t some entirely theoretical exercise either: the Zig core team is working on a debugger that lets you step through code mixing comptime and runtime exactly like this example.

There’s a lot of Zig which is comptime, much more than some simple type reflection, yet you don’t need to dig into any of the details to simply read code. Of course, if you want to actually write coding using comptime, you will eventually need to do more than ignore it.

View 1: Oh look, generics.

Generics are not a specific feature in Zig. Instead a simple subset of comptime features together handle everything you need for generic programming. This view doesn’t let you understand all of comptime, but it does provide you an entry point to complete many of the tasks you might use it for. To make a type generic, simply wrap its definition in a function which takes a type, and returns a type.

pub fn GenericMyStruct(comptime T: type) type { return struct { a: T, b: T, c: T, fn sumFields(my_struct: GenericMyStruct(T)) T { var sum: T = 0; const fields = comptime std.meta.fieldNames(GenericMyStruct(T)); inline for (fields) |field_name| { sum += @field(my_struct, field_name); } return sum; } }; } pub fn main() void { const my_struct: GenericMyStruct(i64) = .{ .a = 1, .b = 2, .c = 3, }; std.debug.print("structs's sum is {d}.\n", .{my_struct.sumFields()}); }

PreviousResetNext

Generic functions are possible as well.

fn quadratic(comptime T: type, a: T, b: T, c: T, x: T) T { return a * x*x + b * x + c; } pub fn main() void { const a = quadratic(f32, 21.6, 3.2, -3, 0.5); const b = quadratic(i64, 1, -3, 4, 2); std.debug.print("Answer: {d}{d}\n", .{a, b}); }

PreviousResetNext

It is also possible to infer the type of an argument by using the special type anytype, typically used if the type of the argument doesn’t matter to the rest of the function signature.

View 2: Standard code, run at compile time.

It’s a tale as old as time programming: A way to automate commands is added. Then you need variables, of course. Oh, conditionals too. Please, can I have loops? The path to a shoehorned macro language is paved with reasonable feature requests. Zig uses the same language in runtime, comptime, and even the build system.

Consider the classic fizz buzz.

fn fizzBuzz(writer: std.io.AnyWriter) !void { var i: usize = 1; while (i <= 100) : (i += 1) { if (i % 3 == 0 and i % 5 == 0) { try writer.print("fizzbuzz\n", .{}); } else if (i % 3 == 0) { try writer.print("fizz\n", .{}); } else if (i % 5 == 0) { try writer.print("buzz\n", .{}); } else { try writer.print("{d}\n", .{i}); } } } pub fn main() !void { const out_writer = std.io.getStdOut().writer().any(); try fizzBuzz(out_writer); }

PreviousResetNext

Simple enough. Though, one thing that I always find funny about conversations of making Fizz Buzz fast is that the standard version of the problem always runs for exactly the first 100 numbers. So given that the output is fixed, why not just precompute the answer and output that? Using the exact same fizzBuzz function, we can do just that.

pub fn main() !void { const full_fizzbuzz = comptime init: { var cw = std.io.countingWriter(std.io.null_writer); fizzBuzz(cw.writer().any()) catch unreachable; var buffer: [cw.bytes_written]u8 = undefined; var fbs = std.io.fixedBufferStream(&buffer); fizzBuzz(fbs.writer().any()) catch unreachable; break :init buffer; }; const out_writer = std.io.getStdOut().writer().any(); try out_writer.writeAll(&full_fizzbuzz); }

PreviousResetNext

Here the comptime keyword indicates that the block it precedes will run during the compile. Additionally the block is labeled “init”, so that the whole block can evaluate to a value with the later break. A writer which counts how many bytes are written (but discards the actual bytes written) is used to determine the total length. An array is created with that length, written to, and set as full_fizzbuzz.

Timing just the critical section, the precomputed version runs about 9 times faster. Of course, this example is so small the total execution time is dominated by other factors, but you get the idea.

There are a few small differences between comptime and runtime. Only comptime has access to variables of types comptime_int, comptime_float, or type. Additionally some functions have only comptime arguments, effectively making them comptime only. Runtime is the only one to have access to system calls, or anything which would use them. If your code doesn’t use any of those features, it will work equally well in both comptime and runtime.

View 3: Partial Evaluation

Now we’re getting to the fun stuff.

One way to view evaluation of code is to substitute inputs with their runtime value, and then repeatedly substitute the first expression into the evaluated form until only the result remains. This is common in CS theory contexts, and some functional languages. As a set up to a later example, we’ll use the array sum to get an idea of this process:

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; for (array) |value| { sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

Break the for loop into individual statements.

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; { const value = array[0]; sum += value; } { const value = array[1]; sum += value; } { const value = array[2]; sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

Substitue array[0] with 1.

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; { const value = 1; sum += value; } { const value = array[1]; sum += value; } { const value = array[2]; sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

Substitute value with 1.

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 0; { sum += 1; } { const value = array[1]; sum += value; } { const value = array[2]; sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

Increment sum by 1.

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 1;

{ const value = array[1]; sum += value; } { const value = array[2]; sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

Substitute array[1] with 2.

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 1;

{ const value = 2; sum += value; } { const value = array[2]; sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

Substitute value with 2.

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 1;

{ sum += 2; } { const value = array[2]; sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

Increment sum by 2.

pub fn main() void { const array: [3]i64 = .{1,2,3}; var sum: i64 = 3;

{ const value = array[2]; sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

Substitute array[2] with 3.

pub fn main() void { var sum: i64 = 3;

{ const value = 3; sum += value; } std.debug.print("array's sum is {d}.\n", .{sum}); }

Substitute value with 3.

pub fn main() void { var sum: i64 = 3;

{ sum += 3; } std.debug.print("array's sum is {d}.\n", .{sum}); }

Increment sum by 3.

pub fn main() void { var sum: i64 = 6;

std.debug.print("array's sum is {d}.\n", .{sum}); }

Substitute sum with 6.

pub fn main() void {

std.debug.print("array's sum is {d}.\n", .{6}); }

Next std.debug.print would be replaced by its implementation. Stopping here for simplicity.

PreviousResetNext

Partial evaluation is a technique by which you can pass some, but not necessarily all, of the arguments to a function. The substitutions for expressions which use already only known values in this context can be performed. This produces a new function which only takes the still unknown arguments. Zig comptime can be viewed as a partial evaluation taking place during the compile process. Taking another look at the sum struct example, gives us:

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } return sum; } };

inline for indicates that this for loop runs at comptime, so break the iterations into individual statements.

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; { const field_name = "a"; sum += @field(my_struct, field_name); } { const field_name = "b"; sum += @field(my_struct, field_name); } { const field_name = "c"; sum += @field(my_struct, field_name); } return sum; } };

Replace field_name with its value.

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; { sum += @field(my_struct, "a"); } { const field_name = "b"; sum += @field(my_struct, field_name); } { const field_name = "c"; sum += @field(my_struct, field_name); } return sum; } };

@field allows accessing a field using a comptime known string.

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; { sum += my_struct.a; } { const field_name = "b"; sum += @field(my_struct, field_name); } { const field_name = "c"; sum += @field(my_struct, field_name); } return sum; } };

continue with the next field, skipping ahead a bit.

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; { sum += my_struct.a; } { sum += my_struct.b; } { const field_name = "c"; sum += @field(my_struct, field_name); } return sum; } };

and the last field, skipping ahead again.

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; { sum += my_struct.a; } { sum += my_struct.b; } { sum += my_struct.c; } return sum; } };

Cleaning up the unnessisary scopes.

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; sum += my_struct.a; sum += my_struct.b; sum += my_struct.c; return sum; } };

PreviousResetNext

The final function is “hand optimized”, but the work is done by Zig’s comptime. This allows the intent to be directly coded. No “remember to update the sum function when you change these fields” comment needed. Any changes to MyStruct’s fields are just handled correctly.

View 4: Comptime Evaluation, runtime code emission

This is nearly the same as partial evaluation. Here, there are two version of the code, input (pre-comptime) and output (post-comptime). The input code is run by the compiler. If a statement is knowable at compile time, it is simply evaluated. However if a statement requires some runtime value, the statement is added to the output code.

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } return sum; } };

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { } };

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } return sum; } };

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; } };

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } return sum; } };

field_name: []const u8 = "a";

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; } };

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } return sum; } };

field_name: []const u8 = "a";

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; sum += my_struct.a; } };

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } return sum; } };

field_name: []const u8 = "b";

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; sum += my_struct.a; } };

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } return sum; } };

field_name: []const u8 = "b";

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; sum += my_struct.a; sum += my_struct.b; } };

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } return sum; } };

field_name: []const u8 = "c";

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; sum += my_struct.a; sum += my_struct.b; } };

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } return sum; } };

field_name: []const u8 = "c";

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; sum += my_struct.a; sum += my_struct.b; sum += my_struct.c; } };

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } return sum; } };

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; sum += my_struct.a; sum += my_struct.b; sum += my_struct.c; } };

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } return sum; } };

const MyStruct = struct { a: i64, b: i64, c: i64, fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; sum += my_struct.a; sum += my_struct.b; sum += my_struct.c; return sum; } };

PreviousResetNext

This view is actually the closest to how Zig’s compiler handles comptime. The primary difference is that Zig first parses the syntax of your code, and turns it into bytecode for a virtual machine. That virtual machine running is how comptime is implemented. This virtual machine will evaluate whatever it can, and emit new bytecode (which is later translated into machine code) for whatever needs to be handled at runtime. Conditionals such as if statements with runtime inputs simply emit both paths.

A natural result of this is that dead code is never semantically analyzed. This can take some getting used to, as writing an invalid function doesn’t always give you a compile error until you actually try to use it. However this also makes compilation more efficient, and allows more natural looking conditional compilation, no #ifdefs here!

It’s worth noting how much comptime permeates Zig’s design. All Zig code runs through this virtual machine, including functions with no obvious use of comptime. Even where you see simple type names, such as function arguments, are actually expressions which evaluate in comptime to a variable of type type. This is how the generics example above works. It also means you can use a more complicated expressions to calculate a type, where appropriate.

Another consequence of this is that static analysis of Zig code is much more complicated than most statically typed languages, since significant portions of the compiler need to run to even determine all of the types. So until Zig tooling is able to catch up, editor tools such as code completion don’t always work well.

View 5: Textual Code Generation

I lamented the difficulty of writing code that outputs new source code at the beginning of the post. Yet it remains a powerful tool that, even in Zig, has its place in solving some problems. If this method of metaprogramming is familiar to you then moving to Zig comptime might feel like a significant downgrade. There is a familiar structure to writing code that, when run, produces code.

But wait, the last example did just that, right? If you look at things in just the right way, there is an underlying equivalence between code that writes code and mixed comptime and runtime code.

The following example doesn’t step through code, instead it flips between two versions. The first is an example which outputs the code, and the second is the familiar comptime example. The two versions of code are aligned with the same logic on the same line.

pub fn writeSumFn( writer: std.io.AnyWriter, type_name: []const u8, field_names: [][]const u8, ) !void { try writer.print("fn sumFields(value: {s}) i64 {{\n", .{type_name}); try writer.print("var sum: i64 = 0;\n", .{}); for (field_names) |field_name| { try writer.print("sum += value.{s};\n", .{field_name}); } try writer.print("return sum;\n", .{}); try writer.print("}}\n", .{}); }

fn sumFields(my_struct: MyStruct) i64 { var sum: i64 = 0; inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| { sum += @field(my_struct, field_name); } return sum; }

Toggle

Notice how there are two conversions: The code which runs in the generator becomes the comptime part of the code. The code which the generator outputs becomes the runtime part of the code.

Another thing I like about this example is it shows how generating code which uses type information as an input is much simpler with Zig. The example handwaves where the type name and field name information comes from. If you’re using some other form of input, such as a specification, Zig provides @embedFile which you can use to then parse like you normally would.

Bringing back the generics example, there are a few more subtleties worth highlighting:

pub fn writeMyStructOfType( writer: std.io.AnyWriter, T: []const u8, ) !void { try writer.print("const MyStruct_{s} = struct {{\n", .{T}); try writer.print("a: {s},\n", .{T}); try writer.print("b: {s},\n", .{T}); try writer.print("c: {s},\n", .{T}); try writer.print("fn sumFields(value: MyStruct_{s}) {s} {{\n", .{T,T}); try writer.print("var sum: {s} = 0;\n", .{T}); const fields = [_][]const u8{ "a", "b", "c" }; for (fields) |field_name| { try writer.print("sum += value.{s};\n", .{field_name}); } try writer.print("return sum;\n", .{}); try writer.print("}}\n", .{}); try writer.print("}};\n", .{}); }

pub fn GenericMyStruct(comptime T: type) type { return struct { a: T, b: T, c: T, fn sumFields(my_struct: GenericMyStruct(T)) T { var sum: T = 0; const fields = comptime std.meta.fieldNames(GenericMyStruct(T)); inline for (fields) |field_name| { sum += @field(my_struct, field_name); } return sum; } }; }

Toggle

The fields retain the conversion above, but mix the two in a single line. The field’s type expression is done by the generator/at comptime, while the field itself ends up as a definition used by the runtime code.

References to the name of the type are more direct with comptime, able to use the function directly instead of having to concat text into a name that has be to consistent across uses.

There is an exception to this view. You can create types where the names of the fields are determined at comptime, however doing so involves calling a builtin function with a specification containing a list of field definitions. As a result you can’t define declarations, such as methods, on these types. In practice this doesn’t limit the expressiveness of your code, but it does limit what kinds of API you can expose to other code.

Relevant to this section are textual macros such as those in C. Most of the sane things you can do can be done in comptime, though they rarely take a form that looks similar. However, you can’t do everything that text macros allow. For example, you can’t decide that you dislike a Zig keyword and have a macro substitute your own. I think this is the right call, even if it’s a rough transition for those used to that ability. Besides, Zig has half a century of programmers figuring out what is right, so its choices are far more sane.

Conclusion

When reading Zig code to understand the end behavior, taking comptime into consideration isn’t necessary. When writing comptime code, I usually think about it in one of the forms of partial evaluation. However, if you know how you’d solve a problem using a different methodology of metaprogramming, it’s very likely you’ll be able to translate it into comptime.

Realizing the textual code generation conversion exists is why I’m all in on Zig’s style of comptime metaprogramming. On one hand, textual code generation is maximally powerful, on the other hand, the ability to ignore comptime while reading and debugging is maximally simple. This is why, as I titled the post, Zig’s comptime is bonkers good.

Further Reading

Zig is not a one trick pony built upon comptime. You can learn more about Zig at the official website.

I used the same example multiple times in this post to simplify showing the different conversions that take place. The downside of this is despite talking a lot about comptime, I haven’t actually shown much of it. The language reference goes over the specific features of comptime.

If you want to see more examples, I recommend just reading some of Zig’s standard library. A few links for the curious:

The formatting function used by std.debug.print in the examples is a powerful generic function. Lots of languages parse their format strings at runtime, and possibly add some special validators to the string format to catch errors early. In Zig, the format string is parsed at comptime, creating efficient output code while also performing all validation at compile time.

ArrayList is a fairly simple but fully featured generic container.

Zig main functions can have one of several different return types. In typical Zig fashion, this isn’t some magical implementation in the compiler, but just some typical comptime code.

If you wish to reach me about this post to provide a comment or correction, please email me at [email protected]

總結
The article discusses the concept of metaprogramming, particularly in the context of the Zig programming language, which treats code as data. The author shares their initial struggles with Zig's 'comptime' feature but eventually finds it rewarding. They present six different perspectives on how to understand and utilize comptime, emphasizing that this is not a comprehensive guide but rather a way to broaden one's understanding. One key point is that metaprogramming can complicate code readability, often leading to 'write-only code.' However, Zig allows for a more straightforward approach, enabling developers to mix runtime and comptime seamlessly. The article includes examples demonstrating how to sum values in an array and fields in a struct using comptime, showcasing its practical applications. The author encourages readers to explore these concepts to enhance their programming skills and understanding of Zig's capabilities.