Slices en Zig
En el mundo de la programación de sistemas, trabajar eficientemente con secuencias de datos es fundamental. Zig, como lenguaje moderno orientado a este propósito, nos ofrece una herramienta poderosa: los slices. Estas estructuras nos permiten manipular colecciones de datos de manera segura y expresiva sin el coste adicional de crear copias innecesarias.
Los slices en Zig representan una visión parcial o completa de una colección de datos, permitiéndonos referenciar secciones específicas de arrays u otras estructuras sin necesidad de duplicarlas en memoria. Este artículo profundiza en el concepto de slices, explicando qué son, cómo usarlos y las mejores prácticas para su uso en tu código Zig.
¿Qué son los slices en Zig?
Un slice en Zig es esencialmente un "puntero gordo" (fat pointer) que contiene dos componentes:
- Un puntero al primer elemento de la secuencia (
ptr
). - La longitud de la secuencia (
len
).
La sintaxis para el tipo slice es []T
, donde T
es el tipo de los elementos que contiene. Por ejemplo, []i32
representa un slice de enteros de 32 bits.
A diferencia de los arrays, cuyo tamaño se conoce en tiempo de compilación y forma parte del tipo (por ejemplo, [5]i32
), o de los punteros a varios elementos ([*]i32
), los slices llevan consigo información sobre su longitud en tiempo de ejecución, lo que permite realizar comprobaciones de límites automáticamente.
Creación de slices
Existen varias formas de crear slices en Zig. Veamos los métodos más comunes:
1. A partir de un array
const std = @import("std");
const expect = std.testing.expect;
test "crear slices desde arrays" {
// Definimos un array
var numeros = [_]i32{ 1, 2, 3, 4, 5 };
// Crear un slice de todo el array
const slice_completo: []i32 = &numeros;
// Verificamos que la longitud es correcta
try expect(slice_completo.len == 5);
// Crear un slice de una porción del array usando la sintaxis de slice
const slice_parcial = numeros[1..4];
// La longitud ahora es 3 (elementos en posiciones 1, 2 y 3)
try expect(slice_parcial.len == 3);
// El primer elemento del slice_parcial es el segundo del array original
try expect(slice_parcial[0] == 2);
// El último elemento del slice_parcial es el cuarto del array original
try expect(slice_parcial[2] == 4);
}
En este ejemplo:
&numeros
crea un slice que abarca todo el array.numeros[1..4]
crea un slice desde el índice 1 (inclusive) hasta el índice 4 (exclusive).
2. Usando la sintaxis de rango
La sintaxis de rango en Zig es potente y flexible:
test "sintaxis de rango para slices" {
var datos = [_]u8{ 10, 20, 30, 40, 50, 60, 70, 80 };
// Desde el inicio hasta un índice
const primeros_tres = datos[0..3];
try expect(primeros_tres.len == 3);
// Desde un índice hasta el final
const ultimos_cinco = datos[3..];
try expect(ultimos_cinco.len == 5);
try expect(ultimos_cinco[0] == 40);
// Un rango vacío
const vacio = datos[2..2];
try expect(vacio.len == 0);
// Usando una variable para el inicio (debe ser conocida en tiempo de ejecución)
var inicio: usize = 2;
const desde_variable = datos[inicio..6];
try expect(desde_variable.len == 4);
try expect(desde_variable[0] == 30);
}
Observa cómo podemos:
- Omitir el índice final para incluir todos los elementos hasta el final.
- Crear un slice vacío cuando los índices de inicio y fin son iguales.
- Usar variables para los índices (aunque estas deben ser conocidas en tiempo de ejecución).
3. Slices con memoria asignada dinámicamente
También podemos crear slices a partir de memoria asignada dinámicamente:
test "slices con memoria asignada dinámicamente" {
const allocator = std.testing.allocator;
// Asignamos memoria para 6 enteros
const memoria = try allocator.alloc(i32, 6);
// Es importante liberar esta memoria al final
defer allocator.free(memoria);
// Ahora memoria es un slice de longitud 6
try expect(memoria.len == 6);
// Podemos escribir en esta memoria
memoria[0] = 10;
memoria[5] = 60;
// Y también crear sub-slices
const subseccion = memoria[2..5];
try expect(subseccion.len == 3);
// La subsección comparte la misma memoria
subseccion[0] = 30;
try expect(memoria[2] == 30);
}
Este ejemplo muestra cómo:
- Asignar memoria dinámicamente con un
allocator
. - Utilizar
defer
para asegurar que la memoria se libere cuando sea necesario. - Crear y manipular slices sobre esta memoria dinámica.
Propiedades y métodos de los slices
Los slices en Zig tienen propiedades y comportamientos importantes que debemos conocer:
Comprobación de límites en tiempo de ejecución
Una de las ventajas principales de los slices es que realizan comprobación de límites automáticamente:
test "comprobación de límites en slices" {
var valores = [_]u8{ 1, 2, 3 };
const slice = valores[0..];
// Acceso correcto
const valor = slice[1];
try expect(valor == 2);
// El siguiente código provocaría un error en tiempo de ejecución
// (está comentado para que la prueba pase)
// _ = slice[3]; // ¡Pánico! Índice fuera de límites
}
Propiedades ptr y len
Podemos acceder directamente a los componentes internos del slice:
test "propiedades internas de slices" {
var datos = [_]i32{ 10, 20, 30, 40 };
const slice = datos[1..3];
// El puntero apunta al primer elemento del slice
try expect(@intFromPtr(slice.ptr) == @intFromPtr(&datos[1]));
// La longitud es el número de elementos
try expect(slice.len == 2);
// Podemos convertir el puntero interno a un tipo [*]T
const puntero_multiple: [*]i32 = slice.ptr;
try expect(puntero_multiple[0] == 20);
try expect(puntero_multiple[1] == 30);
}
Es importante entender que:
slice.ptr
es un puntero a múltiples elementos ([*]T
).slice.len
es la cantidad de elementos en el slice.
Slices constantes vs slices mutables
Al igual que con otros tipos en Zig, debemos distinguir entre slices constantes y mutables:
test "slices constantes vs mutables" {
var numeros = [_]i32{ 1, 2, 3, 4, 5 };
// Slice mutable: podemos modificar los elementos
var slice_mutable: []i32 = numeros[0..3];
slice_mutable[1] = 22;
try expect(numeros[1] == 22);
// Slice constante: no podemos modificar los elementos
const slice_constante: []const i32 = numeros[0..3];
// slice_constante[1] = 33; // ¡Error de compilación!
// Pero podemos leer normalmente
try expect(slice_constante[1] == 22);
// Un slice constante puede apuntar a memoria mutable
numeros[1] = 33;
try expect(slice_constante[1] == 33);
}
Observa que:
- Un slice
[]T
permite modificar los elementos. - Un slice
[]const T
no permite modificar los elementos, pero se puede crear a partir de memoria mutable. - La constancia se refiere a los valores apuntados, no al slice en sí.
Slices con terminador (sentinel-terminated slices)
Zig ofrece una característica especial: slices con un valor terminador. Esto es útil para interoperar con APIs que esperan secuencias terminadas (como cadenas C terminadas en nulo).
La sintaxis para estos slices es [:x]T
, donde x
es el valor terminador.
test "slices con terminador" {
// Una cadena es un puntero a un array terminado en cero
const cadena: [:0]const u8 = "hola";
// La longitud no incluye el terminador
try expect(cadena.len == 4);
// Pero podemos acceder al terminador
try expect(cadena[4] == 0);
// Creación manual de un slice con terminador
var datos = [_]u8{ 1, 2, 3, 0, 4, 5 };
const terminado_en_cero = datos[0..3 :0];
try expect(terminado_en_cero.len == 3);
try expect(terminado_en_cero[3] == 0); // Podemos acceder al terminador
}
El sistema verifica que el valor en la posición del terminador sea el valor esperado:
test "validación del terminador" {
var datos = [_]u8{ 1, 2, 3, 9, 4, 5 };
// Esta línea causaría un pánico en tiempo de ejecución porque
// en la posición 3 el valor es 9, no 0
// const terminado_en_cero = datos[0..3 :0];
// En cambio, si el terminador coincide, funciona:
const terminado_en_nueve = datos[0..3 :9];
try expect(terminado_en_nueve[3] == 9);
}
Operaciones comunes con slices
Veamos algunas operaciones habituales:
Modificación a través de slices
test "modificación de datos a través de slices" {
var buffer = [_]u8{ 1, 2, 3, 4, 5 };
const slice = buffer[1..4];
// Modificar un elemento
slice[1] = 22;
try expect(buffer[2] == 22);
// Recorrer y modificar
for (slice, 0..) |*item, i| {
item.* *= 2;
_ = i; // Evitar advertencia de variable no utilizada
}
try expect(buffer[1] == 4);
try expect(buffer[2] == 44);
try expect(buffer[3] == 8);
}
Copiar datos entre slices
Para copiar datos entre slices, podemos usar funciones como mem.copy
:
test "copiar datos entre slices" {
var origen = [_]u8{ 10, 20, 30, 40, 50 };
var destino = [_]u8{ 0, 0, 0, 0, 0 };
// Copiamos un segmento
@memcpy(destino[1..4], origen[2..5]);
try expect(destino[0] == 0);
try expect(destino[1] == 30);
try expect(destino[2] == 40);
try expect(destino[3] == 50);
try expect(destino[4] == 0);
}
Comparación de slices
test "comparación de slices" {
const mem = std.mem;
const a = [_]u8{ 1, 2, 3 };
const b = [_]u8{ 1, 2, 3 };
const c = [_]u8{ 1, 2, 4 };
// Comparar contenido
try expect(mem.eql(u8, &a, &b));
try expect(!mem.eql(u8, &a, &c));
// Slices parciales
try expect(mem.eql(u8, a[0..2], c[0..2]));
}
Slices para cadenas de texto
Los slices son especialmente útiles para manejar cadenas de texto en Zig:
test "slices para cadenas de texto" {
const mem = std.mem;
// Las cadenas literales se convierten a slices constantes
const mensaje: []const u8 = "Hola, mundo";
try expect(mensaje.len == 11);
// Subcadenas
const saludo = mensaje[0..4];
try expect(mem.eql(u8, saludo, "Hola"));
// Buscar en una cadena
const posicion = mem.indexOf(u8, mensaje, "mundo").?;
try expect(posicion == 6);
// Obtener la palabra encontrada
const palabra = mensaje[posicion .. posicion + 5];
try expect(mem.eql(u8, palabra, "mundo"));
}
Este ejemplo muestra cómo:
- Las cadenas literales son arrays terminados en cero que pueden coercionar a slices.
- Crear subcadenas mediante la sintaxis de slice.
- Usar funciones de la biblioteca estándar para buscar dentro de slices.
Casos de uso avanzados
Slices con slices
Podemos tener slices que contienen otros slices, lo que es útil para manipular colecciones de cadenas:
test "slices de slices" {
const mem = std.mem;
const allocator = std.testing.allocator;
// Crear un slice de cadenas
const palabras = [_][]const u8{
"manzana",
"banana",
"cereza",
};
// El tipo es un slice de slices constantes de u8
try expect(@TypeOf(palabras[0]) == []const u8);
// Podemos acceder y comparar
try expect(mem.eql(u8, palabras[1], "banana"));
// Para un ejemplo más dinámico, creamos un ArrayList de slices
var lista = std.ArrayList([]const u8).init(allocator);
defer lista.deinit();
try lista.append("uno");
try lista.append("dos");
try lista.append("tres");
// Los items de un ArrayList forman un slice
const items_slice = lista.items;
try expect(items_slice.len == 3);
try expect(mem.eql(u8, items_slice[2], "tres"));
}
Slice como búfer temporal
Los slices son ideales como búferes temporales para operaciones de entrada/salida:
test "slice como búfer" {
// Simulamos entrada/salida con un búfer
var buffer: [100]u8 = undefined;
// Creamos un slice sobre el buffer
const slice = buffer[0..];
// Simulamos escritura en el búfer
const bytes_escritos = try escribirEnBuffer(slice);
// Ahora tenemos un slice que contiene exactamente los datos escritos
const datos_validos = buffer[0..bytes_escritos];
try expect(datos_validos.len == 5);
try expect(std.mem.eql(u8, datos_validos, "datos"));
}
fn escribirEnBuffer(buffer: []u8) !usize {
// Simulamos escribir "datos" en el buffer
if (buffer.len < 5) return error.BufferTooSmall;
buffer[0] = 'd';
buffer[1] = 'a';
buffer[2] = 't';
buffer[3] = 'o';
buffer[4] = 's';
return 5;
}
Implementación de la estructura de datos Queue
Un ejemplo práctico de uso de slices para implementar una cola:
const Queue = struct {
data: []u8,
head: usize,
tail: usize,
len: usize,
const Self = @This();
fn init(buffer: []u8) Self {
return Self{
.data = buffer,
.head = 0,
.tail = 0,
.len = 0,
};
}
fn enqueue(self: *Self, value: u8) !void {
if (self.len == self.data.len) {
return error.QueueFull;
}
self.data[self.tail] = value;
self.tail = (self.tail + 1) % self.data.len;
self.len += 1;
}
fn dequeue(self: *Self) !u8 {
if (self.len == 0) {
return error.QueueEmpty;
}
const value = self.data[self.head];
self.head = (self.head + 1) % self.data.len;
self.len -= 1;
return value;
}
};
test "implementación de cola con slices" {
var buffer: [5]u8 = undefined;
var cola = Queue.init(&buffer);
try cola.enqueue(10);
try cola.enqueue(20);
try cola.enqueue(30);
try expect((try cola.dequeue()) == 10);
try expect((try cola.dequeue()) == 20);
try cola.enqueue(40);
try cola.enqueue(50);
try cola.enqueue(60);
try expect(cola.len == 4);
try expect((try cola.dequeue()) == 30);
try expect((try cola.dequeue()) == 40);
}
Buenas prácticas para el uso de slices
Para utilizar slices de manera efectiva y segura en Zig, considera estas recomendaciones:
1. Prioriza los slices sobre los punteros de múltiples elementos
Los slices son más seguros que [*]T
porque realizan comprobación de límites:
test "slices vs punteros múltiples" {
var datos = [_]i32{ 10, 20, 30 };
// Con slice: seguro
const slice = datos[0..];
// slice[3] = 40; // Error en tiempo de ejecución (fuera de límites)
_ = slice;
// Con puntero múltiple: peligroso
const ptr: [*]i32 = &datos;
// ptr[3] = 40; // No hay comprobación de límites, ¡comportamiento indefinido!
_ = ptr;
}
2. Cuidado con la duración (lifetime) de los datos
Los slices no poseen memoria, solo la referencian. Es responsabilidad del programador asegurarse de que la memoria referenciada siga siendo válida:
fn sliceInvalido() []i32 {
var numeros = [_]i32{ 1, 2, 3 };
return &numeros; // ¡ERROR! Devolver un slice a memoria de pila local
}
3. Usa defer
para liberar memoria dinámica
Cuando trabajas con slices que apuntan a memoria asignada dinámicamente:
fn procesarDatos() !void {
const allocator = std.heap.page_allocator;
const datos = try allocator.alloc(u8, 1000);
defer allocator.free(datos);
// Usar datos...
// No es necesario liberar manualmente al final
}
4. Al pasar slices como argumentos, utiliza []const T si no vas a modificarlos
fn suma(numeros: []const i32) i32 {
var total: i32 = 0;
for (numeros) |num| {
total += num;
}
return total;
}
fn duplicar(numeros: []i32) void {
for (numeros) |*num| {
num.* *= 2;
}
}
5. Aprovecha los slices con terminador cuando sea apropiado
Para interoperar con APIs de C o cuando sea semánticamente útil:
fn procesarCadenaC(cadena: [:0]const u8) void {
// El terminador nulo está garantizado
}
Conclusión
Los slices en Zig son una poderosa abstracción que nos permite trabajar con secuencias de datos de manera eficiente y segura. A diferencia de los arrays, cuyo tamaño es conocido en tiempo de compilación, los slices mantienen información de longitud en tiempo de ejecución, permitiendo comprobaciones de límites automáticas para prevenir accesos fuera de rango.
Hemos visto cómo:
- Crear slices a partir de arrays, memoria dinámica y rangos.
- Acceder a las propiedades internas de los slices.
- Usar slices con terminador para casos especiales.
- Realizar operaciones comunes como copia y comparación.
- Aplicar slices a problemas prácticos como manipulación de cadenas y estructuras de datos.
Los slices son una muestra del enfoque de Zig: proporcionar herramientas potentes y eficientes sin sacrificar el control manual sobre la memoria. Cuando se utilizan correctamente, los slices nos ayudan a escribir código más seguro, legible y mantenible, evitando errores comunes asociados con la manipulación directa de punteros.
Al dominar los slices, damos un paso importante en el aprovechamiento de las capacidades de Zig como lenguaje de programación de sistemas moderno y seguro.