Modelo de memória
Zig não protege você da memória; ele deixa a memória à mostra e espera que você a entenda. Isso torna o modelo de memória o primeiro assunto de verdade da linguagem, e o mais rentável de estudar devagar. A sugestão prática que vale seguir aqui é contraintuitiva: quase não escreva lógica. Faça programas minúsculos que declaram variáveis e imprimem os endereços delas, e observe onde cada coisa cai. Ver os números é o que transforma “stack” e “heap” de conceitos abstratos em coisas concretas.
Stack e heap são dois regimes de tempo de vida
Section titled “Stack e heap são dois regimes de tempo de vida”A distinção entre stack e heap não é sobre velocidade, embora a stack seja mais rápida; é sobre quem controla o tempo de vida do dado. Uma variável na stack vive exatamente enquanto a função que a declarou está executando. Quando a função retorna, seu quadro de pilha some, e qualquer ponteiro que ainda aponte para lá vira uma referência a lixo. O heap é o oposto: um bloco que você aloca vive até você mandar liberá-lo, atravessando fronteiras de função à vontade, ao preço de você nunca poder esquecer de liberá-lo. Essa mecânica de quadros, registradores e o custo de cada camada está detalhada no nível de sistema da trilha de concorrência, e vale a leitura cruzada.
O erro clássico que o modelo de Zig quer que você enxergue é retornar o endereço de uma variável local. O código compila, o ponteiro parece válido, e ele aponta para um quadro de pilha que já foi reciclado. Em Zig isso é problema seu, e é exatamente por isso que a linguagem quer que você veja os endereços: o defer, o errdefer e a passagem explícita de allocator existem para dar disciplina a esse tempo de vida que a linguagem se recusa a gerenciar por você.
Ponteiros que dizem o que são
Section titled “Ponteiros que dizem o que são”Um ponteiro em Zig carrega mais informação no tipo do que em C. Onde C tem um só ponteiro cru que serve para tudo, Zig distingue no tipo se ele aponta para um item ou muitos, se você pode escrever através dele, e se ele pode ser nulo. A nulidade, em particular, mora no tipo opcional, não no ponteiro comum, então um *T normal nunca é nulo, e o compilador cobra que você trate o caso nulo antes de desreferenciar um ?*T. Essa separação elimina por construção uma classe inteira de bugs de ponteiro nulo.
| Tipo | Aponta para | Sabe o comprimento? | Pode ser nulo? |
|---|---|---|---|
*T | um único item | não se aplica | não |
[*]T | muitos itens contíguos | não | não |
[]T (slice) | muitos itens, como janela | sim (.len) | não |
*const T | um item, só leitura | não se aplica | não |
?*T | um item ou nada | não se aplica | sim |
O [*]T é o análogo mais próximo do ponteiro cru de C, um endereço sem comprimento; o []T acrescenta o comprimento e é o que quase toda a biblioteca padrão usa para dizer “uma sequência”. O *const T é a promessa, verificada pelo compilador, de que você não vai escrever através daquele ponteiro.
Array e slice não são a mesma coisa
Section titled “Array e slice não são a mesma coisa”Aqui mora a confusão mais comum de quem chega em Zig. Um array tem o comprimento embutido no tipo: [4]u8 é um tipo diferente de [5]u8, o tamanho é conhecido em tempo de compilação, e o array é um valor, copiado quando atribuído. Um slice, []u8, é outra coisa: um ponteiro gordo, um par de ponteiro para o primeiro elemento mais o comprimento, decidido em tempo de execução. O slice não possui os dados; ele é uma janela sobre memória que vive em outro lugar, seja um array na stack, seja um bloco no heap.
const std = @import("std");
pub fn main() void { var arr = [_]u8{ 10, 20, 30, 40 }; // tipo [4]u8, mora na stack const slice = arr[1..3]; // []u8: aponta para arr, comprimento 2
std.debug.print("endereco do array: {*}\n", .{&arr}); std.debug.print("ponteiro do slice: {*}, len {d}\n", .{ slice.ptr, slice.len }); std.debug.print("mesmo endereco base do item 1? {}\n", .{&arr[1] == slice.ptr});}O que a prosa descreve, o diagrama torna literal: o slice não copia nada, ele guarda um ponteiro para dentro do array e um comprimento que recorta a janela.
flowchart LR
subgraph arr["arr: [4]u8 (na stack)"]
direction LR
e0["10"]
e1["20"]
e2["30"]
e3["40"]
end
S["slice = arr[1..3]<br/>ptr + len = 2"] -. ptr .-> e1
S -. abrange .-> e2
Imprimir os dois lado a lado confirma o desenho: o slice.ptr aponta para dentro do arr, e o slice.len é o comprimento da janela, não do array inteiro. Entender que um slice é ponteiro mais comprimento é o que faz o resto da biblioteca padrão fazer sentido, porque quase toda API que recebe “uma sequência de bytes” recebe um slice.
Sentinel arrays: o terminador no tipo
Section titled “Sentinel arrays: o terminador no tipo”Zig leva a ideia de comprimento no tipo um passo adiante com os sentinel arrays. Um [:0]u8 é um slice cujo elemento logo após o último é garantidamente um zero, e um [N:0]u8 é o array equivalente. O caso onipresente é a string terminada em nulo do mundo C: um literal de string em Zig tem tipo *const [N:0]u8, justamente para poder ser passado a uma função C sem cópia nem conversão. O sentinel é uma promessa verificada pelo tipo de que o terminador está lá, o que dá segurança na fronteira com C sem abrir mão da representação que C espera, um ponto que volta no interop com C.
Alinhamento, o que o hardware exige
Section titled “Alinhamento, o que o hardware exige”Todo tipo tem um alinhamento natural, o endereço em que o processador espera encontrá-lo: um u64 quer morar num endereço múltiplo de 8, um u32 num múltiplo de 4. O compilador cuida disso sozinho na maior parte do tempo, inserindo padding dentro dos structs, e você pode inspecionar com @alignOf(T) e forçar com align(N). Isso parece um detalhe esotérico até dois momentos em que deixa de ser: as instruções SIMD frequentemente exigem que os dados estejam alinhados, sob pena de erro ou lentidão, e os acessos atômicos dependem de alinhamento para funcionar. Em Zig, decisões de layout de memória têm consequências de desempenho que a linguagem não esconde de você, e que reaparecem em SIMD e desempenho.
O kit de std.mem
Section titled “O kit de std.mem”O trabalho braçal com memória crua vive em std.mem, e conhecer o módulo evita reinventar a roda. std.mem.eql compara duas sequências, std.mem.indexOf procura uma subsequência, std.mem.copyForwards copia bytes, e std.mem.span converte um ponteiro com sentinela num slice medindo até o terminador. É também onde mora a própria interface std.mem.Allocator, a peça central do próximo assunto. Nada disso aloca por conta própria, coerente com a promessa da linguagem: as funções operam sobre a memória que você já tem, e quando precisam de mais, pedem um allocator.
Palavras-chave
Section titled “Palavras-chave”Conceitos: stack, heap, tempo de vida, ponteiro, slice, array, sentinel array, alinhamento, padding, ponteiro gordo
Linguagem: *T, [*]T, *const T, ?*T, []u8, [N]u8, [:0]u8, @alignOf, align(N)
Biblioteca: std.mem, std.mem.eql, std.mem.span, std.debug.print
Hardware: memória virtual, página, mmap, alinhamento de SIMD e atômicos