Unions en Zig
Las unions en Zig son tipos de datos que permiten almacenar diferentes tipos de valores en la misma ubicación de memoria. A diferencia de las estructuras, que agrupan múltiples campos que existen simultáneamente, una union solo puede contener un valor de uno de sus posibles tipos a la vez. Esto las hace extremadamente útiles cuando necesitamos manejar datos que pueden tomar diferentes formas, pero solo una a la vez, optimizando así el uso de memoria.
En este artículo exploraremos:
- Unions básicas
- Unions etiquetadas (tagged unions)
- Unions externas y empaquetadas
- Técnicas para trabajar con unions de manera segura
Unions básicas
Una union en Zig se define de manera similar a una estructura, pero utiliza la palabra clave union
en lugar de struct
:
const Union = union {
entero: i32,
flotante: f64,
booleano: bool,
};
Al crear una instancia de una union, debemos especificar qué campo estamos activando:
const std = @import("std");
test "union básica" {
const valor = Union{ .entero = 42 };
try std.testing.expect(valor.entero == 42);
}
Es importante entender que solo un campo de la union está activo a la vez. Si intentamos acceder a un campo no activo, Zig generará un error en tiempo de ejecución:
test "acceso incorrecto a union" {
var valor = Union{ .entero = 42 };
// Esto causaría un error en tiempo de ejecución:
// valor.flotante = 3.14;
// La forma correcta es asignar completamente la union:
valor = Union{ .flotante = 3.14 };
try std.testing.expect(valor.flotante == 3.14);
}
Unions etiquetadas (tagged unions)
Una de las características más potentes de Zig es su soporte para unions etiquetadas. Estas combinan una enumeración con una union para realizar un seguimiento del tipo activo, proporcionando seguridad en tiempo de ejecución:
const std = @import("std");
const TipoValor = enum {
entero,
flotante,
booleano,
};
const Valor = union(TipoValor) {
entero: i32,
flotante: f64,
booleano: bool,
};
test "union etiquetada" {
const v = Valor{ .entero = 42 };
// Podemos obtener la etiqueta:
const tipo = @as(TipoValor, v);
try std.testing.expect(tipo == .entero);
// Podemos utilizar switch con pattern matching:
switch (v) {
.entero => |valor| try std.testing.expect(valor == 42),
.flotante => unreachable,
.booleano => unreachable,
}
}
Las unions etiquetadas son excelentes para trabajar con valores que pueden tener diferentes tipos, ya que nos permiten verificar fácilmente qué tipo está activo y acceder a su valor de manera segura.
Modificando valores con switch
Una característica poderosa de las unions etiquetadas en Zig es la capacidad de modificar sus valores dentro de un switch usando el operador de captura por referencia (*
):
test "modificar union etiquetada con switch" {
var v = Valor{ .entero = 42 };
switch (v) {
.entero => |*valor| valor.* += 1,
.flotante => unreachable,
.booleano => unreachable,
}
try std.testing.expect(v.entero == 43);
}
Unions con inferencia de enumeración
Zig permite definir unions etiquetadas sin declarar explícitamente la enumeración, utilizando union(enum)
:
const Variante = union(enum) {
entero: i32,
flotante: f64,
booleano: bool,
// Se puede omitir el tipo para campos void
nada,
fn esVerdadero(self: Variante) bool {
return switch (self) {
.entero => |x| x != 0,
.flotante => |x| x != 0.0,
.booleano => |x| x,
.nada => false,
};
}
};
test "union con enum inferido y método" {
var v1 = Variante{ .entero = 1 };
var v2 = Variante{ .booleano = false };
try std.testing.expect(v1.esVerdadero());
try std.testing.expect(!v2.esVerdadero());
}
Nota cómo podemos agregar métodos a las unions, de manera similar a las estructuras.
Unions externas (extern union
)
Las unions externas tienen una disposición de memoria garantizada compatible con la ABI de C:
const ExternaUnion = extern union {
entero: i32,
flotante: f32,
bytes: [4]u8,
};
test "extern union" {
var valor = ExternaUnion{ .entero = 0x12345678 };
// Podemos acceder a cualquier campo sin chequeos de seguridad
// (esto podría causar comportamiento indefinido si el sistema
// no usa little-endian)
try std.testing.expect(valor.bytes[0] == 0x78);
// Cambiamos a flotante sin ninguna asignación completa
valor.flotante = 3.14;
}
Las unions externas son útiles para interoperabilidad con C o cuando necesitamos manipular diferentes vistas de los mismos datos. Sin embargo, no tienen las mismas protecciones que las unions normales, por lo que debemos ser cuidadosos.
Unions empaquetadas (packed union
)
Las unions empaquetadas garantizan una disposición de memoria específica y son elegibles para usarse dentro de una estructura empaquetada:
const Bits = packed union {
valor: u8,
estructura: packed struct {
a: u3,
b: u3,
c: u2,
},
};
test "packed union" {
var bits = Bits{ .valor = 0 };
bits.estructura.a = 0b101;
bits.estructura.b = 0b110;
bits.estructura.c = 0b10;
try std.testing.expect(bits.valor == 0b10110101);
}
Las unions empaquetadas son muy útiles para manipular bits individuales o campos en protocolos binarios y formatos de datos.
Inicialización de union con sintaxis literal anónima
Podemos inicializar unions sin especificar el tipo, al igual que con las estructuras anónimas:
const Numero = union {
entero: i32,
flotante: f64,
};
test "sintaxis literal de union anónima" {
const i: Numero = .{ .entero = 42 };
const f = crearNumero();
try std.testing.expect(i.entero == 42);
try std.testing.expect(f.flotante == 12.34);
}
fn crearNumero() Numero {
return .{ .flotante = 12.34 };
}
@unionInit - Inicialización dinámica de unions
En ocasiones necesitamos inicializar una union cuando el campo a activar se conoce solo en tiempo de ejecución. Para esto, Zig proporciona la función integrada @unionInit
:
test "@unionInit" {
const std = @import("std");
const campo_nombre = "entero";
const valor = @unionInit(Numero, campo_nombre, 42);
try std.testing.expect(valor.entero == 42);
}
Ejemplo práctico: Analizador de valores JSON simplificado
Para ilustrar un caso de uso real de las unions, implementaremos un analizador simplificado de valores JSON:
const std = @import("std");
const JsonValor = union(enum) {
nulo,
booleano: bool,
numero: f64,
cadena: []const u8,
fn imprimir(self: JsonValor, escritor: anytype) !void {
switch (self) {
.nulo => try escritor.writeAll("null"),
.booleano => |b| try escritor.writeAll(if (b) "true" else "false"),
.numero => |n| try escritor.print("{d}", .{n}),
.cadena => |s| try escritor.print("\"{s}\"", .{s}),
}
}
};
test "analizador JSON simplificado" {
const valores = [_]JsonValor{
.nulo,
.{ .booleano = true },
.{ .numero = 42.5 },
.{ .cadena = "hola" },
};
var buffer: [100]u8 = undefined;
var fbs = std.io.fixedBufferStream(&buffer);
const escritor = fbs.writer();
try escritor.writeAll("[");
for (valores, 0..) |valor, i| {
if (i > 0) try escritor.writeAll(", ");
try valor.imprimir(escritor);
}
try escritor.writeAll("]");
const salida = buffer[0..fbs.pos];
const esperado = "[null, true, 42.5, \"hola\"]";
try std.testing.expectEqualStrings(esperado, salida);
}
Este ejemplo muestra cómo las unions etiquetadas son ideales para representar estructuras de datos que pueden tener diferentes tipos, como los valores en JSON.
Cuándo usar unions
Las unions son particularmente útiles en los siguientes casos:
- Procesamiento de mensajes: Cuando se reciben mensajes que pueden ser de diferentes tipos.
- Parseo de datos: Al analizar formatos como JSON, XML o protocolos binarios.
- Máquinas de estado: Para representar diferentes estados con diferentes datos asociados.
- Optimización de memoria: Cuando tenemos varios tipos mutuamente excluyentes.
- Interoperabilidad con C: Para trabajar con unions de C.
Conclusión
Las unions en Zig son una potente herramienta para trabajar con datos que pueden tomar diferentes formas:
- Las unions básicas nos permiten almacenar diferentes tipos en la misma ubicación de memoria.
- Las unions etiquetadas combinan enumeraciones y unions para proporcionar seguridad en tiempo de ejecución.
- Las unions externas y empaquetadas ofrecen control preciso sobre la disposición de memoria.
- El switch con pattern matching nos permite trabajar con unions de manera elegante y segura.
Las unions, especialmente las etiquetadas, son una característica distintiva de Zig que proporciona seguridad sin sacrificar el rendimiento, permitiéndonos implementar estructuras de datos complejas como árboles de sintaxis abstracta, analizadores y máquinas de estado de manera eficiente y segura.
Cuando necesites manejar datos que pueden tener diferentes formas pero solo una a la vez, considera usar una union. Y recuerda que en la mayoría de los casos, una union etiquetada es la opción más segura, ya que proporciona verificación en tiempo de ejecución del tipo activo.