zig-allocator-malloc

Allocators en Zig: memoria dinámica sin malloc global

  • 4 min

Un Allocator en Zig es una interfaz que decide cómo pedir, redimensionar y liberar memoria dinámica. Si vienes de C, C++ o Rust, probablemente estés acostumbrado a disponer de un asignador global por defecto.

En Zig no hay un allocator predeterminado por convención. Si enlazas libc puedes usar std.heap.c_allocator, pero las APIs reutilizables suelen recibir un Allocator elegido por quien las llama.

Zig parte de una premisa: la asignación de memoria es una dependencia que conviene hacer visible.

Si una función necesita asignar memoria dinámica, debe pedir permiso. Y la forma de pedir permiso es recibiendo un parámetro de tipo Allocator.

El problema del asignador global

En casi todos los lenguajes, existe un “Montón Global” (Heap) invisible.

  • En C: malloc usa el asignador por defecto de la libc.
  • En Python/JS: El Garbage Collector pide memoria al sistema cuando quiere.
  • En Rust: Box::new usa el Global Allocator (aunque Rust está permitiendo cambiarlos poco a poco).

Esto tiene problemas graves para la programación de sistemas:

  1. Coste oculto: la firma no indica si una operación puede reservar, realojar o fallar por falta de memoria. Un malloc no realiza necesariamente una llamada al sistema en cada uso, pero tampoco es gratuito.
  2. Falta de control: ¿Qué pasa si quieres que una librería use una región de memoria específica (ej: memoria rápida SRAM en un chip)? Si la librería usa malloc internamente, no puedes cambiarlo.
  3. Propiedad menos clara: si todo usa el mismo asignador, cuesta más determinar quién debe liberar cada resultado.

El allocator explícito

En Zig, un Allocator es una interfaz que permite:

  1. Darte un trozo de memoria (alloc).
  2. Cambiar el tamaño de ese trozo (resize).
  3. Liberar ese trozo (free).

Si escribes una función que necesita memoria, tu firma cambia así:

// En C (malloc oculto)
// char* duplicar(const char* input);

// En Zig (Allocator explícito)
fn duplicar(allocator: Allocator, input: []const u8) ![]u8 {
    // Usamos el allocator que nos han PASADO para pedir memoria
    const memoria = try allocator.alloc(u8, input.len);
    
    @memcpy(memoria, input);
    return memoria;
}
Copied!

Qué aporta este diseño

Al principio parece verboso (tienes que pasar el parámetro a todas partes), pero las ventajas son inmensas:

  1. Ves qué APIs pueden reservar memoria: un parámetro allocator avisa de esa posibilidad y el ! refleja que la reserva puede fallar.
  2. Tú eliges la estrategia: puedes proporcionar un asignador de propósito general, uno basado en un búfer fijo o uno con un ciclo de vida muy corto. La función solo usa la interfaz recibida.
  3. Facilita la portabilidad: el mismo algoritmo puede trabajar sobre distintas fuentes de memoria, siempre que el resto de sus dependencias también sea compatible con el objetivo.

Esta es una convención de diseño, no una restricción del compilador. Una función sin parámetro allocator todavía podría usar page_allocator, c_allocator u otra fuente global internamente; su documentación debe indicarlo.

Operaciones básicas: create, alloc, destroy, free

La interfaz std.mem.Allocator tiene métodos estándar que usaremos constantemente. Es importante distinguir entre crear un objeto y crear un array.

Un solo objeto (create y destroy)

Usamos create cuando queremos un puntero a una sola instancia (*T). Equivalente a new en C++.

const allocator = ...; // Supongamos que ya tenemos uno

// Reservar memoria para un i32
const ptr = try allocator.create(i32);
ptr.* = 42;

// Liberar memoria
allocator.destroy(ptr);
Copied!

Varios elementos (alloc y free)

Usamos alloc cuando queremos un Slice ([]T). Equivalente a malloc(n * sizeof(T)) en C.

// Reservar espacio para 100 bytes
const buffer = try allocator.alloc(u8, 100);
defer allocator.free(buffer); // Importante: usar free para slices

// Usamos el buffer
buffer[0] = 0xFF;
Copied!

Idea práctica:

  • Si usaste create, libera con destroy.
  • Si usaste alloc, libera con free.

Ejemplo completo

Vamos a ver cómo se ve un programa real que concatena dos strings. Fíjate en cómo el allocator viaja desde el main hasta la lógica.

const std = @import("std");
const Allocator = std.mem.Allocator;

// Esta función NO sabe de dónde viene la memoria.
// Solo sabe que necesita un Allocator para trabajar.
fn unirTexto(allocator: Allocator, a: []const u8, b: []const u8) ![]u8 {
    const total_len = a.len + b.len;
    
    // 1. Pedimos memoria
    const resultado = try allocator.alloc(u8, total_len);
    // Nota: NO ponemos defer free aquí, porque queremos devolver el resultado
    // Si fallamos al copiar, usaríamos errdefer.

    // 2. Copiamos datos
    @memcpy(resultado[0..a.len], a);
    @memcpy(resultado[a.len..], b);
    
    return resultado;
}

pub fn main() !void {
    // 1. Elegimos nuestra estrategia de memoria (veremos esto en el próximo artículo)
    var debug_allocator = std.heap.DebugAllocator(.{}){};
    defer _ = debug_allocator.deinit();
    const allocator = debug_allocator.allocator();

    // 2. Llamamos a la función inyectando la dependencia
    const saludo = try unirTexto(allocator, "Hola ", "Zig");
    
    // 3. Somos dueños de la memoria devuelta, debemos liberarla
    defer allocator.free(saludo);
    
    std.debug.print("{s}\n", .{saludo});
}
Copied!