comptime

Zig places importance on the concept of whether an expression is known at compile-time. There are a few different places this concept is used, and these building blocks are used to keep the language small, readable, and powerful.

Introducing the Compile-Time Concept

Compile-Time Parameters

Compile-time parameters is how Zig implements generics. It is compile-time duck typing.

  1. fn max(comptime T: type, a: T, b: T) T {
  2. return if (a > b) a else b;
  3. }
  4. fn gimmeTheBiggerFloat(a: f32, b: f32) f32 {
  5. return max(f32, a, b);
  6. }
  7. fn gimmeTheBiggerInteger(a: u64, b: u64) u64 {
  8. return max(u64, a, b);
  9. }

In Zig, types are first-class citizens. They can be assigned to variables, passed as parameters to functions, and returned from functions. However, they can only be used in expressions which are known at compile-time, which is why the parameter T in the above snippet must be marked with comptime.

A comptime parameter means that:

  • At the callsite, the value must be known at compile-time, or it is a compile error.
  • In the function definition, the value is known at compile-time.

For example, if we were to introduce another function to the above snippet:

test.zig

  1. fn max(comptime T: type, a: T, b: T) T {
  2. return if (a > b) a else b;
  3. }
  4. test "try to pass a runtime type" {
  5. foo(false);
  6. }
  7. fn foo(condition: bool) void {
  8. const result = max(
  9. if (condition) f32 else u64,
  10. 1234,
  11. 5678);
  12. }
  1. $ zig test test.zig
  2. ./docgen_tmp/test.zig:9:9: error: values of type 'type' must be comptime known
  3. if (condition) f32 else u64,
  4. ^

This is an error because the programmer attempted to pass a value only known at run-time to a function which expects a value known at compile-time.

Another way to get an error is if we pass a type that violates the type checker when the function is analyzed. This is what it means to have compile-time duck typing.

For example:

test.zig

  1. fn max(comptime T: type, a: T, b: T) T {
  2. return if (a > b) a else b;
  3. }
  4. test "try to compare bools" {
  5. _ = max(bool, true, false);
  6. }
  1. $ zig test test.zig
  2. ./docgen_tmp/test.zig:2:18: error: operator not allowed for type 'bool'
  3. return if (a > b) a else b;
  4. ^
  5. ./docgen_tmp/test.zig:5:12: note: called from here
  6. _ = max(bool, true, false);
  7. ^
  8. ./docgen_tmp/test.zig:4:29: note: called from here
  9. test "try to compare bools" {
  10. ^

On the flip side, inside the function definition with the comptime parameter, the value is known at compile-time. This means that we actually could make this work for the bool type if we wanted to:

test.zig

  1. fn max(comptime T: type, a: T, b: T) T {
  2. if (T == bool) {
  3. return a or b;
  4. } else if (a > b) {
  5. return a;
  6. } else {
  7. return b;
  8. }
  9. }
  10. test "try to compare bools" {
  11. try @import("std").testing.expect(max(bool, false, true) == true);
  12. }
  1. $ zig test test.zig
  2. Test [1/1] test "try to compare bools"...
  3. All 1 tests passed.

This works because Zig implicitly inlines if expressions when the condition is known at compile-time, and the compiler guarantees that it will skip analysis of the branch not taken.

This means that the actual function generated for max in this situation looks like this:

  1. fn max(a: bool, b: bool) bool {
  2. return a or b;
  3. }

All the code that dealt with compile-time known values is eliminated and we are left with only the necessary run-time code to accomplish the task.

This works the same way for switch expressions - they are implicitly inlined when the target expression is compile-time known.

Compile-Time Variables

In Zig, the programmer can label variables as comptime. This guarantees to the compiler that every load and store of the variable is performed at compile-time. Any violation of this results in a compile error.

This combined with the fact that we can inline loops allows us to write a function which is partially evaluated at compile-time and partially at run-time.

For example:

comptime_vars.zig

  1. const expect = @import("std").testing.expect;
  2. const CmdFn = struct {
  3. name: []const u8,
  4. func: fn(i32) i32,
  5. };
  6. const cmd_fns = [_]CmdFn{
  7. CmdFn {.name = "one", .func = one},
  8. CmdFn {.name = "two", .func = two},
  9. CmdFn {.name = "three", .func = three},
  10. };
  11. fn one(value: i32) i32 { return value + 1; }
  12. fn two(value: i32) i32 { return value + 2; }
  13. fn three(value: i32) i32 { return value + 3; }
  14. fn performFn(comptime prefix_char: u8, start_value: i32) i32 {
  15. var result: i32 = start_value;
  16. comptime var i = 0;
  17. inline while (i < cmd_fns.len) : (i += 1) {
  18. if (cmd_fns[i].name[0] == prefix_char) {
  19. result = cmd_fns[i].func(result);
  20. }
  21. }
  22. return result;
  23. }
  24. test "perform fn" {
  25. try expect(performFn('t', 1) == 6);
  26. try expect(performFn('o', 0) == 1);
  27. try expect(performFn('w', 99) == 99);
  28. }
  1. $ zig test comptime_vars.zig
  2. Test [1/1] test "perform fn"...
  3. All 1 tests passed.

This example is a bit contrived, because the compile-time evaluation component is unnecessary; this code would work fine if it was all done at run-time. But it does end up generating different code. In this example, the function performFn is generated three different times, for the different values of prefix_char provided:

  1. // From the line:
  2. // expect(performFn('t', 1) == 6);
  3. fn performFn(start_value: i32) i32 {
  4. var result: i32 = start_value;
  5. result = two(result);
  6. result = three(result);
  7. return result;
  8. }
  1. // From the line:
  2. // expect(performFn('o', 0) == 1);
  3. fn performFn(start_value: i32) i32 {
  4. var result: i32 = start_value;
  5. result = one(result);
  6. return result;
  7. }
  1. // From the line:
  2. // expect(performFn('w', 99) == 99);
  3. fn performFn(start_value: i32) i32 {
  4. var result: i32 = start_value;
  5. return result;
  6. }

Note that this happens even in a debug build; in a release build these generated functions still pass through rigorous LLVM optimizations. The important thing to note, however, is not that this is a way to write more optimized code, but that it is a way to make sure that what should happen at compile-time, does happen at compile-time. This catches more errors and as demonstrated later in this article, allows expressiveness that in other languages requires using macros, generated code, or a preprocessor to accomplish.

Compile-Time Expressions

In Zig, it matters whether a given expression is known at compile-time or run-time. A programmer can use a comptime expression to guarantee that the expression will be evaluated at compile-time. If this cannot be accomplished, the compiler will emit an error. For example:

test.zig

  1. extern fn exit() noreturn;
  2. test "foo" {
  3. comptime {
  4. exit();
  5. }
  6. }
  1. $ zig test test.zig
  2. ./docgen_tmp/test.zig:5:9: error: unable to evaluate constant expression
  3. exit();
  4. ^
  5. ./docgen_tmp/test.zig:5:13: note: referenced here
  6. exit();
  7. ^

It doesn’t make sense that a program could call exit() (or any other external function) at compile-time, so this is a compile error. However, a comptime expression does much more than sometimes cause a compile error.

Within a comptime expression:

  • All variables are comptime variables.
  • All if, while, for, and switch expressions are evaluated at compile-time, or emit a compile error if this is not possible.
  • All function calls cause the compiler to interpret the function at compile-time, emitting a compile error if the function tries to do something that has global run-time side effects.

This means that a programmer can create a function which is called both at compile-time and run-time, with no modification to the function required.

Let’s look at an example:

test.zig

  1. const expect = @import("std").testing.expect;
  2. fn fibonacci(index: u32) u32 {
  3. if (index < 2) return index;
  4. return fibonacci(index - 1) + fibonacci(index - 2);
  5. }
  6. test "fibonacci" {
  7. // test fibonacci at run-time
  8. try expect(fibonacci(7) == 13);
  9. // test fibonacci at compile-time
  10. comptime {
  11. try expect(fibonacci(7) == 13);
  12. }
  13. }
  1. $ zig test test.zig
  2. Test [1/1] test "fibonacci"...
  3. All 1 tests passed.

Imagine if we had forgotten the base case of the recursive function and tried to run the tests:

test.zig

  1. const expect = @import("std").testing.expect;
  2. fn fibonacci(index: u32) u32 {
  3. //if (index < 2) return index;
  4. return fibonacci(index - 1) + fibonacci(index - 2);
  5. }
  6. test "fibonacci" {
  7. comptime {
  8. try expect(fibonacci(7) == 13);
  9. }
  10. }
  1. $ zig test test.zig
  2. ./docgen_tmp/test.zig:5:28: error: operation caused overflow
  3. return fibonacci(index - 1) + fibonacci(index - 2);
  4. ^
  5. ./docgen_tmp/test.zig:5:21: note: called from here
  6. return fibonacci(index - 1) + fibonacci(index - 2);
  7. ^
  8. ./docgen_tmp/test.zig:5:21: note: called from here
  9. return fibonacci(index - 1) + fibonacci(index - 2);
  10. ^
  11. ./docgen_tmp/test.zig:5:21: note: called from here
  12. return fibonacci(index - 1) + fibonacci(index - 2);
  13. ^
  14. ./docgen_tmp/test.zig:5:21: note: called from here
  15. return fibonacci(index - 1) + fibonacci(index - 2);
  16. ^
  17. ./docgen_tmp/test.zig:5:21: note: called from here
  18. return fibonacci(index - 1) + fibonacci(index - 2);
  19. ^
  20. ./docgen_tmp/test.zig:5:21: note: called from here
  21. return fibonacci(index - 1) + fibonacci(index - 2);
  22. ^
  23. ./docgen_tmp/test.zig:5:21: note: called from here
  24. return fibonacci(index - 1) + fibonacci(index - 2);
  25. ^
  26. ./docgen_tmp/test.zig:10:29: note: called from here
  27. try expect(fibonacci(7) == 13);
  28. ^
  29. ./docgen_tmp/test.zig:8:18: note: called from here
  30. test "fibonacci" {
  31. ^
  32. ./docgen_tmp/test.zig:5:21: note: referenced here
  33. return fibonacci(index - 1) + fibonacci(index - 2);
  34. ^
  35. ./docgen_tmp/test.zig:10:29: note: referenced here
  36. try expect(fibonacci(7) == 13);
  37. ^

The compiler produces an error which is a stack trace from trying to evaluate the function at compile-time.

Luckily, we used an unsigned integer, and so when we tried to subtract 1 from 0, it triggered undefined behavior, which is always a compile error if the compiler knows it happened. But what would have happened if we used a signed integer?

test.zig

  1. const expect = @import("std").testing.expect;
  2. fn fibonacci(index: i32) i32 {
  3. //if (index < 2) return index;
  4. return fibonacci(index - 1) + fibonacci(index - 2);
  5. }
  6. test "fibonacci" {
  7. comptime {
  8. try expect(fibonacci(7) == 13);
  9. }
  10. }
  1. $ zig test test.zig
  2. ./docgen_tmp/test.zig:5:21: error: evaluation exceeded 1000 backwards branches
  3. return fibonacci(index - 1) + fibonacci(index - 2);
  4. ^
  5. ./docgen_tmp/test.zig:5:21: note: called from here
  6. return fibonacci(index - 1) + fibonacci(index - 2);
  7. ^
  8. ./docgen_tmp/test.zig:5:21: note: called from here
  9. return fibonacci(index - 1) + fibonacci(index - 2);
  10. ^
  11. ./docgen_tmp/test.zig:5:21: note: called from here
  12. return fibonacci(index - 1) + fibonacci(index - 2);
  13. ^
  14. ./docgen_tmp/test.zig:5:21: note: called from here
  15. return fibonacci(index - 1) + fibonacci(index - 2);
  16. ^
  17. ./docgen_tmp/test.zig:5:21: note: called from here
  18. return fibonacci(index - 1) + fibonacci(index - 2);
  19. ^
  20. ./docgen_tmp/test.zig:5:21: note: called from here
  21. return fibonacci(index - 1) + fibonacci(index - 2);
  22. ^
  23. ./docgen_tmp/test.zig:5:21: note: called from here
  24. return fibonacci(index - 1) + fibonacci(index - 2);
  25. ^
  26. ./docgen_tmp/test.zig:5:21: note: called from here
  27. return fibonacci(index - 1) + fibonacci(index - 2);
  28. ^
  29. ./docgen_tmp/test.zig:5:21: note: called from here
  30. return fibonacci(index - 1) + fibonacci(index - 2);
  31. ^
  32. ./docgen_tmp/test.zig:5:21: note: called from here
  33. return fibonacci(index - 1) + fibonacci(index - 2);
  34. ^
  35. ./docgen_tmp/test.zig:5:21: note: called from here
  36. return fibonacci(index - 1) + fibonacci(index - 2);
  37. ^
  38. ./docgen_tmp/test.zig:10:29: note: referenced here
  39. try expect(fibonacci(7) == 13);
  40. ^

The compiler noticed that evaluating this function at compile-time took a long time, and thus emitted a compile error and gave up. If the programmer wants to increase the budget for compile-time computation, they can use a built-in function called @setEvalBranchQuota to change the default number 1000 to something else.

What if we fix the base case, but put the wrong value in the expect line?

test.zig

  1. const expect = @import("std").testing.expect;
  2. fn fibonacci(index: i32) i32 {
  3. if (index < 2) return index;
  4. return fibonacci(index - 1) + fibonacci(index - 2);
  5. }
  6. test "fibonacci" {
  7. comptime {
  8. try expect(fibonacci(7) == 99999);
  9. }
  10. }
  1. $ zig test test.zig
  2. Test [1/1] test "fibonacci"...
  3. Test [2/1]
  4. test "fibonacci"... FAIL (TestUnexpectedResult)
  5. 0 passed; 0 skipped; 1 failed.
  6. error: the following test command failed with exit code 1:
  7. docgen_tmp/zig-cache/o/333a8741816702dafd0ff99af380ebb1/test /home/andy/Downloads/zig/build-release/zig

What happened is Zig started interpreting the expect function with the parameter ok set to false. When the interpreter hit @panic it emitted a compile error because a panic during compile causes a compile error if it is detected at compile-time.

At container level (outside of any function), all expressions are implicitly comptime expressions. This means that we can use functions to initialize complex static data. For example:

test.zig

  1. const first_25_primes = firstNPrimes(25);
  2. const sum_of_first_25_primes = sum(&first_25_primes);
  3. fn firstNPrimes(comptime n: usize) [n]i32 {
  4. var prime_list: [n]i32 = undefined;
  5. var next_index: usize = 0;
  6. var test_number: i32 = 2;
  7. while (next_index < prime_list.len) : (test_number += 1) {
  8. var test_prime_index: usize = 0;
  9. var is_prime = true;
  10. while (test_prime_index < next_index) : (test_prime_index += 1) {
  11. if (test_number % prime_list[test_prime_index] == 0) {
  12. is_prime = false;
  13. break;
  14. }
  15. }
  16. if (is_prime) {
  17. prime_list[next_index] = test_number;
  18. next_index += 1;
  19. }
  20. }
  21. return prime_list;
  22. }
  23. fn sum(numbers: []const i32) i32 {
  24. var result: i32 = 0;
  25. for (numbers) |x| {
  26. result += x;
  27. }
  28. return result;
  29. }
  30. test "variable values" {
  31. try @import("std").testing.expect(sum_of_first_25_primes == 1060);
  32. }
  1. $ zig test test.zig
  2. Test [1/1] test "variable values"...
  3. All 1 tests passed.

When we compile this program, Zig generates the constants with the answer pre-computed. Here are the lines from the generated LLVM IR:

  1. @0 = internal unnamed_addr constant [25 x i32] [i32 2, i32 3, i32 5, i32 7, i32 11, i32 13, i32 17, i32 19, i32 23, i32 29, i32 31, i32 37, i32 41, i32 43, i32 47, i32 53, i32 59, i32 61, i32 67, i32 71, i32 73, i32 79, i32 83, i32 89, i32 97]
  2. @1 = internal unnamed_addr constant i32 1060

Note that we did not have to do anything special with the syntax of these functions. For example, we could call the sum function as is with a slice of numbers whose length and values were only known at run-time.

Generic Data Structures

Zig uses these capabilities to implement generic data structures without introducing any special-case syntax. If you followed along so far, you may already know how to create a generic data structure.

Here is an example of a generic List data structure, that we will instantiate with the type i32. In Zig we refer to the type as List(i32).

  1. fn List(comptime T: type) type {
  2. return struct {
  3. items: []T,
  4. len: usize,
  5. };
  6. }

That’s it. It’s a function that returns an anonymous struct. For the purposes of error messages and debugging, Zig infers the name "List(i32)" from the function name and parameters invoked when creating the anonymous struct.

To keep the language small and uniform, all aggregate types in Zig are anonymous. To give a type a name, we assign it to a constant:

  1. const Node = struct {
  2. next: *Node,
  3. name: []u8,
  4. };

This works because all top level declarations are order-independent, and as long as there isn’t an actual infinite regression, values can refer to themselves, directly or indirectly. In this case, Node refers to itself as a pointer, which is not actually an infinite regression, so it works fine.

Case Study: printf in Zig

Putting all of this together, let’s see how printf works in Zig.

printf.zig

  1. const print = @import("std").debug.print;
  2. const a_number: i32 = 1234;
  3. const a_string = "foobar";
  4. pub fn main() void {
  5. print("here is a string: '{s}' here is a number: {}\n", .{a_string, a_number});
  6. }
  1. $ zig build-exe printf.zig
  2. $ ./printf
  3. here is a string: 'foobar' here is a number: 1234

Let’s crack open the implementation of this and see how it works:

  1. /// Calls print and then flushes the buffer.
  2. pub fn printf(self: *Writer, comptime format: []const u8, args: anytype) anyerror!void {
  3. const State = enum {
  4. start,
  5. open_brace,
  6. close_brace,
  7. };
  8. comptime var start_index: usize = 0;
  9. comptime var state = State.start;
  10. comptime var next_arg: usize = 0;
  11. inline for (format) |c, i| {
  12. switch (state) {
  13. State.start => switch (c) {
  14. '{' => {
  15. if (start_index < i) try self.write(format[start_index..i]);
  16. state = State.open_brace;
  17. },
  18. '}' => {
  19. if (start_index < i) try self.write(format[start_index..i]);
  20. state = State.close_brace;
  21. },
  22. else => {},
  23. },
  24. State.open_brace => switch (c) {
  25. '{' => {
  26. state = State.start;
  27. start_index = i;
  28. },
  29. '}' => {
  30. try self.printValue(args[next_arg]);
  31. next_arg += 1;
  32. state = State.start;
  33. start_index = i + 1;
  34. },
  35. else => @compileError("Unknown format character: " ++ c),
  36. },
  37. State.close_brace => switch (c) {
  38. '}' => {
  39. state = State.start;
  40. start_index = i;
  41. },
  42. else => @compileError("Single '}' encountered in format string"),
  43. },
  44. }
  45. }
  46. comptime {
  47. if (args.len != next_arg) {
  48. @compileError("Unused arguments");
  49. }
  50. if (state != State.Start) {
  51. @compileError("Incomplete format string: " ++ format);
  52. }
  53. }
  54. if (start_index < format.len) {
  55. try self.write(format[start_index..format.len]);
  56. }
  57. try self.flush();
  58. }

This is a proof of concept implementation; the actual function in the standard library has more formatting capabilities.

Note that this is not hard-coded into the Zig compiler; this is userland code in the standard library.

When this function is analyzed from our example code above, Zig partially evaluates the function and emits a function that actually looks like this:

  1. pub fn printf(self: *Writer, arg0: i32, arg1: []const u8) !void {
  2. try self.write("here is a string: '");
  3. try self.printValue(arg0);
  4. try self.write("' here is a number: ");
  5. try self.printValue(arg1);
  6. try self.write("\n");
  7. try self.flush();
  8. }

printValue is a function that takes a parameter of any type, and does different things depending on the type:

  1. pub fn printValue(self: *Writer, value: anytype) !void {
  2. switch (@typeInfo(@TypeOf(value))) {
  3. .Int => {
  4. return self.printInt(T, value);
  5. },
  6. .Float => {
  7. return self.printFloat(T, value);
  8. },
  9. else => {
  10. @compileError("Unable to print type '" ++ @typeName(T) ++ "'");
  11. },
  12. }
  13. }

And now, what happens if we give too many arguments to printf?

test.zig

  1. const print = @import("std").debug.print;
  2. const a_number: i32 = 1234;
  3. const a_string = "foobar";
  4. test "printf too many arguments" {
  5. print("here is a string: '{s}' here is a number: {}\n", .{
  6. a_string,
  7. a_number,
  8. a_number,
  9. });
  10. }
  1. $ zig test test.zig
  2. ./lib/std/fmt.zig:362:9: error: Unused arguments
  3. @compileError("Unused arguments");
  4. ^
  5. ./lib/std/io/writer.zig:34:34: note: called from here
  6. return std.fmt.format(self, format, args);
  7. ^
  8. ./lib/std/debug.zig:68:27: note: called from here
  9. nosuspend stderr.print(fmt, args) catch return;
  10. ^
  11. ./docgen_tmp/test.zig:7:10: note: called from here
  12. print("here is a string: '{s}' here is a number: {}\n", .{
  13. ^
  14. ./docgen_tmp/test.zig:6:34: note: called from here
  15. test "printf too many arguments" {
  16. ^
  17. ./lib/std/io/writer.zig:34:34: error: expected type 'std.os.WriteError!void', found '@typeInfo(@typeInfo(@TypeOf(std.fmt.format)).Fn.return_type.?).ErrorUnion.error_set!void'
  18. return std.fmt.format(self, format, args);
  19. ^
  20. ./lib/std/debug.zig:68:27: note: called from here
  21. nosuspend stderr.print(fmt, args) catch return;
  22. ^
  23. ./docgen_tmp/test.zig:7:10: note: called from here
  24. print("here is a string: '{s}' here is a number: {}\n", .{
  25. ^
  26. ./docgen_tmp/test.zig:6:34: note: called from here
  27. test "printf too many arguments" {
  28. ^
  29. ./lib/std/io/writer.zig:34:34: note: error set '@typeInfo(@typeInfo(@TypeOf(std.fmt.format)).Fn.return_type.?).ErrorUnion.error_set' cannot cast into error set 'std.os.WriteError'
  30. return std.fmt.format(self, format, args);
  31. ^

Zig gives programmers the tools needed to protect themselves against their own mistakes.

Zig doesn’t care whether the format argument is a string literal, only that it is a compile-time known value that can be coerced to a []const u8:

printf.zig

  1. const print = @import("std").debug.print;
  2. const a_number: i32 = 1234;
  3. const a_string = "foobar";
  4. const fmt = "here is a string: '{s}' here is a number: {}\n";
  5. pub fn main() void {
  6. print(fmt, .{a_string, a_number});
  7. }
  1. $ zig build-exe printf.zig
  2. $ ./printf
  3. here is a string: 'foobar' here is a number: 1234

This works fine.

Zig does not special case string formatting in the compiler and instead exposes enough power to accomplish this task in userland. It does so without introducing another language on top of Zig, such as a macro language or a preprocessor language. It’s Zig all the way down.

See also: