Nos últimos anos, tornou-se quase um clichê dizer que “exceções são coisa do passado” e que results e erros tipados são o futuro.
Contudo, muitas dessas discussões perdem o ponto principal: não se trata apenas de sintaxe ou de modismo. A questão é como as linguagens de programação modelam falhas — se como um evento de controle de fluxo ou como parte do contrato de tipos de uma função.
Nota: Aqui estou falando apenas de erros esperados e recuperáveis, como validações, falhas de I/O ou operações que podem falhar legitimamente. Erros imprevisíveis, que normalmente interrompem o programa — como panic! em Rust ou RuntimeException em Java — não são considerados neste artigo. Esses pertencem à categoria de falhas irrecuperáveis, e seguem outra filosofia de tratamento.
Duas filosofias opostas: Go e Java
Para entender onde tudo começou, vale comparar duas escolas de pensamento totalmente opostas.
Go — erros como valores simples
Go adota uma abordagem propositalmente simples: sem exceções, sem stack unwinding, sem mágica do compilador.
Toda função que pode falhar retorna um segundo valor — um erro — e cabe a você decidir o que fazer com ele.
1func saveUser(u User) error {
2 if err := validate(u); err != nil {
3 return err
4 }
5 if err := persist(u); err != nil {
6 return err
7 }
8 return nil
9}
10
11func main() {
12 if err := saveUser(user); err != nil {
13 log.Println(err)
14 }
15}
Vantagens
- Previsível e leve: um erro é apenas outro valor de retorno.
- Nenhum controle de fluxo oculto — tudo é explícito.
- Fácil de entender, especialmente para quem está começando.
Desvantagens
- Verbosidade: o
if err != nil
repetido adiciona ruído. - Difícil de compor: o tratamento de erro não encadeia naturalmente.
- Tipagem limitada:
error
é uma interface, não um tipo genérico — o que reduz a precisão. - Um leve, mas, ainda existente, custo de runtime: cada erro é um valor de interface que armazena metadados de tipo e um ponteiro, o que adiciona alguma indireção. Não é dramático, mas não é gratuito.
O principal trade-off no modelo do Go é a simplicidade em detrimento da abstração. Você sempre sabe exatamente o que acontece, mas paga por isso em verbosidade e falta de capacidade de composição.
Java — checked exceptions e tratamento obrigatório
O design do Java apostou no caminho oposto: falhas devem fazer parte do contrato da função.
O compilador força você a lidar com as exceções ou declará-las com throws
.
1public void saveUser(User user) throws ValidationException, SQLException {
2 validate(user);
3 persist(user);
4}
5
6public static void main(String[] args) {
7 try {
8 saveUser(new User());
9 } catch (ValidationException | SQLException e) {
10 log.error(e.getMessage());
11 }
12}
Vantagens
- O compilador impõe disciplina: você não pode ignorar uma falha declarada.
- As assinaturas dos métodos mostram explicitamente quais erros podem ocorrer.
Desvantagens
- Verbosidade e intrusão:
try/catch
se espalha pelas camadas do código. - Custo de performance: lançar exceções dispara o stack unwinding. Isso significa que o runtime precisa percorrer a pilha de chamadas até encontrar um bloco
catch
compatível, executando códigos de limpeza (finally
) pelo caminho. Esse processo envolve capturar stack traces e invalida otimizações — é relativamente caro. - Semântica dividida: o tipo de retorno (
void
,User
, etc.) não representa a falha — é um canal de controle separado.
Versões mais recentes do Java (17+) começaram a experimentar com pattern matching para exceções, e projetos como o Project Amber estão introduzindo um tratamento mais orientado a expressões.
No entanto, esses aprimoramentos são recursos opt-in — tornam as exceções menos dolorosas, mas não mudam o modelo central. Exceções em Java ainda são controle de fluxo em tempo de execução, fora do sistema de tipos.
Rust: um meio-termo que o tornou prático
Rust fica entre esses dois mundos.
Como o Go, rejeita exceções como mecanismo de controle.
Como o Java, torna o tratamento de falhas explícito e verificado pelo compilador.
Tudo gira em torno do tipo Result<T, E>
:
1fn save_user(user: &User) -> Result<(), AppError> {
2 validate(user)?;
3 persist(user)?;
4 Ok(())
5}
O operador ?
é uma abreviação para:
1match validate(user) {
2 Ok(_) => (),
3 Err(e) => return Err(e.into()),
4}
Isso é funcionalmente equivalente ao if err != nil { return err }
do Go, mas com tipagem forte e zero custo de runtime — o compilador expande para um simples retorno condicional.
Não há stack unwinding, nem alocação no heap, nem dynamic dispatch. Erros são apenas dados, representados como enums totalmente conhecidos pelo compilador.
O que Rust herda de cada modelo
Aspecto | Java | Go | Rust |
---|---|---|---|
Erros fazem parte do contrato da função | sim (throws ) |
sim (error como retorno) |
sim (Result<T, E> ) |
Stack unwinding no fluxo normal | sim | não | não |
Tratamento obrigatório pelo compilador | sim | não (disciplina apenas) | sim |
Custo de runtime | alto | baixo (indireção por interface) | desprezível (despacho em tempo de compilação) |
Controle de fluxo explícito | não (lançamento implícito) | sim | sim |
Verbosidade | alta | alta | baixa |
Conceitualmente, Rust está mais próximo de Java em garantias de segurança — você não pode ignorar um erro acidentalmente — mas mais próximo de Go em semântica, já que um erro ainda é um valor de retorno, não um salto de execução.
Quando o modelo de Rust não é perfeito
A abordagem de Rust tem seus próprios trade-offs.
Como toda falha possível precisa aparecer no sistema de tipos, funções podem rapidamente se encher de Result
e Option
aninhados. A composição genérica de erros (From
/Into
ou thiserror
) reduz o ruído, mas adiciona uma camada de abstração que nem sempre compensa em programas pequenos.
Rust também não tem a flexibilidade de “sair de qualquer lugar” que as exceções permitem — às vezes, um throw
é realmente a maneira mais limpa de sair de múltiplas camadas de lógica. A simplicidade do Go também pode parecer mais natural em scripts, ferramentas ou APIs onde os erros são apenas logados e ignorados localmente.
Portanto, embora Rust ofereça segurança e capacidade de composição máximas, ele também exige que o desenvolvedor modele cada falha explicitamente — uma vantagem em sistemas grandes, mas talvez exagero em programas pequenos.
Semântica e visibilidade
A diferença central não está em se a linguagem obriga o tratamento, mas em quanto o compilador sabe sobre o erro.
- Em Java, tanto
User save()
quantoUser save() throws SQLException
retornam o mesmo tipo (User
); o canal de erro existe fora do sistema de tipos. - Em Rust,
fn save() -> Result<User, DbError>
é um tipo distinto — o compilador rastreia cada propagação ao longo da cadeia de chamadas.
É por isso que a abordagem de Rust favorece a composição: você pode mapear, transformar ou mesclar erros como valores de primeira classe, sem que a linguagem precise “saltar” para fora da função.
O panorama geral
Nenhum desses modelos é universalmente “melhor”. Cada um otimiza para um equilíbrio diferente entre segurança, simplicidade e expressividade.
Linguagem | Filosofia | O que otimiza |
---|---|---|
Java | Falhas como controle de fluxo | Segurança por imposição, ao custo de performance e verbosidade |
Go | Falhas como valores | Simplicidade e previsibilidade, ao custo de repetição |
Rust | Falhas como valores tipados | Segurança em tempo de compilação e componibilidade, ao custo de complexidade e verbosidade em assinaturas de tipos grandes |
Com o tempo, as linguagens de programação vêm convergindo para um mesmo princípio: tornar as falhas explícitas, previsíveis e conhecidas pelo tipo.
Rust não inventou essa ideia — apenas deu a ela uma forma prática e ergonômica que se encaixa na programação de sistemas moderna.