Abra um arquivo que não existe em Python e open() lança um FileNotFoundError que sobe para fora da sua função. Faça o mesmo em Go e os.ReadFile devolve um valor error ao lado de um slice de bytes nil. Mesma falha, duas formas de código completamente diferentes em volta dela. A diferença não é cosmética. Ela decide se quem chama pode ignorar a falha por acidente, e se o compilador percebe quando isso acontece.
O que exceptions e valores de erro fazem de diferente na prática?
Uma exception é controle de fluxo que salta. Algo falha, a função lança, e a execução sai da pilha de chamadas atual para cair no primeiro bloco catch ou except correspondente, não importa quantos níveis acima isso esteja. A assinatura da função não dá nenhuma pista de que isso pode acontecer. loadConfig(path: string): Config em TypeScript parece exatamente uma função que não pode falhar, até lançar uma exception por um arquivo ausente.
Um valor de erro é dado. A função o devolve junto com, ou no lugar de, o resultado que foi pedido. O (Config, error) do Go, o Result<Config, ConfigError> do Rust, e uma busca que devolve null quando não encontra nada são a mesma ideia: a falha faz parte do tipo de retorno, então quem chama a vê diretamente no ponto da chamada. Em Rust, o sistema de tipos obriga você a tratá-la antes de tocar no resultado. Em Go, isso é convenção.
Nenhuma das duas abordagens é nova, e nenhuma venceu de fato. C devolvia valores sentinela como -1 e NULL; as exceptions vieram depois, para tirar o tratamento de erros da lógica principal. Python, Java, C# e JavaScript usam exceptions por padrão. Go e Rust usam valores de erro por padrão. A maioria das linguagens que adota um padrão ainda deixa você recorrer ao outro quando ele se encaixa melhor.
Como o Go devolve erros como valores?
Go não tem exceptions para falhas comuns. Uma função que pode falhar devolve um error como último valor, e quem a chama verifica isso imediatamente:
| |
%w envolve o erro original em vez de descartá-lo, para que errors.Is e errors.As mais acima na pilha possam verificar o que deu errado sem comparar strings na mensagem.
O ponto fraco: nada impede você de escrever data, _ := os.ReadFile(path) e descartar o erro. O compilador aceita sem reclamar. Um linter como errcheck detecta erros não verificados, mas só se estiver configurado na sua CI.
Como as exceptions do Python saltam para um handler?
O mesmo carregador de configuração em Python não tem verificações de erro em linha depois de cada chamada. A verificação acontece em volta da chamada, não dentro dela:
| |
raise ... from e mantém o traceback original conectado ao novo ConfigError, da mesma forma que o %w do Go mantém o erro original acessível.
A diferença que importa é a assinatura. load_config nunca diz que pode lançar uma exception. Quem a chama três níveis acima, se esqueceu o try, descobre isso quando o programa trava com um traceback em produção, porque nada na linguagem verifica o ponto da chamada.
Como o Result do Rust obriga você a tratar o erro?
Rust mantém a ideia do erro como valor e adiciona um sistema de tipos que não deixa você tocar no valor de sucesso sem reconhecer o caso de falha:
| |
Isso precisa de serde (com a feature derive) e serde_json no Cargo.toml.
Result<T, E> é um enum com duas variantes, Ok(T) e Err(E), e você não consegue tirar uma Config dali sem tratar as duas. O operador ? é um atalho para esse match: se fs::read_to_string devolve Err, load_config retorna imediatamente com esse erro, mapeado para ConfigError::Io. Senão, ele desembrulha o Ok e segue.
Você ainda pode contornar tudo isso com .unwrap(), que entra em panic com Err. Essa é a versão do Rust para uma exception não tratada. Trate isso como algo a ser questionado em code review, não como um atalho.
Por que fetch() não lança uma exception em um 404?
Essa divisão não é exclusiva de uma linguagem: a mesma API pode misturar os dois modelos para falhas diferentes. fetch() rejeita sua promise numa falha de rede (o DNS não resolve, a conexão é recusada) mas resolve normalmente num 404 ou 500. O status HTTP é um valor na response que você já tem: você verifica response.ok, não captura nada. Tratamento de Erros com a Fetch API em JavaScript explica por que isso surpreende quem espera que um erro HTTP lance uma exception, e como envolver fetch() para recuperar um comportamento estilo exception se você quiser.
Quando usar exceptions, e quando devolver erros?
Faça uma pergunta sobre a falha específica: quem chama imediatamente precisa agir sobre ela, ou ela deveria subir até um limite que a trate?
Um cache miss ou um erro de validação na entrada do usuário é um resultado que seu código deveria esperar e sobre o qual ramificar na hora. Um valor de erro se encaixa. Quem chama não consegue fazer seu trabalho sem reagir, então um tipo de retorno que torna a falha visível é uma ajuda, não uma formalidade.
Uma conexão de banco de dados que cai no meio de uma transação, ou uma asserção sobre o estado do programa que se revela falsa, são diferentes. Nada que quem chama imediatamente faça vai resolver isso. Deixar isso se propagar como exception ou panic até um único lugar que registra e encerra de forma limpa é menos código, e código mais honesto, do que fazê-lo atravessar manualmente cada frame.
Linguagens que adotam um modelo padrão já usam o outro onde ele se encaixa. Go entra em panic num índice de slice fora dos limites, porque isso é um bug, não uma condição que quem chama deveria tratar. Rust entra em panic num overflow de inteiro em builds de debug. O dict.get(key, default) do Python devolve um valor em vez de lançar KeyError, para o caso em que uma chave ausente é esperada em vez de excepcional.
O que dá errado com cada estratégia na prática?
Erros engolidos. Um except: vazio que não faz nada, ou um if err != nil { } em Go com corpo vazio, apaga a falha. O sintoma aparece mais tarde como dados ausentes ou uma tela em branco, sem nenhum erro perto da causa real. É o fluxo difícil de rastrear descrito em O Que É Spaghetti Code e Como Refatorá-lo.
Panics na goroutine errada. recover() só funciona dentro da goroutine que entrou em panic. Um recover diferido em main não salva você de um panic numa goroutine worker; o programa inteiro cai. Goroutines e Channels em Go: Guia com Exemplos mostra por que erros de trabalho concorrente deveriam voltar por um channel, como valores.
.unwrap() e um catch genérico em vez de uma decisão. Os dois permitem que você pare de pensar no caso de falha sem realmente fazê-lo desaparecer. Escreva um teste que force o caminho de erro. Isso é muito mais fácil quando a função devolve um erro que você mesmo pode construir do que quando ela lança de dentro de uma biblioteca que você não controla, e é por isso que caminhos de erro têm testes próprios em Teste Unitário vs Teste de Integração vs End-to-End.
Como escolher por função, não por linguagem?
Na próxima vez que você for escrever throw ou return err, olhe para a função na sua frente. Se quem a chama precisa reagir imediatamente, coloque a falha no tipo de retorno: (T, error) em Go, Result em Rust, None ou um default em Python. Se nada de útil pode acontecer até a falha chegar a um handler de nível superior, lance uma exception ou entre em panic e trate isso uma única vez, naquele limite. Depois adicione um teste para o caminho de erro, qualquer que seja o escolhido.