Tratamento de erros
Filosofia
Seção intitulada “Filosofia”Erros são valores explícitos. O desenvolvedor tem que lidar com eles. Não há exceções, não há propagação implícita.
Dois eixos: Result e error{...}
Seção intitulada “Dois eixos: Result e error{...}”Falha tem duas perguntas independentes, e a linguagem dá um construto pra cada, em vez de fundir as duas:
Result[T, E]: a resolução, ou seja, deu certo ou não. É umenumbuiltin,{ Ok(T), Err(E) }, que reusa o sum type da seção 2, sem maquinaria nova.error{...}: a união de erros, ou seja, quais falhas existem. Um error-set estrutural e fechado que o compilador conhece exatamente.
Os dois compõem: o E de um Result é um error{...}. Mantê-los separados é o que dá composição automática de erros (abaixo) sem misturar o valor de sucesso com as variantes de falha na mesma união:
fn open(path: string) -> Result[File, error{NotFound, PermissionDenied}]Casar o Result como sujeito (fork)
Seção intitulada “Casar o Result como sujeito (fork)”Casar o Result inteiro como sujeito cobre o Ok e cada variante de erro: exaustivo, e esquecer um caso é erro de compilação. É um nível (padrão aninhado Err(error.X)), não dois matches encaixados. Esta é a forma de fork: sucesso e erro terminam ali (ambos viram, digamos, um Response):
match open(path) { Ok(f) => use(f) Err(error.NotFound) => ... Err(error.PermissionDenied) => ...}Domínio fechado _ é erro de compilação: você cobre todas as variantes, e adicionar uma nova quebra a build até tratá-la (a rede de segurança; um _ aqui a anularia, caindo na variante nova em silêncio). Para o caso sequencial, em que o sucesso continua e só o erro precisa de atenção, use os trailers abaixo (lá o Ok é resolvido pela atribuição, e o erro já vem desembrulhado: você casa error.X direto, sem o Err()).
Trailers de erro: catch (unidade) e match |e| (ramifica)
Seção intitulada “Trailers de erro: catch (unidade) e match |e| (ramifica)”No caso sequencial o Result aparece como trailer de uma operação. O Ok é resolvido pela atribuição no início da linha (você nunca trata o Ok à mão), e o trailer lida só com o erro. Dois trailers, ambos ligam o erro com |e|:
catch |e| { bloco } lida com o erro como unidade: propaga, dá panic, loga. Qualquer E. O bloco é statement e nunca produz o v. Em value-binding (v := op() catch |e|) isso força o handler a escapar: o sucesso dá o Ok ao v, e como o erro não tem valor pra dar, return sai da função (propaga), break sai da loop, panic aborta. (Em statement puro, sem v pra preencher, como o catch do spawn na seção 8, não há o que deixar vazio, então o handler reage e a execução segue; divergir é permitido, não obrigatório.) return aqui é early-return, não há retorno implícito; e é a propagação sem ? operator (o ? é “mágica” que incentiva preguiça; aqui você vê o que acontece):
v := do_something(x) catch |e| { return Err(e) } // propaga, explícitov := do_something(x) catch |e| { return e } // propaga, açúcar → Err(e)v := do_something(x) catch |e| { runtime.panic(e) } // abortaO açúcar return e embrulha em Err() só quando o retorno é um Result[_, E], qualquer Ok com erro E (o _ é o tipo de sucesso, não “sem valor”: ausência-de-valor não é um Result, é o error-set sozinho), e o compilador sabe estaticamente que e é um error-set ⊆ E. Não é coerção geral T → Result; é “um erro, num contexto que espera erro, vira Err”. Sucesso é sempre explícito (return Ok(x)): assimetria proposital, em que o erro propagado é o caminho comum e ganha açúcar, enquanto o sucesso se declara, pra return x nunca ser ambíguo entre valor-ok e valor-de-erro.
match |e| { arms } ramifica por variante de erro. Só faz sentido quando E é uma união (error{E1, E2, …}); como o catch, é statement e não produz o v (no erro não existe Ok), exaustivo sobre as variantes. Em value-binding cada arm escapa (return/break/panic); em statement puro reage e segue, a mesma regra do catch acima:
v := op() match |e| { // E = error{E1, E2, E3} E1 => { log(e); return Err(e) } // trata uma especialmente: loga e propaga E2 | E3 => runtime.panic(e) // agrupa o resto com '|' (fechado → sem '_')}É açúcar pra catch |e| { match e { arms } }: o catch redundante e o e repetido somem, e sobra o match (do erro) que sempre esteve lá. A escolha catch-vs-match é exatamente “lida como unidade (catch) vs ramifica por caso (match)”.
Trailer escapa; recuperar-com-valor é match sobre o Result. Os trailers (catch/match |e|) nunca dão valor ao v: tratam o erro e saem. Quando o erro deve produzir um valor (um default que vira o v), o construto é o match sobre o Result inteiro (o fork acima): o braço Err(...) faz return <valor> e entrega ao v igual o Ok. Pôr recuperação-com-valor num trailer é o erro fácil (este documento já o cometeu), porque o trailer é pra sair, e o match sobre Result é pra resolver com valor.
Regra: match |e| sobre erro de variante única (não-união) é erro de compilação. Não há o que ramificar, então use catch. Capacidade de ramificação declarada e não-exercida é ruído, na mesma régua do var nunca-mutado (seção 6) e do deep desnecessário (seção 5).
Conjunto de variantes: o subconjunto de uma soma fechada
Seção intitulada “Conjunto de variantes: o subconjunto de uma soma fechada”error{NotFound, Timeout} nunca foi um construto exclusivo do erro: é um domínio-soma com um subconjunto de variantes. error é apenas o domínio embutido de variantes abertas (qualquer tag); um enum declarado é um domínio de variantes fixas. Os dois se sub-setam com a mesma notação Domínio{variantes}:
decl Event { Send(Msg); Receive(Msg); Spawn(Pid); Exit(Reason); ChannelBlock(Chan) }
Event{Send, Receive, ChannelBlock} // um Event, garantido ser uma destas trêserror{NotFound, Timeout} // o mesmo conceito, no domínio embutido 'error'(Numa posição de tipo, Nome{...} é um conjunto de variantes, como Event{Send}; em posição de valor, é o literal de struct, como Vec2{1; 2}, seção 14. Não colidem: a posição separa, e o kind reforça, já que enum não tem literal de struct e struct não tem conjunto de variantes.)
Daí a simetria do aberto/fechado: error pelado é aberto (variantes abertas casa com _), Event pelado é fechado (variantes fixas exaustivo sem _), e {...} fecha qualquer um num subconjunto. O caso-limite fecha o sistema: {} com zero variantes é o conjunto vazio, desabitado, nenhuma tag construível. O subconjunto encaixa só num sentido, do estreito para o largo:
Event{Send} ⊆ Event{Send, Receive} ⊆ EventConstruir uma variante dá o conjunto mais estreito, que alarga sozinho pra qualquer superconjunto (igual Err(error.NotFound) vira error{NotFound} e cabe em qualquer error-set maior):
e := Event.Send(msg) // tipo: Event{Send}f(e) // OK se f pede Event{Send, Receive}Compor é unir: o spread nome... (postfix; o variádico é prefix ...int, seção 13) junta as variantes, igual pra erro e enum:
fn open(p) -> Result[File, error{NotFound, PermissionDenied}]fn parse(f) -> Result[Data, error{ParseError, Encoding}]
fn load(p) -> Result[Data, error{open..., parse...}] { // {NotFound, PermissionDenied, ParseError, Encoding} f := open(p) catch |e| { return e } // error{NotFound, PermissionDenied} ⊆ E → encaixa d := parse(f) catch |e| { return e } return Ok(d)}Casar um subconjunto é exaustivo sobre o subconjunto: cobre exatamente aquelas variantes (sem _), e um arm pra variante de fora é erro (inalcançável). Estreitar largoestreito custa um match em runtime; alargar é grátis. Em assinatura, o conjunto barra a fronteira no tipo: fn on_traffic(e: Event{Send, Receive}) recusa um Exit sem check de runtime.
Isto não é subtipagem geral: é a relação restrita que o erro sempre teve (“um conjunto cabe noutro maior”, domínio fechado, checado estaticamente), agora estendida a qualquer soma nominal.
O conjunto de erro vazio, error{}, diz “não erra”. Como {} é desabitado, um Result[T, error{}] tem o Err inconstruível: a função nunca falha. Isso serve à honestidade na fronteira de interface: um concreto que satisfaz uma interface falível (fn read() -> Result[usize, error]) mas que nunca erra declara error{}, e encaixa, porque error{} ⊆ qualquer error-set (o vazio cabe em todos). O leitor vê na assinatura que o Err jamais ocorre, sem o tipo precisar inventar um erro que não acontece nem o concreto abrir mão de cumprir o contrato. É outra pergunta, em outra posição, que o noreturn (seção 14): error{} responde “há erro possível?” (slot do E); noreturn responde “o controle volta ao chamador?” (posição de retorno). Os dois são desabitados por baixo, mas distintos na superfície, e por isso ganham nomes separados.
Abrir uma variante: o pattern e seu binding
Seção intitulada “Abrir uma variante: o pattern e seu binding”Uma variante pode carregar um valor: Send(Msg) leva uma Msg, Circle(float) um float, Rect(float, float) dois, Quit nada. É o que separa esta soma do enum de constantes (Go, C): lá enum State { Running, Paused } são rótulos sem dado, e o valor é só “qual rótulo”; aqui cada variante é como uma struct própria atrás de um rótulo, e o enum-de-constantes é o caso particular em que nenhuma variante carrega nada. Dois pontos costumam tropeçar quem chega daí:
A variável é do tipo da soma, não da variante. Quando um valor tem tipo Shape (digamos s: Shape = pick(), de fn pick() -> Shape), Circle não é o tipo dele, é uma das formas que um Shape pode ter. Por isso um match sobre ele cobre Rect também: o compilador olha o tipo (Shape, que admite as duas formas), não a linha, e em geral só em runtime se sabe a forma. A exaustividade é do tipo. (Construir uma variante direto dá o tipo mais estreito, não a soma inteira: s := Shape.Circle(2.0) tem tipo Shape{Circle}, que alarga pra Shape sozinho ou por anotação, seção 7. Por isso o match de dois braços abaixo anota s: Shape pra segurar qualquer forma; um s := Shape.Circle(2.0) pelado seria um Shape{Circle}, casado só pelo braço Circle.)
O nome dentro do pattern é um binding novo, não algo de fora. A mesma variante, construindo e abrindo:
decl Shape { Circle(float); Rect(float, float) } // DECLARA: a forma e o que ela carregas: Shape = Shape.Circle(2.0) // CONSTRÓI: enche a "caixa" Circle com 2.0 (tipado Shape pra segurar qualquer forma)match s { Circle(r) => area(r) // ABRE: se for Circle, chame de 'r' o que está dentro Rect(w, h) => w * h // ABRE: puxe os dois valores como 'w' e 'h'}Na construção (Circle(2.0)) o parêntese é entrada: você empurra 2.0 pra dentro. No pattern (Circle(r)) é saída: o conteúdo sai e ganha o nome r. Mesmo miolo Circle(...), direção oposta; só a posição (à esquerda do =>, num match) diz qual é. (Na construção você nomeia o tipo, Shape.Circle; no pattern o Shape. some, porque o match já fixou que s é um Shape.) O r é um binding fresco que vale só naquele arm, como o x de loop x in xs, o e de catch |e|, o f de fn(f) => …: nome introduzido por posição, sem var/:=. E é o acesso guardado ao payload: r só existe no braço onde você provou a forma Circle, então não há como ler o float de um Rect (ele nem está lá). A convenção que desfaz a ambiguidade visual é variante Capitalizada, binding minúsculo (seção 14): o de fora o compilador casa como construtor da soma, o de dentro é nome novo. Quando o payload não interessa, _ casa a forma e descarta; | casa várias formas de uma vez.
Quem encheu a caixa pode não ser você, e é o que se vê ao ler a stdlib. A derivação de serialize (seção 10) casa sobre reflect(T).kind, um valor de soma que o compilador montou, já na forma certa pra T:
comptime match reflect(T).kind { Struct(s) => comptime loop f in s.fields { serialize(f.get(v), out) } Tuple(parts) => comptime loop p in parts { serialize(p.get(v), out) } Slice(_) | Array(_, _) => loop x in v { serialize(x, out) } Int(_) | Float(_) | Bool => out.write(v.to_bytes()) ... // Enum, Optional, String, e os fail (mold completo na seção 10)}Se T é uma struct, o compilador entregou o kind na forma Struct carregando o descritor da struct. Struct(s) abre isso e chama o descritor de s, e por isso s.fields (a lista de campos) funciona dentro do braço. O s não traz dado próprio: recebe o que o kind já carregava, igual ao r de Circle(r), só que quem encheu a caixa foi reflect, não um Circle(2.0) seu. Os outros braços são a mesma operação: Tuple(parts) abre na lista de elementos posicionais, Slice(_) casa a forma e descarta o conteúdo, Int(_) | Float(_) | Bool casa três formas sem olhar payload. Sempre o mesmo gesto: casar a forma e, se quiser, nomear o que ela carrega.
Tratar vs propagar
Seção intitulada “Tratar vs propagar”catch |e| { return e }(oureturn Err(e)) propagar, conciso.catch |e| { ... }tratar como unidade e sair (panic, ou log-e-propaga). Todo caminho diverge.match |e| { ... }tratar ramificando e sair por variante. Erro união; todo arm diverge.match resultado { Ok/Err }fork / recuperar: trata sucesso e erro juntos e é quem produz o valor (cada arm dáreturndo valor à variável). É aqui, não no trailer, que se recupera com um fallback.
Sucesso sem valor: o error-set sozinho
Seção intitulada “Sucesso sem valor: o error-set sozinho”Operação que dá certo sem produzir valor não usa Result, porque a linguagem não tem unit, então não existe Result[(), E]. Ela devolve só o error-set: fn send(...) -> error{ProcessDown}. O tipo diz “esta falha, ou nada (sucesso)”: sucesso é a ausência de erro, sem payload e sem null, porque o valor é tagueado (ou uma tag de erro, ou a tag de sucesso, que não carrega nada).
Como não há valor, não há braço de Ok: você usa um trailer, e o sucesso é o cair-fora (a execução segue pra próxima linha):
chan <- data catch |e| { return Err(e) } // erro → propaga; sucesso → segue (nada a ligar)Pra ramificar por variante de erro (reagir a uma, propagar outra) é o match |e| (seção 14); um arm que repete diverge com continue. Um chan <- data local aqui só tem o ProcessDown terminal (um buffer cheio bloqueia o sender, seção 5, em vez de devolver erro), então você o propaga; uma operação com um erro genuinamente retentável é a forma loop-e-continue.
É esse o “error-as-value” das falhas de processo: enviar para um processo morto não lança nada, devolve error.ProcessDown, tratado como qualquer outro erro. (Não existe erro FullBuffer: um canal limitado cheio bloqueia o sender (backpressure, seção 5) e um peer morto dá ProcessDown, então um send nunca precisa de uma variante “buffer cheio”.)
Múltiplos retornos: tupla, error-set, ou Result
Seção intitulada “Múltiplos retornos: tupla, error-set, ou Result”O sucesso pode carregar mais de um valor, e há três formas de retorno, uma por combinação de “tem valor(es)?” × “tem erro?”, sem sobreposição:
- Só valor(es), sem erro uma tupla
(a, b): grupo de valores entre parênteses, separados por vírgula. A grafia é a mesma na assinatura, noOke noreturn. (Parênteses são o token de grupo-de-valores em toda a linguagem, seja em argumentos, agrupamento aritmético ou tupla; a vírgula distingue tupla de simples agrupamento. As chaves{}ficam para corpos, literais e conjuntos de variantes, incluindo error-sets.) - Só erro, sem valor o error-set sozinho (
-> error{...}, acima): sucesso é a ausência de erro, sem payload. - Valor(es) + erro sempre
Result[V, E], comVsendo a tupla quando há mais de um valor.
fn split(s: string) -> (string, string) // valores, sem erro → tuplafn send(...) -> error{ProcessDown} // sucesso sem valor → error-setfn divmod(a: int, b: int) -> Result[(int, int), error{DivZero}] // valores + erro → ResultNo Ok, a tupla destrincha esquerdadireita pras variáveis (Ok((q, r))), e o return casa por posição (return (q, r)).
Erro e valor não coexistem como lista posicional, e por isso não existe a forma (u8, error). Result é exclusivo: Err quer dizer que não há valor. Misturar valor e erro num retorno posicional sugeriria “u8 e erro juntos”, que é justamente o que não acontece, já que no Err não há u8. A fronteira é dura: assim que entra um erro, a forma é Result. Ali a exclusividade é explícita (Ok ou Err, nunca os dois) e o “isto pode falhar” fica proeminente no tipo, não enterrado num slot. Tupla é só valor; valor(es)+erro é Result; só-erro é o error-set sozinho. São três formas, sem sobreposição e sem desaçucaração, e isso decorre da explicitude: onde pode falhar, o Result grita; ele não vira um slot escondido no meio de valores.
Escape hatch: erros não-tipados
Seção intitulada “Escape hatch: erros não-tipados”Para erros externos (FFI, bibliotecas de terceiros) onde você não controla a origem nem conhece o conjunto, use um error aberto (sem o {...} que o fecha num conjunto): não-exaustivo, exige _:
fn call_external() -> Result[Data, error] // error aberto: conjunto desconhecido