Erros como valores
O tratamento de erro é onde a filosofia de “sem fluxo de controle escondido” fica mais concreta. Uma exceção é, por definição, controle escondido: ela desvia a execução por um canal que não aparece na assinatura da função nem no ponto da chamada. Zig recusa isso. Uma função que pode falhar carrega a falha no tipo de retorno, o compilador obriga quem chama a lidar com ela, e o desvio de controle continua visível na forma de um try ou de um catch escrito à mão.
O error union e os error sets
Section titled “O error union e os error sets”O bloco de construção é o error union, escrito com um ! antes do tipo do sucesso. anyerror!u32 é “um u32 ou qualquer erro”; na prática você quase sempre deixa o conjunto de erros ser inferido e escreve só !u32. Do outro lado, um error set é um conjunto nomeado de erros possíveis, parecido com um enum mas com semântica própria:
const ErroDeArquivo = error{ NaoEncontrado, SemPermissao };
fn abrir(caminho: []const u8) ErroDeArquivo!std.fs.File { if (caminho.len == 0) return error.NaoEncontrado; // ...}Um detalhe que surpreende quem vem de Rust: os erros de Zig não carregam dados. error.NaoEncontrado é um valor atômico, sem payload, mais próximo de uma etiqueta do que de uma estrutura. Isso é uma escolha deliberada de simplicidade, e a contrapartida é que, quando você precisa de contexto rico sobre a falha, o padrão idiomático é devolver o erro simples e carregar os detalhes num parâmetro de diagnóstico à parte. É menos expressivo que o Result<T, E> do Rust, e mais barato: um erro é sempre um inteiro pequeno.
try, catch e a obrigação de tratar
Section titled “try, catch e a obrigação de tratar”O que impede o erro de ser ignorado é que um !T não é um T. Você não consegue usar o valor sem primeiro resolver o canal de erro, e a linguagem dá dois verbos para isso. O try é a propagação: try abrir(p) significa “se deu erro, devolva esse erro para o meu chamador; senão, siga com o valor”. É açúcar exato para abrir(p) catch |err| return err. O catch é a interceptação no local: ou fornece um valor de reserva, ou abre um bloco para tratar o erro, frequentemente com um switch sobre o error set.
// Propaga para cima e para por aqui se falhar.const arquivo = try abrir(caminho);
// Ou trata na hora, com um valor de reserva.const arquivo2 = abrir(caminho) catch return;
// Ou distingue os casos.const arquivo3 = abrir(caminho) catch |err| switch (err) { error.NaoEncontrado => criarPadrao(), error.SemPermissao => return err,};defer e errdefer: limpeza no caminho certo
Section titled “defer e errdefer: limpeza no caminho certo”Como não há destrutores, a liberação de recursos é escrita à mão, e Zig dá duas ferramentas que rodam na saída do escopo. O defer executa sempre, seja a saída normal ou por erro, e é onde vão os close e os free. O errdefer executa apenas quando a saída é por erro, e é a peça que torna o tratamento de erro seguro em funções que constroem algo em etapas. Se você alocou metade de uma estrutura e a terceira etapa falhou, o errdefer desfaz o que já foi feito antes de o erro subir, sem contaminar o caminho de sucesso com essa limpeza.
fn construir(allocator: std.mem.Allocator) !*Node { const no = try allocator.create(Node); errdefer allocator.destroy(no); // só roda se algo abaixo falhar
no.filho = try allocator.create(Node); // se isto falhar, o no acima é destruído return no; // caminho de sucesso: o errdefer não dispara}Essa combinação entre try que propaga e errdefer que desfaz é o centro do idioma. Um resolve a saída, o outro garante que a saída não deixe lixo, e juntos dão à limpeza manual a confiabilidade que outras linguagens compram com destrutores automáticos.
Ausência também é um valor: o optional
Section titled “Ausência também é um valor: o optional”O error union tem um análogo que resolve o problema vizinho. Se !T é “um T ou uma falha”, o optional ?T é “um T ou nada”, e a mesma disciplina governa os dois: um ?T não é um T, e a linguagem não deixa você usar o valor sem antes tratar o caso nulo. A diferença está só no que o canal extra carrega, uma falha de um lado, uma ausência do outro. Onde outras linguagens deixam o null aparecer em qualquer referência e falhar em tempo de execução, Zig prende a nulidade ao tipo: um *T nunca é nulo, só o ?*T pode ser, exatamente como o modelo de memória já adiantou.
A maquinaria de desempacotar espelha a dos erros. O orelse é o catch do optional, fornecendo um valor de reserva quando vem nulo, e a captura, escrita entre barras verticais, desempacota o valor e lhe dá um nome apenas no ramo em que ele existe.
fn primeiro(slice: []const u8) ?u8 { if (slice.len == 0) return null; // "nada" é um resultado legítimo return slice[0];}
// orelse: um valor de reserva quando o optional é nulo.const inicial = primeiro(entrada) orelse '?';
// A captura |c| desempacota e nomeia o valor só no ramo não-nulo.if (primeiro(entrada)) |c| { std.debug.print("comeca com {c}\n", .{c});} else { std.debug.print("entrada vazia\n", .{});}Esse |c| não é sintaxe exclusiva do optional; é o mesmo mecanismo de captura que aparece no for sobre um slice e reaparece no switch de uma união em structs e composição. O padrão onipresente do iterador nasce dele: while (it.next()) |item| roda enquanto next() devolve um valor e para no primeiro nulo. E quando você tem certeza de que o optional não é nulo e quer afirmá-lo, o .? desempacota à força, equivalente a orelse unreachable, o que vira um panic num binário com as checagens de segurança ligadas se a aposta estiver errada.
Posicionar o optional contra as vizinhas dá o mesmo mapa do error union, um andar acima: o ?T de Zig corresponde ao Option<T> de Rust e ao optional de Swift, com o orelse fazendo o papel do unwrap_or de um e do ?? do outro. A diferença de sempre é que Zig recusa a compilação se você desreferenciar sem tratar o nulo, em vez de confiar na sua disciplina.
Onde isso se encaixa no mapa das linguagens
Section titled “Onde isso se encaixa no mapa das linguagens”Vale posicionar a escolha de Zig contra as vizinhas, porque a mesma ideia aparece com temperamentos diferentes. As quatro linguagens concordam no problema, exceções escondem controle, e divergem só na forma de tratar, e as diferenças cabem em poucas dimensões:
| Linguagem | Erro carrega dados? | Compilador obriga a tratar? | Propaga com |
|---|---|---|---|
Zig (!T) | não (etiqueta sem payload) | sim | try |
Rust (Result<T, E>) | sim (o E) | sim | operador ? |
Go (error) | sim | não, é fácil ignorar por acidente | if err != nil manual |
Effect (canal E) | sim | sim, pelo tipo | combinadores e yield* |
O Result<T, E> do Rust é o parente mais próximo, com o operador ? fazendo o papel do try, mas o E do Rust carrega dados e o de Zig não. O if err != nil de Go também trata erro como valor, mas o deixa fácil de ignorar por acidente, enquanto o error union de Zig recusa a compilação se você não tratar. E o Effect, no mundo do TypeScript, promove a falha ao canal E do tipo Effect<A, E, R> pela mesma razão de fundo: um erro que o compilador conhece é um erro que ele pode cobrar de você.
Palavras-chave
Section titled “Palavras-chave”Conceitos: erro como valor, error union, error set, propagação, interceptação, limpeza no erro, sem payload, error return trace, optional, ausência, captura de payload
Linguagem: !T, ?T, anyerror, try, catch, orelse, .?, if (x) |v|, while (...) |v|, defer, errdefer, error.Nome, switch sobre erro
Contraste: Rust Result, Option, operador ?, Swift optional ??, Go if err != nil, Effect canal E