zig-tipos-genericos-comptime

Genéricos en Zig: tipos como valores y comptime

  • 5 min

Un tipo genérico en Zig es un tipo construido en tiempo de compilación a partir de otros tipos. Si vienes de C++, Java o TypeScript, probablemente pienses en los famosos ángulos <T>; Zig lo plantea de otra forma.

En otros lenguajes aparecen formas como List<String>, Vector<int> o Map<String, User> para pedir una versión especializada de un tipo.

En Zig, no existe la sintaxis de genéricos. Esto es posible porque en Zig los tipos son valores disponibles durante la compilación.

Al igual que puedes pasar un número 5 a una función, puedes pasar el tipo i32 a una función. Y al igual que una función puede devolver un 42, puede devolver un struct.

Funciones que devuelven tipos

En Zig, una estructura de datos genérica (como una Lista) es simplemente una función que toma un tipo como parámetro (comptime) y devuelve una definición de struct.

Veamos cómo crearíamos nuestra propia “Caja” genérica:

// Una función que acepta un tipo 'T' y devuelve un tipo 'type'
fn Caja(comptime T: type) type {
    return struct {
        valor: T,
        
        // Podemos poner métodos dentro
        pub fn obtener(self: @This()) T {
            return self.valor;
        }
    };
}

pub fn main() void {
    // Llamamos a la función con i32 -> Nos devuelve un tipo nuevo
    const CajaDeEnteros = Caja(i32);
    
    // Llamamos a la función con bool -> Nos devuelve OTRO tipo nuevo
    const CajaDeBooleanos = Caja(bool);

    // Usamos los tipos
    const c1 = CajaDeEnteros{ .valor = 100 };
    const c2 = CajaDeBooleanos{ .valor = true };
    _ = .{ c1, c2 };
}
Copied!

Así creamos tipos genéricos sin una sintaxis adicional: usamos comptime y una función que devuelve type.

Por convención, las funciones que devuelven tipos se escriben en PascalCase (como Caja, ArrayList, HashMap), para que parezcan tipos normales al usarlas.

Funciones genéricas

Lo mismo aplica para funciones que operan sobre diferentes tipos. Simplemente pasamos el tipo como argumento.

Supongamos que queremos una función max que funcione para cualquier tipo numérico.

fn maximo(comptime T: type, a: T, b: T) T {
    if (a > b) return a;
    return b;
}

pub fn main() void {
    const x = maximo(i32, 10, 20);
    const y = maximo(f32, 5.5, 1.2);
}
Copied!

Inferencia de tipos (anytype)

Escribir el tipo explícitamente maximo(i32, ...) puede ser verboso. Zig permite “Duck Typing” en tiempo de compilación usando la palabra clave anytype.

Si usas anytype, el tipo del parámetro se infiere en cada llamada. En este ejemplo a y b deben admitir la comparación y ser compatibles con el tipo de retorno.

// 'a' y 'b' pueden ser cualquier cosa
fn maximo(a: anytype, b: anytype) @TypeOf(a) {
    if (a > b) return a;
    return b;
}

pub fn main() void {
    // Zig infiere que es i32
    const x = maximo(10, 20); 
}
Copied!

Monomorfización

Zig especializa las instancias genéricas mediante monomorfización.

Cuando llamas a Caja(i32) y Caja(bool), el compilador obtiene dos tipos distintos. Las funciones utilizadas se especializan para cada combinación necesaria; las declaraciones que nunca se descubren no tienen por qué emitir código.

  • Ventaja: el código especializado conoce el tipo concreto y no necesita despacho dinámico por ese motivo.
  • Desventaja: muchas especializaciones pueden aumentar el tamaño del binario.

Introspección de tipos con @typeInfo

Como tenemos el tipo T disponible durante la compilación, podemos inspeccionarlo.

Zig nos da la función @typeInfo(T), que devuelve una unión etiquetada describiendo el tipo (¿es un entero? ¿es un struct? ¿qué campos tiene?).

Esto nos permite escribir funciones genéricas que se adaptan según lo que reciben. Por ejemplo, una función que solo acepta enteros:

fn sumar(comptime T: type, a: T, b: T) T {
    // Validamos en tiempo de compilación
    const info = @typeInfo(T);
    
    if (info != .int) {
        @compileError("¡Esta función solo admite enteros!");
    }
    
    return a + b;
}
Copied!

Si intentas llamar a sumar(f32, 1.5, 2.5), el compilador te lanzará tu propio error personalizado y detendrá la compilación.

Ejemplo: una lista enlazada

Vamos a juntar todo para crear una LinkedList básica.

const std = @import("std");

// La función genérica
fn LinkedList(comptime T: type) type {
    return struct {
        // Definimos el Nodo dentro del ámbito del tipo
        const Node = struct {
            data: T,
            next: ?*Node,
        };

        head: ?*Node = null,
        allocator: std.mem.Allocator,

        // 'Self' se refiere al tipo que estamos retornando
        const Self = @This();

        pub fn init(allocator: std.mem.Allocator) Self {
            return Self{ .allocator = allocator };
        }

        pub fn add(self: *Self, value: T) !void {
            const new_node = try self.allocator.create(Node);
            new_node.* = Node{ .data = value, .next = self.head };
            self.head = new_node;
        }

        pub fn deinit(self: *Self) void {
            var current = self.head;
            while (current) |node| {
                const next = node.next;
                self.allocator.destroy(node);
                current = next;
            }
            self.* = undefined;
        }
    };
}

pub fn main() !void {
    var debug_allocator = std.heap.DebugAllocator(.{}){};
    defer _ = debug_allocator.deinit();

    // Creamos el tipo concreto
    const IntList = LinkedList(i32);
    
    var list = IntList.init(debug_allocator.allocator());
    defer list.deinit();

    try list.add(10);
    try list.add(20);
}
Copied!