Ir al contenido principal

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:

  1. Un puntero al primer elemento de la secuencia (ptr).
  2. 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.