zig-arraylist-hashmap

ArrayList y HashMap en Zig 0.16 con allocators

  • 4 min

Un Allocator es la pieza que permite a una estructura pedir memoria dinámica cuando no sabe de antemano cuánto va a crecer.

Hasta ahora hemos visto allocators de forma bastante directa: pedir memoria, usarla y liberarla. Pero donde realmente se vuelven cotidianos es en las estructuras dinámicas de la librería estándar, como ArrayList y HashMap.

En Zig, muchas estructuras no esconden la memoria que usan. Si una colección puede crecer, normalmente habrá un allocator cerca. Es la forma de Zig de dejar claro que esa operación puede reservar memoria.

Por qué una colección necesita memoria

Un array normal ([N]T) tiene tamaño fijo. El compilador sabe cuántos elementos hay y reserva ese espacio.

const numeros = [_]u8{ 10, 20, 30 };
Copied!

Eso está muy bien si sabemos el tamaño por adelantado. Pero muchas veces no lo sabemos:

  • Leemos líneas de un archivo.
  • Recibimos datos por red.
  • Vamos acumulando resultados.
  • Construimos una lista de errores, tokens o entidades.

Ahí necesitamos una estructura que pueda crecer. Y si algo puede crecer, alguien tiene que darle memoria nueva.

Ese alguien es el Allocator.

ArrayList

Un ArrayList es una lista dinámica. Conceptualmente se parece a un vector de C++ o a una lista de otros lenguajes, pero con la filosofía de Zig: la memoria no se oculta.

const std = @import("std");

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

    var lista: std.ArrayList(u8) = .empty;
    defer lista.deinit(allocator);

    try lista.append(allocator, 10);
    try lista.append(allocator, 20);
    try lista.append(allocator, 30);

    for (lista.items) |valor| {
        std.debug.print("{d}\n", .{valor});
    }
}
Copied!

Hay tres detalles importantes:

  1. Creamos la lista con .empty.
  2. Cada operación que puede crecer recibe el allocator.
  3. Liberamos la memoria con deinit(allocator).

La lista expone sus elementos mediante lista.items, que es un slice ([]T). Esto es muy cómodo, porque podemos recorrerlo igual que cualquier otro slice.

Si guardas lista.items y después haces más append, la lista puede realojar memoria. En ese caso, el slice anterior queda invalidado. Lo mismo se aplica a punteros obtenidos a partir de sus elementos.

Capacidad y realojos

Un ArrayList no pide memoria elemento a elemento. Normalmente reserva un bloque con cierta capacidad y lo va rellenando.

Si se queda sin espacio, pide un bloque más grande, copia los datos y libera el bloque anterior. Eso se llama realojar memoria.

Si sabes aproximadamente cuántos elementos vas a guardar, puedes reservar capacidad antes de empezar:

var lista: std.ArrayList(u32) = .empty;
defer lista.deinit(allocator);

try lista.ensureTotalCapacity(allocator, 1000);

for (0..1000) |i| {
    try lista.append(allocator, @intCast(i));
}
Copied!

Esto evita muchos realojos intermedios. No siempre hace falta, pero en código sensible al rendimiento puede marcar la diferencia.

HashMap

Un HashMap es una tabla clave-valor. Es decir, una estructura donde guardamos un valor asociado a una clave.

Por ejemplo:

“temperatura” -> 23 “humedad” -> 61 “presion” -> 1013

En Zig, una de las variantes más habituales para claves de texto es std.StringHashMap.

const std = @import("std");

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

    var sensores = std.StringHashMap(u32).init(allocator);
    defer sensores.deinit();

    try sensores.put("temperatura", 23);
    try sensores.put("humedad", 61);
    try sensores.put("presion", 1013);

    if (sensores.get("humedad")) |valor| {
        std.debug.print("Humedad: {d}%\n", .{valor});
    }
}
Copied!

Aquí también hay memoria dinámica. El mapa necesita reservar espacio para sus buckets internos, y puede crecer cuando insertamos más elementos.

Recorrer un HashMap

Para recorrer un HashMap, usamos un iterador.

var it = sensores.iterator();
while (it.next()) |entry| {
    std.debug.print("{s}: {d}\n", .{ entry.key_ptr.*, entry.value_ptr.* });
}
Copied!

Cada entry nos da punteros a la clave y al valor. Por eso accedemos con .*.

Puede parecer un poco más explícito que en otros lenguajes, pero es coherente con Zig: si estás tocando memoria indirecta, se ve en el código.

ArrayList o HashMap

Aunque ambas estructuras usan allocators, sirven para cosas distintas.

EstructuraUso típicoAcceso
ArrayList(T)Lista ordenada de elementosPor índice
StringHashMap(T)Diccionario con claves de textoPor clave
AutoHashMap(K, V)Diccionario para claves compatibles con autoHash, como enteros o enums.Por clave

Si solo necesitas acumular elementos, usa ArrayList. Si necesitas buscar por una clave, usa un HashMap.

StringHashMap no copia las claves al insertarlas. El texto usado como clave debe seguir vivo mientras permanezca dentro del mapa. Los literales del ejemplo duran todo el programa; para texto temporal tendrás que duplicarlo y liberar esa copia cuando corresponda.