Stack vs Heap em Zig: Fundamentos de Gerenciamento de Memória

Antes de escrever código eficiente em qualquer linguagem de sistemas, você precisa entender como a memória funciona. Neste primeiro artigo da série Masterclass de Memória, vamos explorar os fundamentos de stack e heap em Zig — e por que Zig torna essas escolhas mais claras do que qualquer outra linguagem.

O Modelo de Memória de um Programa

Quando um programa é executado, o sistema operacional reserva diferentes regiões de memória para ele. As duas mais importantes para nós são:

RegiãoCaracterísticasVelocidadeGerenciamento
StackTamanho fixo, LIFOExtremamente rápidaAutomático
HeapTamanho dinâmicoMais lentaManual em Zig

A Stack: Memória Automática

A stack (pilha) é uma região de memória que funciona como uma pilha de pratos — o último item colocado é o primeiro a ser removido (LIFO — Last In, First Out). Cada vez que uma função é chamada, um stack frame é criado; quando a função retorna, o frame é destruído automaticamente.

const std = @import("std");

fn calcularArea(largura: f64, altura: f64) f64 {
    // 'resultado' vive na stack desta função
    // Quando calcularArea() retorna, 'resultado' é destruído automaticamente
    const resultado = largura * altura;
    return resultado;
}

fn exemploStack() void {
    // Estas variáveis vivem na stack de exemploStack()
    const x: i32 = 42;
    const y: f64 = 3.14;
    var buffer: [256]u8 = undefined; // Array de 256 bytes na stack

    const area = calcularArea(10.0, 20.0);

    std.debug.print("x = {d}, y = {d:.2}, area = {d:.1}\n", .{ x, y, area });
    std.debug.print("buffer tem {d} bytes na stack\n", .{buffer.len});
}

pub fn main() void {
    exemploStack();
}

Neste exemplo, todas as variáveis (x, y, buffer, resultado) vivem na stack. Elas são criadas quando a função é chamada e destruídas quando a função retorna. Você não precisa se preocupar em “liberar” essa memória.

Vantagens da stack:

  • Alocação instantânea (apenas mover o stack pointer)
  • Desalocação automática ao sair do escopo
  • Excelente localidade de cache (dados contíguos)

Limitações da stack:

  • Tamanho limitado (geralmente 1-8 MB por thread)
  • Dados não sobrevivem além do escopo da função
  • Tamanho deve ser conhecido em tempo de compilação

A Heap: Memória Dinâmica

A heap é uma região de memória muito maior, onde você pode alocar dados de tamanho variável que persistem além do escopo de uma função. Em Zig, toda alocação de heap é feita através de allocators — nunca há uma chamada oculta a malloc.

const std = @import("std");

pub fn main() !void {
    // O allocator é explícito — você sempre sabe quando está alocando
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer {
        const status = gpa.deinit();
        if (status == .leak) {
            std.debug.print("ALERTA: Memory leak detectado!\n", .{});
        }
    }
    const allocator = gpa.allocator();

    // Alocação na heap — tamanho determinado em runtime
    const tamanho: usize = 1000;
    const dados = try allocator.alloc(u8, tamanho);
    defer allocator.free(dados); // Liberar quando sair do escopo

    // Preencher os dados
    for (dados, 0..) |*byte, i| {
        byte.* = @intCast(i % 256);
    }

    std.debug.print("Alocados {d} bytes na heap\n", .{dados.len});
    std.debug.print("Primeiros 5 bytes: ", .{});
    for (dados[0..5]) |byte| {
        std.debug.print("{d} ", .{byte});
    }
    std.debug.print("\n", .{});
}

Observe como em Zig:

  1. O allocator é passado explicitamente — sem malloc global oculto
  2. O defer garante que a memória será liberada ao sair do escopo
  3. O GeneralPurposeAllocator detecta memory leaks automaticamente

Stack vs Heap: Quando Usar Cada Uma

Escolher entre stack e heap é uma decisão fundamental. Aqui está um guia prático:

Use a Stack Quando:

const std = @import("std");

// 1. Dados com tamanho conhecido em compilação
fn processarCoords() void {
    var ponto = [3]f64{ 1.0, 2.0, 3.0 }; // 24 bytes na stack
    ponto[0] = 10.0;
    std.debug.print("Ponto: ({d}, {d}, {d})\n", .{ ponto[0], ponto[1], ponto[2] });
}

// 2. Buffers temporários pequenos
fn formatarMensagem(nome: []const u8) void {
    var buf: [512]u8 = undefined;
    const msg = std.fmt.bufPrint(&buf, "Olá, {s}!", .{nome}) catch "erro";
    std.debug.print("{s}\n", .{msg});
}

// 3. Structs pequenas
const Vetor2D = struct {
    x: f64,
    y: f64,

    fn magnitude(self: Vetor2D) f64 {
        return @sqrt(self.x * self.x + self.y * self.y);
    }
};

pub fn main() void {
    processarCoords();
    formatarMensagem("Zig Brasil");

    const v = Vetor2D{ .x = 3.0, .y = 4.0 };
    std.debug.print("Magnitude: {d:.1}\n", .{v.magnitude()});
}

Use a Heap Quando:

const std = @import("std");

const Node = struct {
    valor: i32,
    proximo: ?*Node,
};

fn criarLista(allocator: std.mem.Allocator, valores: []const i32) !?*Node {
    var cabeca: ?*Node = null;
    var atual: ?*Node = null;

    for (valores) |val| {
        const novo = try allocator.create(Node);
        novo.* = .{ .valor = val, .proximo = null };

        if (cabeca == null) {
            cabeca = novo;
        } else {
            atual.?.proximo = novo;
        }
        atual = novo;
    }
    return cabeca;
}

fn imprimirLista(lista: ?*Node) void {
    var atual = lista;
    while (atual) |node| {
        std.debug.print("{d} -> ", .{node.valor});
        atual = node.proximo;
    }
    std.debug.print("null\n", .{});
}

fn destruirLista(allocator: std.mem.Allocator, lista: ?*Node) void {
    var atual = lista;
    while (atual) |node| {
        const proximo = node.proximo;
        allocator.destroy(node);
        atual = proximo;
    }
}

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

    // Lista ligada — tamanho dinâmico, dados na heap
    const valores = [_]i32{ 10, 20, 30, 40, 50 };
    const lista = try criarLista(allocator, &valores);
    defer destruirLista(allocator, lista);

    imprimirLista(lista);
}

O Padrão defer para Segurança de Memória

Um dos recursos mais poderosos de Zig para gerenciamento de memória é o defer. Ele garante que um trecho de código será executado quando o escopo atual terminar, independentemente de como isso aconteça (retorno normal ou erro).

const std = @import("std");

const Recurso = struct {
    dados: []u8,
    allocator: std.mem.Allocator,

    fn init(allocator: std.mem.Allocator, tamanho: usize) !Recurso {
        const dados = try allocator.alloc(u8, tamanho);
        return Recurso{
            .dados = dados,
            .allocator = allocator,
        };
    }

    fn deinit(self: *Recurso) void {
        self.allocator.free(self.dados);
        self.dados = &.{};
    }

    fn processar(self: *Recurso) !void {
        // Simulação de processamento
        for (self.dados, 0..) |*byte, i| {
            byte.* = @intCast(i % 256);
        }
        std.debug.print("Processados {d} bytes\n", .{self.dados.len});
    }
};

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

    // 'defer' garante que deinit() SEMPRE será chamado
    var recurso = try Recurso.init(allocator, 1024);
    defer recurso.deinit();

    // Mesmo que processar() falhe, deinit() será chamado
    try recurso.processar();

    std.debug.print("Recurso processado com sucesso!\n", .{});
}

O padrão é simples: para cada init, tenha um defer deinit. Isso é muito mais seguro do que o modelo de C, onde você precisa lembrar de chamar free em cada caminho de retorno.

Sentinelas e Valores Opcionais

Zig usa ponteiros opcionais (?*T) para representar ponteiros que podem ser nulos. Isso elimina uma classe inteira de bugs:

const std = @import("std");

fn buscarValor(allocator: std.mem.Allocator, chave: []const u8) !?[]u8 {
    // Simula uma busca que pode não encontrar o valor
    if (std.mem.eql(u8, chave, "existente")) {
        const resultado = try allocator.alloc(u8, 5);
        @memcpy(resultado, "achei");
        return resultado;
    }
    return null; // Sem alocação, sem problema
}

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

    // Busca com resultado existente
    if (try buscarValor(allocator, "existente")) |valor| {
        defer allocator.free(valor);
        std.debug.print("Encontrado: {s}\n", .{valor});
    } else {
        std.debug.print("Não encontrado\n", .{});
    }

    // Busca sem resultado — nenhuma memória para liberar
    if (try buscarValor(allocator, "inexistente")) |valor| {
        defer allocator.free(valor);
        std.debug.print("Encontrado: {s}\n", .{valor});
    } else {
        std.debug.print("Não encontrado\n", .{});
    }
}

Visualizando a Memória

Para entender melhor o layout de memória, podemos inspecionar endereços:

const std = @import("std");

var global: i32 = 100; // Segmento de dados

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

    const stack_var: i32 = 42; // Stack
    const heap_var = try allocator.create(i32); // Heap
    defer allocator.destroy(heap_var);
    heap_var.* = 99;

    std.debug.print("Endereços de memória:\n", .{});
    std.debug.print("  Global (dados): {*}\n", .{&global});
    std.debug.print("  Stack:          {*}\n", .{&stack_var});
    std.debug.print("  Heap:           {*}\n", .{heap_var});
    std.debug.print("\nValores:\n", .{});
    std.debug.print("  Global: {d}\n", .{global});
    std.debug.print("  Stack:  {d}\n", .{stack_var});
    std.debug.print("  Heap:   {d}\n", .{heap_var.*});
}

Ao rodar esse programa, você verá que os endereços estão em regiões completamente diferentes, refletindo as diferentes áreas de memória do processo.

Resumo e Próximos Passos

Neste artigo, cobrimos:

  • Stack: memória automática, rápida, de tamanho fixo
  • Heap: memória dinâmica, flexível, gerenciada manualmente
  • defer: o mecanismo de Zig para garantir limpeza de memória
  • Ponteiros opcionais: eliminando null pointer bugs
  • Inspeção de endereços: visualizando o layout de memória

No próximo artigo, vamos mergulhar nos tipos de allocators em Zig — entendendo cada allocator da biblioteca padrão e quando usar cada um.

Leitura Complementar

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.