Comptime en Zig: generics y metaprogramación sin macros en tiempo de compilación

Una de las decisiones más llamativas de Zig es no tener macros, no tener templates de C++ ni generics de estilo Java. En su lugar tiene comptime: una palabra clave que permite ejecutar código Zig real durante la compilación. No es un lenguaje de macros separado ni una sintaxis especial; es el mismo Zig que ya sabes escribir, ejecutándose en tiempo de compilación.

Qué significa comptime

comptime le dice al compilador «esto tiene que ser conocido en tiempo de compilación». Puedes aplicarlo a variables, parámetros de función y bloques de código enteros. Cuando el compilador ve un comptime, ejecuta esa parte del código durante la compilación y usa el resultado como si fuera un literal.

const std = @import("std");

pub fn main() void {
    // Se calcula en tiempo de compilación, no en tiempo de ejecución
    comptime var tabla: [10]u32 = undefined;
    comptime {
        for (&tabla, 0..) |*v, i| {
            v.* = @intCast(i * i);
        }
    }

    for (tabla) |v| {
        std.debug.print("{d} ", .{v});
    }
}

El bucle que rellena tabla no existe en el binario final. El compilador lo ejecuta y embebe los valores directamente en el código objeto como si hubieran sido escritos a mano.

Generics con comptime

En C++ los templates son una sintaxis aparte, con sus propios errores crípticos y su propio modelo mental. En Zig los generics son funciones que reciben un tipo como argumento comptime y devuelven un tipo nuevo. El tipo type es un valor de primera clase en tiempo de compilación.

const std = @import("std");

// Función genérica: recibe un tipo, devuelve un tipo
fn Pila(comptime T: type) type {
    return struct {
        items: []T,
        len: usize,
        allocator: std.mem.Allocator,

        const Self = @This();

        pub fn init(allocator: std.mem.Allocator, capacidad: usize) !Self {
            return Self{
                .items = try allocator.alloc(T, capacidad),
                .len = 0,
                .allocator = allocator,
            };
        }

        pub fn push(self: *Self, valor: T) !void {
            if (self.len >= self.items.len) return error.PilaLlena;
            self.items[self.len] = valor;
            self.len += 1;
        }

        pub fn pop(self: *Self) ?T {
            if (self.len == 0) return null;
            self.len -= 1;
            return self.items[self.len];
        }

        pub fn deinit(self: *Self) void {
            self.allocator.free(self.items);
        }
    };
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();

    // Pila de enteros
    var pila_i32 = try Pila(i32).init(alloc, 10);
    defer pila_i32.deinit();
    try pila_i32.push(42);
    try pila_i32.push(17);
    std.debug.print("pop: {?d}n", .{pila_i32.pop()});

    // Pila de flotantes, mismo código
    var pila_f64 = try Pila(f64).init(alloc, 5);
    defer pila_f64.deinit();
    try pila_f64.push(3.14);
}

La función Pila se comporta exactamente como un template de C++, pero sin ninguna sintaxis especial. El compilador instancia una versión del struct para cada tipo concreto que uses.

Introspección de tipos en tiempo de compilación

Zig permite inspeccionar los tipos en tiempo de compilación con @typeInfo. Esto abre la puerta a patrones que en C++ requerirían template metaprogramming avanzado y en Rust macros procedurales.

const std = @import("std");

fn imprimir(valor: anytype) void {
    const T = @TypeOf(valor);
    const info = @typeInfo(T);

    switch (info) {
        .Int => std.debug.print("entero: {d}n", .{valor}),
        .Float => std.debug.print("flotante: {d}n", .{valor}),
        .Bool => std.debug.print("booleano: {}n", .{valor}),
        .Pointer => |ptr_info| {
            if (ptr_info.child == u8) {
                std.debug.print("string: {s}n", .{valor});
            } else {
                std.debug.print("puntero a otro tipon", .{});
            }
        },
        else => std.debug.print("tipo desconocidon", .{}),
    }
}

pub fn main() void {
    imprimir(42);
    imprimir(3.14);
    imprimir(true);
    imprimir("hola");
}

El parámetro anytype indica que el tipo se infiere en tiempo de compilación. El switch sobre @typeInfo se evalúa también en compilación; las ramas que no aplican no generan código.

Bloques comptime y constantes complejas

Un bloque comptime { ... } puede hacer cálculos arbitrarios: bucles, condicionales, llamadas a funciones. El resultado puede ser una constante del programa, un tipo, o incluso código generado.

// Calcular la tabla de senos en tiempo de compilación
const std = @import("std");
const math = std.math;

const TABLA_SENOS = blk: {
    const N = 360;
    var tabla: [N]f64 = undefined;
    for (&tabla, 0..) |*v, i| {
        const angulo = @as(f64, @floatFromInt(i)) * math.pi / 180.0;
        v.* = @sin(angulo);
    }
    break :blk tabla;
};

pub fn main() void {
    std.debug.print("sin(90°) = {d:.4}n", .{TABLA_SENOS[90]});
}

Esta tabla se calcula una sola vez durante la compilación y queda embebida como datos en el binario. En tiempo de ejecución es solo una lectura de memoria.

Comptime vs. macros de C vs. templates de C++

Característica

Macros C

Templates C++

Comptime Zig

Lenguaje

Preprocesador

Metalenguaje C++

Zig normal

Type safety

Ninguno

Parcial

Total

Errores de compilación

Confusos

Muy confusos

Claros

Debugging

Imposible

Difícil

Normal

Curva de aprendizaje

Baja

Alta

Media

Comptime en la biblioteca estándar

La propia biblioteca estándar de Zig usa comptime extensivamente. std.ArrayList, std.HashMap, std.fmt.print son todos genéricos implementados con el mismo mecanismo. El formatter de std.debug.print, por ejemplo, parsea la cadena de formato en tiempo de compilación y verifica que los tipos de los argumentos son correctos, algo que en C es imposible sin extensiones del compilador.

Si vienes del mundo de Rust y quieres ver cómo se compara comptime con los generics y los traits, el artículo sobre ownership en Rust da perspectiva sobre los dos sistemas. Y si quieres ver cómo comptime encaja con el sistema de build, en el artículo sobre zig build de esta serie verás que el propio build.zig es código Zig que se ejecuta en tiempo de compilación.

Imagen: Pexels / Markus Spiske

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP