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ão | Características | Velocidade | Gerenciamento |
|---|---|---|---|
| Stack | Tamanho fixo, LIFO | Extremamente rápida | Automático |
| Heap | Tamanho dinâmico | Mais lenta | Manual 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:
- O allocator é passado explicitamente — sem
mallocglobal oculto - O
defergarante que a memória será liberada ao sair do escopo - O
GeneralPurposeAllocatordetecta 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
- Builtins de memória — Funções built-in relacionadas a memória
- Zig para Iniciantes — Se precisa revisar a sintaxe básica
- Receitas: Trabalhando com Slices — Exemplos práticos com slices