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 };
}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);
}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);
}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;
}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);
}