Em Rust, o tratamento de erros é uma parte fundamental da construção de software robusto. Embora a linguagem forneça tipos de erro padrão como std::io::Error ou std::num::ParseIntError, muitas vezes precisamos de erros que representem domínios específicos da nossa aplicação. Criar tipos de erro customizados permite que você comunique exatamente o que deu errado e forneça informações contextuais úteis.

Nesta aula, vamos explorar como definir seus próprios tipos de erro, implementar a trait Error (que permite integração com o operador ? e com bibliotecas de logging), e dar uma visão geral de duas bibliotecas populares: thiserror (para definir erros de forma concisa) e anyhow (para tratamento flexível de erros em aplicações).

Definindo tipos de erro

Em Rust, a maneira mais comum de definir um tipo de erro é usando um enum, onde cada variante representa uma categoria diferente de erro. Por exemplo, em um processador de arquivos, você pode ter erros de formato inválido, arquivo não encontrado, ou permissão negada. Cada variante pode carregar dados adicionais relevantes ao erro.

Para que o tipo de erro funcione bem com o ecossistema Rust, ele deve implementar as traits Debug e Display. A trait Debug é usada para formatação de depuração (com {:?}), enquanto Display é usada para mensagens amigáveis ao usuário (com {}). Além disso, para usar o operador ? com seu erro em funções que retornam Result<T, Box<dyn Error>>, você precisa que seu erro implemente a trait Error.

Exemplo de definição de um enum de erro:

use std::fmt;

#[derive(Debug)]
pub enum MeuErro {
    DadoInvalido(String),
    NaoEncontrado { id: u32 },
    ErroInterno(std::io::Error),
}

impl fmt::Display for MeuErro {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MeuErro::DadoInvalido(dados) => write!(f, "Dados inválidos: {}", dados),
            MeuErro::NaoEncontrado { id } => write!(f, "Item com id {} não encontrado", id),
            MeuErro::ErroInterno(erro) => write!(f, "Erro interno: {}", erro),
        }
    }
}

Observe que derivamos Debug automaticamente e implementamos Display manualmente. A implementação de Display é importante para que a mensagem de erro seja legível. Agora, podemos usar esse erro em funções que retornam Result<T, MeuErro>.

trait Error

A trait Error está definida na biblioteca padrão (std::error::Error). Ela exige que o tipo implemente Debug e Display, e fornece métodos como source() para encadear erros. Implementar Error é simples: basta fazer impl std::error::Error for MeuErro {} (a implementação pode ser vazia se você não precisar de métodos extras).

Implementar Error permite que seu erro seja usado com Box<dyn Error> e com o operador ? em funções que retornam Result<T, Box<dyn Error>>. Além disso, bibliotecas de logging e monitoramento podem usar source() para rastrear a cadeia de erros.

Exemplo completo com implementação de Error:

use std::error::Error;
use std::fmt;

#[derive(Debug)]
pub enum MeuErro {
    DadoInvalido(String),
    NaoEncontrado { id: u32 },
    ErroInterno(std::io::Error),
}

impl fmt::Display for MeuErro {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MeuErro::DadoInvalido(dados) => write!(f, "Dados inválidos: {}", dados),
            MeuErro::NaoEncontrado { id } => write!(f, "Item com id {} não encontrado", id),
            MeuErro::ErroInterno(erro) => write!(f, "Erro interno: {}", erro),
        }
    }
}

impl Error for MeuErro {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            MeuErro::ErroInterno(e) => Some(e),
            _ => None,
        }
    }
}

Note que sobrescrevemos o método source() para retornar o erro interno quando aplicável. Isso permite que quem lida com o erro obtenha a causa raiz.

thiserror (visão geral)

A crate thiserror simplifica drasticamente a definição de erros customizados, eliminando a necessidade de escrever manualmente as implementações de Display e Error. Basta derivar a trait thiserror::Error e usar atributos para formatar as mensagens.

Para usar, adicione thiserror = "1" ao Cargo.toml e então:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum MeuErro {
    #[error("Dados inválidos: {0}")]
    DadoInvalido(String),
    #[error("Item com id {id} não encontrado")]
    NaoEncontrado { id: u32 },
    #[error(transparent)]
    ErroInterno(#[from] std::io::Error),
}

O atributo #[error(...)] define a mensagem de Display. O #[error(transparent)] delega a exibição ao erro interno e implementa source() automaticamente. O #[from] gera uma implementação de From para converter automaticamente o erro interno no seu tipo de erro. Isso torna o código muito mais limpo e menos propenso a erros.

Além disso, thiserror implementa Error automaticamente, então você não precisa fazer isso manualmente. É uma excelente escolha para bibliotecas que precisam de erros bem definidos.

anyhow (visão geral)

Enquanto thiserror é voltado para definir erros em bibliotecas, anyhow é focado em aplicações, onde você não quer se preocupar com o tipo exato do erro. anyhow fornece um tipo anyhow::Error que pode encapsular qualquer erro que implemente Error. Ele também oferece a macro bail! para retornar erros rapidamente e o trait Context para adicionar informações contextuais.

Exemplo de uso do anyhow:

use anyhow::{Context, Result};

fn ler_arquivo(caminho: &str) -> Result<String> {
    let conteudo = std::fs::read_to_string(caminho)
        .with_context(|| format!("Falha ao ler o arquivo {}", caminho))?;
    Ok(conteudo)
}

fn main() -> Result<()> {
    let dados = ler_arquivo("config.toml")?;
    println!("{}", dados);
    Ok(())
}

O método with_context adiciona uma mensagem descritiva ao erro original. O tipo Result<T> em anyhow é um alias para Result<T, anyhow::Error>. Isso permite que você use o operador ? com diferentes tipos de erro sem precisar convertê-los explicitamente.

anyhow é ideal para protótipos e aplicações finais, onde a flexibilidade é mais importante que a precisão do tipo de erro. Em bibliotecas, prefira thiserror para expor erros específicos.

Boas práticas e observações finais

Ao projetar erros customizados, considere:

  • Use enums para categorizar erros e inclua dados relevantes em cada variante.
  • Sempre implemente Display e Error (ou use thiserror).
  • Para erros que encapsulam outros erros, implemente source() para permitir rastreamento.
  • Em bibliotecas, exponha seus tipos de erro publicamente; em aplicações, anyhow simplifica o tratamento.
  • Evite usar Box<dyn Error> diretamente; prefira tipos concretos ou anyhow::Error.

Referências

Exercícios

  1. Crie um enum de erro chamado ErroCalculadora com variantes DivisaoPorZero e RaizNegativa. Implemente Display manualmente (não use thiserror).

    ✓ Resposta:
    use std::fmt;
    
    #[derive(Debug)]
    pub enum ErroCalculadora {
        DivisaoPorZero,
        RaizNegativa(f64),
    }
    
    impl fmt::Display for ErroCalculadora {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                ErroCalculadora::DivisaoPorZero => write!(f, "Divisão por zero"),
                ErroCalculadora::RaizNegativa(valor) => write!(f, "Raiz quadrada de número negativo: {}", valor),
            }
        }
    }
    
    impl std::error::Error for ErroCalculadora {}
  2. Usando a crate thiserror, reescreva o enum ErroCalculadora do exercício anterior, de forma concisa.

    ✓ Resposta:
    use thiserror::Error;
    
    #[derive(Error, Debug)]
    pub enum ErroCalculadora {
        #[error("Divisão por zero")]
        DivisaoPorZero,
        #[error("Raiz quadrada de número negativo: {0}")]
        RaizNegativa(f64),
    }
  3. Implemente uma função dividir(a: f64, b: f64) -> Result<f64, ErroCalculadora> que retorna Err(ErroCalculadora::DivisaoPorZero) se b for zero.

    ✓ Resposta:
    fn dividir(a: f64, b: f64) -> Result<f64, ErroCalculadora> {
        if b == 0.0 {
            Err(ErroCalculadora::DivisaoPorZero)
        } else {
            Ok(a / b)
        }
    }
  4. Usando anyhow, escreva uma função ler_numero(caminho: &str) -> anyhow::Result<f64> que lê um número de um arquivo e faz o parsing. Use with_context para adicionar contexto.

    ✓ Resposta:
    use anyhow::{Context, Result};
    
    fn ler_numero(caminho: &str) -> Result<f64> {
        let conteudo = std::fs::read_to_string(caminho)
            .with_context(|| format!("Falha ao ler o arquivo {}", caminho))?;
        let numero: f64 = conteudo.trim().parse()
            .with_context(|| format!("Falha ao fazer parse do conteúdo do arquivo {}", caminho))?;
        Ok(numero)
    }
  5. Explique a diferença principal entre thiserror e anyhow e em qual cenário cada um é mais adequado.

    ✓ Resposta:

    thiserror é usado para definir tipos de erro customizados de forma concisa, focando em bibliotecas onde os erros são bem específicos e fazem parte da API pública. anyhow é usado em aplicações para lidar com erros de forma flexível, sem se preocupar com o tipo exato, permitindo adicionar contexto facilmente. Em resumo: thiserror para bibliotecas, anyhow para aplicações.