zig-reflexion-basica

Reflexión en Zig con @typeInfo e inline for en comptime

  • 4 min

La reflexión en Zig es la capacidad de inspeccionar tipos durante la compilación. En otros lenguajes, reflexionar suele significar que un programa se examina a sí mismo en runtime; en Zig ocurre antes de generar el binario.

En lenguajes dinámicos esta inspección suele ocurrir durante la ejecución. Otros lenguajes estáticos recurren a metadatos, plantillas o generación de código.

En Zig, la reflexión usa información disponible durante la compilación y no necesita conservar metadatos de campos para consultarlos en tiempo de ejecución.

El compilador analiza el tipo y genera código específico para cada caso. Esto evita búsquedas dinámicas, aunque el código generado sí ocupa espacio en el binario.

Inspeccionar un tipo con @typeInfo

Todo empieza con la función incorporada @typeInfo(T).

Si le pasas un tipo T, te devuelve una Tagged Union que describe con todo detalle qué es ese tipo. ¿Es un entero? ¿Es una función? ¿Es un struct? Si es un struct, ¿qué campos tiene?

const std = @import("std");

const Usuario = struct {
    id: u32,
    nombre: []const u8,
    admin: bool,
};

pub fn main() void {
    // Obtenemos la información del tipo
    const info = @typeInfo(Usuario);

    // Comprobamos qué es
    switch (info) {
        .@"struct" => |s| {
            std.debug.print("Es un struct con {d} campos.\n", .{s.fields.len});
        },
        .int => std.debug.print("Es un entero.\n", .{}),
        else => std.debug.print("Es otra cosa.\n", .{}),
    }
}
Copied!

Iterando campos con inline for

Supongamos que queremos escribir una función imprimirTodo que reciba cualquier struct e imprima el nombre de sus campos y sus valores.

No podemos usar un for normal, porque los nombres de los campos ("id", "nombre") son conocidos solo al compilar. Necesitamos inline for.

Además, necesitamos una forma de acceder al valor de un campo usando su nombre como string. Para eso existe @field(objeto, "nombre").

Vamos a construirlo paso a paso:

fn imprimirStruct(objeto: anytype) void {
    // 1. Obtenemos el tipo del objeto
    const T = @TypeOf(objeto);
    
    // 2. Obtenemos la info del tipo
    const info = @typeInfo(T);
    
    // 3. Validamos que sea un struct (en tiempo de compilación)
    if (info != .@"struct") {
        @compileError("imprimirStruct solo acepta structs");
    }

    std.debug.print("Struct: {s}\n", .{@typeName(T)});

    // 4. Iteramos sobre los campos (DESENROLLADO DE BUCLE)
    inline for (info.@"struct".fields) |campo| {
        // 'campo' contiene metadatos: nombre, tipo, offset...
        
        // Accedemos al valor en tiempo de ejecución
        const valor = @field(objeto, campo.name);
        
        std.debug.print("  - {s}: {any}\n", .{ campo.name, valor });
    }
}

pub fn main() void {
    const u = Usuario{ .id = 1, .nombre = "Luis", .admin = true };
    
    // ¡Funciona automáticamente!
    imprimirStruct(u);
}
Copied!

¿Qué ha hecho el compilador?

De forma conceptual, el inline for produce para Usuario un resultado equivalente a este código:

// Código equivalente tras especializar la función:
std.debug.print("Struct: Usuario\n", .{});
std.debug.print("  - id: {any}\n", .{ u.id });
std.debug.print("  - nombre: {any}\n", .{ u.nombre });
std.debug.print("  - admin: {any}\n", .{ u.admin });
Copied!

No hace falta recorrer nombres de campos durante la ejecución: cada acceso queda resuelto al compilar.

Ejemplo: serializador JSON simplificado

La reflexión permite construir serializadores para structs sin escribir a mano el acceso a cada campo.

Vamos a hacer una versión simplificada para entender la idea:

fn aJson(objeto: anytype) void {
    const T = @TypeOf(objeto);
    const info = @typeInfo(T);

    std.debug.print("{{", .{}); // Abrimos JSON {

    inline for (info.@"struct".fields, 0..) |campo, i| {
        const valor = @field(objeto, campo.name);
        
        // Ponemos coma si no es el primero
        if (i > 0) std.debug.print(", ", .{});
        
        // Imprimimos "clave": valor
        std.debug.print("\"{s}\": ", .{campo.name});
        
        // Aquí deberíamos detectar si 'valor' es string o número
        // Para simplificar, usamos {any}
        if (campo.type == []const u8) {
             std.debug.print("\"{s}\"", .{valor});
        } else {
             std.debug.print("{any}", .{valor});
        }
    }
    std.debug.print("}}\n", .{}); // Cerramos JSON }
}
Copied!

Al llamar a aJson(u), obtenemos: {"id": 1, "nombre": "Luis", "admin": true}

Este ejemplo solo ilustra la reflexión: no escapa caracteres de las cadenas ni cubre todos los tipos JSON. Para datos reales, usa std.json.

Validación de capacidades con @hasField

La reflexión también sirve para imponer restricciones a los tipos genéricos.

Imagina que quieres una función que acepte cualquier objeto, siempre y cuando tenga un campo llamado vida.

fn curar(entidad: anytype) void {
    const T = @TypeOf(entidad.*);
    
    // Comprobamos si tiene el campo "vida"
    const tiene_vida = @hasField(T, "vida");
    
    if (!tiene_vida) {
        @compileError("La entidad debe tener un campo 'vida'");
    }

    // La llamada debe pasar un puntero mutable: curar(&jugador)
    entidad.vida += 10;
}
Copied!

Si intentas pasarle un objeto sin vida, el programa no compilará. Te mostrará tu mensaje de error personalizado.