Error handling com Result e o operador ?

1. Introdução ao tratamento de erros em Rust

Rust adota uma filosofia única para tratamento de erros: sem exceções, sem try/catch, sem surpresas em tempo de execução. Tudo é explícito através do sistema de tipos. A linguagem distingue claramente entre erros recuperáveis — representados pelo tipo Result<T, E> — e erros irrecuperáveis — que disparam panic! e geralmente indicam bugs ou estados inválidos.

Enquanto Option<T> lida com a ausência de um valor (Some/None), Result<T, E> carrega informação sobre por que algo falhou. Essa distinção é fundamental para escrever código robusto e previsível.

2. O tipo Result<T, E> em profundidade

Result é um enum com duas variantes:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Métodos comuns permitem extrair valores ou verificar o estado:

fn dividir(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("divisão por zero"))
    } else {
        Ok(a / b)
    }
}

let resultado = dividir(10.0, 2.0);
println!("is_ok: {}", resultado.is_ok());   // true
println!("is_err: {}", resultado.is_err()); // false

// Cuidado: unwrap pode causar panic!
let valor = resultado.unwrap(); // 5.0

Combinadores permitem transformar Result sem extrair o valor:

let resultado = dividir(10.0, 0.0)
    .map(|v| v * 2.0)           // Só executa se Ok
    .map_err(|e| format!("Erro: {}", e)); // Transforma o erro

// and_then para encadeamento que pode falhar
fn validar_positivo(x: f64) -> Result<f64, String> {
    if x > 0.0 { Ok(x) } else { Err(String::from("não positivo")) }
}

let encadeado = dividir(10.0, 2.0)
    .and_then(validar_positivo);

3. Propagação de erros com match

Antes do operador ?, a propagação manual era feita com match:

use std::fs::File;
use std::io::Read;

fn ler_arquivo(path: &str) -> Result<String, std::io::Error> {
    let mut arquivo = match File::open(path) {
        Ok(f) => f,
        Err(e) => return Err(e),
    };

    let mut conteudo = String::new();
    match arquivo.read_to_string(&mut conteudo) {
        Ok(_) => Ok(conteudo),
        Err(e) => Err(e),
    }
}

Essa abordagem funciona, mas é verbosa. Com múltiplos tipos de erro, o problema se agrava:

fn processar_dados(path: &str) -> Result<i32, String> {
    let mut arquivo = match File::open(path) {
        Ok(f) => f,
        Err(e) => return Err(format!("Erro de IO: {}", e)),
    };

    let mut conteudo = String::new();
    match arquivo.read_to_string(&mut conteudo) {
        Ok(_) => {},
        Err(e) => return Err(format!("Erro de leitura: {}", e)),
    }

    let numero: i32 = match conteudo.trim().parse() {
        Ok(n) => n,
        Err(e) => return Err(format!("Erro de parse: {}", e)),
    };

    Ok(numero * 2)
}

4. O operador ? para propagação concisa

O operador ? revoluciona a propagação de erros. Ele faz duas coisas:
- Se o valor for Ok(T), extrai T e continua
- Se for Err(E), retorna o erro da função atual (com return implícito)

fn ler_arquivo_simples(path: &str) -> Result<String, std::io::Error> {
    let mut arquivo = File::open(path)?;
    let mut conteudo = String::new();
    arquivo.read_to_string(&mut conteudo)?;
    Ok(conteudo)
}

O exemplo anterior com múltiplos erros fica drasticamente mais limpo:

use std::num::ParseIntError;

fn processar_dados_moderno(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let mut arquivo = File::open(path)?;
    let mut conteudo = String::new();
    arquivo.read_to_string(&mut conteudo)?;
    let numero: i32 = conteudo.trim().parse()?;
    Ok(numero * 2)
}

5. Conversão de tipos de erro com From

O operador ? não funciona magicamente com tipos diferentes. Ele usa o trait From<E> para converter automaticamente o erro encontrado no tipo de erro esperado pela função. Quando você usa Box<dyn Error>, a conversão é automática porque Box<dyn Error> implementa From para qualquer tipo que implemente Error.

Para erros customizados, implementamos conversões explícitas:

#[derive(Debug)]
enum MeuErro {
    Io(std::io::Error),
    Parse(ParseIntError),
}

impl From<std::io::Error> for MeuErro {
    fn from(err: std::io::Error) -> MeuErro {
        MeuErro::Io(err)
    }
}

impl From<ParseIntError> for MeuErro {
    fn from(err: ParseIntError) -> MeuErro {
        MeuErro::Parse(err)
    }
}

fn processar_com_erro_customizado(path: &str) -> Result<i32, MeuErro> {
    let mut arquivo = File::open(path)?;  // Erro IO convertido automaticamente
    let mut conteudo = String::new();
    arquivo.read_to_string(&mut conteudo)?;
    let numero: i32 = conteudo.trim().parse()?;  // Erro Parse convertido
    Ok(numero * 2)
}

6. Criando tipos de erro customizados

Para bibliotecas ou sistemas complexos, criar seu próprio tipo de erro é essencial:

use std::fmt;

#[derive(Debug)]
enum ErroProcessamento {
    ArquivoNaoEncontrado(String),
    FormatoInvalido { linha: u32, detalhe: String },
    ErroInterno(String),
}

impl fmt::Display for ErroProcessamento {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ErroProcessamento::ArquivoNaoEncontrado(path) => {
                write!(f, "Arquivo não encontrado: {}", path)
            }
            ErroProcessamento::FormatoInvalido { linha, detalhe } => {
                write!(f, "Formato inválido na linha {}: {}", linha, detalhe)
            }
            ErroProcessamento::ErroInterno(msg) => {
                write!(f, "Erro interno: {}", msg)
            }
        }
    }
}

impl std::error::Error for ErroProcessamento {}

Com a crate thiserror, isso fica muito mais simples:

use thiserror::Error;

#[derive(Error, Debug)]
enum ErroProcessamento {
    #[error("Arquivo não encontrado: {0}")]
    ArquivoNaoEncontrado(String),

    #[error("Formato inválido na linha {linha}: {detalhe}")]
    FormatoInvalido { linha: u32, detalhe: String },

    #[error("Erro interno: {0}")]
    ErroInterno(String),
}

7. Estratégias avançadas com Result

Combinando Result com Option:

fn encontrar_usuario(id: u32) -> Option<String> {
    if id == 1 { Some(String::from("Alice")) } else { None }
}

// Convertendo Option em Result com mensagem de erro
let usuario = encontrar_usuario(2)
    .ok_or("Usuário não encontrado")?;

Erros em coleções com collect():

let numeros = vec!["10", "20", "trinta", "40"];
let resultados: Result<Vec<i32>, ParseIntError> = numeros
    .iter()
    .map(|s| s.parse::<i32>())
    .collect(); // Para no primeiro erro

match resultados {
    Ok(nums) => println!("{:?}", nums),
    Err(e) => println!("Erro no parse: {}", e),
}

Para aplicações, anyhow simplifica o tratamento:

use anyhow::{Context, Result};

fn ler_config() -> Result<String> {
    let path = "config.toml";
    let conteudo = std::fs::read_to_string(path)
        .with_context(|| format!("Falha ao ler {}", path))?;
    Ok(conteudo)
}

8. Boas práticas e padrões comuns

  • unwrap() e expect(): Use apenas em protótipos, testes, ou quando você tem certeza absoluta que o erro não ocorrerá (ex: valores hardcoded).
  • Operador ?: Preferido para a maioria das funções que podem falhar. Torna o código limpo e explícito.
  • Tratamento explícito: Use match quando precisar de lógica diferente para cada variante de erro.
  • Função main(): Pode retornar Result<(), Box<dyn Error>> para usar ? diretamente:
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let conteudo = std::fs::read_to_string("dados.txt")?;
    println!("{}", conteudo);
    Ok(())
}
  • Documentação: Sempre documente quais erros sua função pode retornar, especialmente em APIs públicas.
  • Prefira tipos de erro específicos em bibliotecas (enum customizado) e anyhow em aplicações.

O tratamento de erros em Rust, embora exija mais planejamento inicial, resulta em código mais seguro, previsível e fácil de debugar. O operador ? combinado com conversões automáticas via From torna a propagação de erros tão concisa quanto em linguagens com exceções, mas com toda a segurança de tipos que Rust oferece.

Referências