Structs e composição
Depois do modelo de memória e dos allocators, os agregados de Zig quase não trazem novidade: não há herança, não há classes, não há construtores mágicos. A maior parte do que existe aqui se aprende rápido, e o que vale entender é o punhado de decisões que fazem a composição funcionar sem o maquinário de orientação a objetos. A única parte que foge dessa facilidade é como Zig faz polimorfismo em tempo de execução sem uma palavra-chave para isso: à mão, com o mesmo padrão de vtable que sustenta a interface Allocator.
O struct, e o arquivo que também é um
Section titled “O struct, e o arquivo que também é um”Um struct é o agregado central, e nada surpreende na sintaxe. A surpresa boa é conceitual: um struct em Zig é um valor de tipo type, produzido por uma expressão struct { ... }, e não uma declaração de topo com estatuto especial. A consequência mais elegante disso é que um arquivo inteiro é, ele próprio, um struct implícito. Quando você faz @import("outro.zig"), o que volta é um struct cujos campos e declarações são as coisas de topo daquele arquivo. Módulo e namespace, em Zig, são a mesma coisa que struct, e essa unificação é o que dá sentido a std.mem ou std.fs serem só structs aninhados.
Métodos são funções com ponto
Section titled “Métodos são funções com ponto”Zig não tem métodos como entidade separada. O que existe é a convenção de que uma função declarada dentro do namespace de um tipo pode ser chamada com a sintaxe de ponto, passando o receptor como primeiro argumento. lista.append(x) é açúcar para ArrayList.append(lista, x), e o primeiro parâmetro, por convenção chamado self, é *Foo quando o método muta o receptor e Foo quando só o lê.
const Contador = struct { valor: u32 = 0,
fn incrementar(self: *Contador) void { self.valor += 1; } fn ler(self: Contador) u32 { return self.valor; }};Não há this implícito e não há despacho dinâmico embutido: a chamada é resolvida em tempo de compilação, como qualquer função. Quando você precisa decidir em tempo de execução qual código roda, Zig não tem uma palavra-chave para isso; você monta o mecanismo à mão, e vale ver exatamente como, porque é o mesmo padrão que a biblioteca padrão usa por baixo de std.mem.Allocator.
Polimorfismo à mão: o padrão vtable
Section titled “Polimorfismo à mão: o padrão vtable”Um vtable (de virtual table) é a peça que as linguagens de objetos não expõem: uma tabela de ponteiros de função, uma entrada por método que pode variar conforme o tipo concreto. Quando você chama animal.falar() numa linguagem com despacho dinâmico, o que acontece por baixo é uma leitura dessa tabela seguida de uma chamada indireta pelo ponteiro encontrado. Zig não faz isso por você, mas entrega todas as peças para montar, e é assim que se constrói uma interface de verdade na linguagem.
O padrão tem duas metades. Um valor de interface é um ponteiro gordo: um ponteiro para os dados concretos, cujo tipo real a interface finge não conhecer (*anyopaque, o equivalente ao void* de C), e um ponteiro para o vtable, a tabela de funções que sabem operar sobre aqueles dados. O código genérico segura só esse par, e nunca o tipo concreto, que é justamente o que o torna genérico.
const Animal = struct { ptr: *anyopaque, vtable: *const VTable,
const VTable = struct { falar: *const fn (ptr: *anyopaque) []const u8, };
// O método da interface só repassa a chamada para o vtable, // devolvendo o ponteiro de dados que a implementação vai recuperar. fn falar(self: Animal) []const u8 { return self.vtable.falar(self.ptr); }};Cada tipo concreto preenche o vtable com suas próprias funções. A função concreta recebe o *anyopaque e faz o caminho de volta: recupera o tipo real com @ptrCast. Um tipo sem estado ignora esse ponteiro; um com estado o usa para ler seus campos.
const Cachorro = struct { // Sem estado: o ponteiro de dados nem chega a ser lido. fn falar(_: *anyopaque) []const u8 { return "au au"; } const vtable = Animal.VTable{ .falar = falar };};
const Papagaio = struct { frase: []const u8,
fn falar(ptr: *anyopaque) []const u8 { // Aqui o ponteiro de dados importa: é por ele que a frase de // *este* papagaio volta a ser alcançável. const self: *Papagaio = @ptrCast(@alignCast(ptr)); return self.frase; } const vtable = Animal.VTable{ .falar = falar };};O @ptrCast é o momento em que o tipo apagado volta, e também o ponto frágil do padrão: o compilador aceita a conversão sem verificar nada, porque confia que você juntou o ponteiro de dados certo ao vtable certo. Com as duas metades no lugar, montar o valor de interface é só emparelhá-las, e quem consome nunca sabe qual tipo está por baixo:
fn apresentar(a: Animal) void { std.debug.print("{s}\n", .{a.falar()});}
var rex = Cachorro{};var polly = Papagaio{ .frase = "quero cracker" };
apresentar(.{ .ptr = &rex, .vtable = &Cachorro.vtable });apresentar(.{ .ptr = &polly, .vtable = &Papagaio.vtable });Visualmente, o valor de Animal é um par de ponteiros que se separa em duas trilhas, uma para os dados e outra para o código:
flowchart LR A["Animal<br/>(ponteiro gordo)"] --> P["ptr: *anyopaque"] A --> V["vtable: *const VTable"] P -.recuperado por @ptrCast.-> D["dados concretos<br/>Cachorro / Papagaio"] V --> F["falar: fn(*anyopaque)"] F -.-> I["implementação do tipo"]
É exatamente essa a forma de std.mem.Allocator: um *anyopaque de estado e um vtable com alloc, resize e free. Entender o padrão aqui é entender por que qualquer estratégia de memória se encaixa atrás da mesma assinatura.
Enums e a ausência de herança
Section titled “Enums e a ausência de herança”Enums são conjuntos nomeados de valores, com um tipo de tag que você pode fixar (enum(u8)) e métodos como qualquer tipo. Até aqui nada de novo. O ponto interessante é o que Zig oferece no lugar da herança para modelar “uma de várias formas”: a tagged union.
Uma union comum guarda um de vários campos no mesmo espaço de memória, ao estilo de C, sem saber qual está ativo. Uma tagged union, escrita union(enum), embute um enum que registra qual variante está viva, e com isso vira uma união discriminada segura: um valor que é exatamente uma das alternativas, e o compilador sabe qual.
const Valor = union(enum) { inteiro: i64, texto: []const u8, nulo,
fn descrever(self: Valor) []const u8 { return switch (self) { .inteiro => "um numero", .texto => "uma string", .nulo => "nada", }; }};O switch que o compilador confere
Section titled “O switch que o compilador confere”O switch sobre uma tagged union ou um enum é exaustivo: se você esquecer de tratar uma variante, o programa não compila. Essa cobrança é o que torna a tagged union um substituto seguro do polimorfismo de subtipo. Onde uma hierarquia de classes espalharia o comportamento por vários arquivos e confiaria no despacho dinâmico, Zig concentra as alternativas num tipo e força cada ponto de uso a considerar todas elas. Adicionar uma variante nova quebra a compilação em todo switch que ainda não a trata, o que transforma uma classe inteira de bugs de “esqueci de tratar o caso novo” em erros de compilação. É a mesma vantagem que as uniões discriminadas dão em linguagens funcionais, disponível aqui sem coletor de lixo e sem alocação escondida.
A tagged union e o vtable da seção anterior são as duas respostas de Zig para “uma de várias formas”, e a escolha entre elas se resume a poucas dimensões:
| Abordagem | Conjunto de variantes | Garantia do compilador | Custo em runtime |
|---|---|---|---|
Tagged union + switch | fechado | switch exaustivo, cobrado na compilação | um teste de tag, sem indireção |
| Vtable manual | aberto | nenhuma; o @ptrCast é uma promessa sua | uma chamada indireta por ponteiro |
| Herança (outras linguagens) | aberto | varia; em geral sem exaustividade | despacho dinâmico, quase sempre indireto |
A leitura prática é direta: quando o conjunto de variantes é conhecido e fechado, a tagged union ganha, porque a exaustividade é de graça e não há indireção. Quando o conjunto é aberto, decidido por quem usa o código (um plugin, uma estratégia de alocação, um backend trocável em runtime), o vtable é a saída, ao custo da segurança que você passa a garantir na mão.
Tirar o valor de dentro
Section titled “Tirar o valor de dentro”O descrever acima só olha a etiqueta da união, nunca o conteúdo. Mas o motivo de uma tagged union existir é justamente guardar um valor por variante, e o switch é também por onde ele sai. A captura entre barras verticais, a mesma que o optional usa para desempacotar o não-nulo, vincula em cada ramo o valor da variante ativa:
fn dobrar(v: Valor) Valor { return switch (v) { .inteiro => |n| .{ .inteiro = n * 2 }, // n é o i64 de dentro .texto, .nulo => v, // nada a dobrar };}O |n| é o i64 que mora na variante .inteiro, disponível só naquele ramo, porque só ali o compilador sabe que a união é um inteiro. Capturar assim entrega uma cópia do valor; capturar por ponteiro, com |*n|, entrega um ponteiro para o valor dentro da união, e é por ele que você o muta no lugar. É o mesmo mecanismo em toda parte, if (opt) |v|, while (it.next()) |v|, for (slice) |x| e o switch aqui, sempre com o sentido de desempacotar e dar nome ao que está dentro.
Palavras-chave
Section titled “Palavras-chave”Conceitos: struct como valor, arquivo é struct, namespace, método como função, sem herança, polimorfismo em runtime, vtable, ponteiro gordo, apagamento de tipo, união discriminada, switch exaustivo, captura de payload
Linguagem: struct, enum, union(enum), self, @import, @ptrCast, @alignCast, @fieldParentPtr, anyopaque, switch, |captura|, |*captura|
Contraste: classes, herança, this implícito, polimorfismo de subtipo, despacho dinâmico