zig-introduccion-comptime

Comptime in Zig: Running Code During Compilation

  • 4 min

comptime is the ability to run Zig code during compilation. In most compiled languages, there is a clear barrier between “compile time” and “run time”; Zig allows crossing it in a controlled manner.

  • Compilation: Translates your code to binary. (Happens on your PC).
  • Execution: The CPU processes the data. (Happens on the user’s PC).

In C++, metaprogramming typically relies on templates, and in Rust, on macros. Both mechanisms have rules different from those of ordinary code.

In Zig, the metaprogramming language is Zig itself.

Zig allows running code during compilation using comptime. We can precompute data, validate types, and generate code without introducing another language within the language.

The comptime Keyword

When we mark a variable or a block with comptime, we are telling the compiler: “Calculate this NOW and save only the final result in the binary”.

Let’s see a classic example: calculating a factorial.

const std = @import("std");

fn factorial(n: u64) u64 {
    if (n == 0) return 1;
    return n * factorial(n - 1);
}

fn compare(runtime_n: u64) void {
    // The argument is only known when the program runs.
    const x = factorial(runtime_n);

    // The compiler must evaluate this call and obtains 120.
    const y = comptime factorial(5);

    std.debug.print("runtime={d}, comptime={d}\n", .{ x, y });
}
Copied!

The same function works in both contexts. When all data is known at compile time, the calculation does not have to be repeated during execution.

Table Generation

In game development, embedded systems, or cryptography, we often use precomputed tables (e.g., a sine/cosine table or CRC values) to avoid slow mathematical calculations in real-time.

In C, you would have to write a Python script that generates a .c file with a giant array. In Zig, you do it in the same file.

const std = @import("std");

// Function that generates an array of squares
fn generateTable(comptime N: usize) [N]u32 {
    var table: [N]u32 = undefined;
    for (0..N) |i| {
        table[i] = @intCast(i * i);
    }
    return table;
}

// The initializer of this global constant is evaluated at compile time.
const squares = generateTable(10);

pub fn main() void {
    // Instant access, no calculation
    std.debug.print("5 squared is {d}\n", .{squares[5]});
}
Copied!

Loop Unrolling with inline for

An inline loop is unrolled and makes its captures known at compile time.

Zig offers us inline for (and inline while).

const items = [_]u8{ 10, 20, 30 };

pub fn main() void {
    // The compiler "copies and pastes" the loop body for each element
    inline for (items) |item| {
        criticalFunction(item);
    }
}
Copied!

The code generated by the compiler will be equivalent to writing it manually:

criticalFunction(10);
criticalFunction(20);
criticalFunction(30);
Copied!

This is necessary when iterating over types or type metadata. To use it solely as an optimization, it’s advisable to have a measurement that demonstrates the improvement, because unrolling can also increase the binary size.

Conditional Compilation

In C, we use the preprocessor to compile different code depending on the operating system:

#ifdef _WIN32
  // Windows code
#else
  // Linux code
#endif
Copied!

In Zig, we use a normal if whose condition is known at comptime. Only the chosen branch is semantically analyzed, which allows including code specific to each target.

const builtin = @import("builtin");

fn clearScreen() void {
    if (builtin.target.os.tag == .windows) {
        // This code only compiles if we are on Windows
        callWindowsApi();
    } else {
        // This code only compiles on Linux/Mac
        std.debug.print("\x1b[2J", .{});
    }
}
Copied!

This makes cross-platform code much easier to read and maintain, as it follows the same syntax as the rest of the program.

comptime_int: Arbitrary Precision Integers

A curiosity of Zig is that, during compile time, integers have no size limit (they are arbitrary precision).

pub fn main() void {
    // This number does not fit in a u64
    const huge = comptime 1000000000000000000000000 * 500;
    
    // But if the final result fits in the destination type, it works.
    const result: u128 = huge; 
}
Copied!

This allows performing very complex constant mathematical calculations without worrying about intermediate overflows, as long as the final result fits in the runtime type.