Em Rust, o tratamento de erros é uma parte fundamental da construção de programas robustos. Embora a linguagem forneça tipos de erro padrão como std::io::Error, muitas vezes é necessário criar seus próprios tipos de erro para representar falhas específicas do domínio da aplicação. Nesta aula, veremos como definir tipos de erro customizados, implementar a trait Error, e utilizar as bibliotecas thiserror e anyhow para simplificar o processo.

Erros customizados permitem que você adicione informações contextuais, como mensagens descritivas e causas internas, facilitando a depuração e a propagação de erros. Além disso, ao definir seus próprios tipos, você pode aproveitar o sistema de tipos do Rust para garantir que todos os erros sejam tratados de forma adequada.

Definindo tipos de erro

Em Rust, a maneira mais comum de definir um tipo de erro é através de uma enumeração. Cada variante da enum pode representar um tipo diferente de falha, com campos opcionais para armazenar dados adicionais. Por exemplo, considere um módulo que lida com operações matemáticas e pode gerar erros como divisão por zero ou raiz quadrada de número negativo.

#[derive(Debug)]
enum MathError {
    DivisionByZero,
    NegativeSquareRoot(f64),
    Overflow,
}

Para que esse tipo possa ser usado como um erro padrão em Rust, é necessário implementar a trait std::fmt::Display (para exibição de mensagens amigáveis) e, opcionalmente, a trait std::error::Error. A implementação de Display é obrigatória para que o erro possa ser impresso com {} ou usado em macros como println!.

use std::fmt;

impl fmt::Display for MathError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MathError::DivisionByZero => write!(f, "division by zero"),
            MathError::NegativeSquareRoot(n) => write!(f, "cannot take square root of negative number: {}", n),
            MathError::Overflow => write!(f, "numeric overflow"),
        }
    }
}

Com Display implementado, podemos usar o erro em funções que retornam Result<T, MathError>. A trait Error é opcional, mas recomendada para integração com bibliotecas que esperam erros genéricos, como Box<dyn Error>.

trait Error

A trait std::error::Error é a base para todos os erros em Rust. Ela exige a implementação de Display e Debug, e fornece métodos como source() para encadear erros. Implementar Error para seu tipo customizado permite que ele seja usado com o operador ? e seja convertido para Box<dyn Error>.

use std::error::Error;

impl Error for MathError {}

Se o seu erro encapsular outro erro (por exemplo, um erro de I/O), você pode sobrescrever o método source() para retornar uma referência ao erro original. Isso facilita a depuração, pois ao imprimir a cadeia de erros, o erro raiz é exibido.

#[derive(Debug)]
enum FileError {
    NotFound(String),
    PermissionDenied,
    IoError(std::io::Error),
}

impl fmt::Display for FileError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            FileError::NotFound(path) => write!(f, "file not found: {}", path),
            FileError::PermissionDenied => write!(f, "permission denied"),
            FileError::IoError(e) => write!(f, "I/O error: {}", e),
        }
    }
}

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

Com a trait Error implementada, você pode usar Box<dyn Error> como tipo de retorno para funções que podem gerar diferentes tipos de erro.

thiserror (visão geral)

A crate thiserror é uma biblioteca de macros que simplifica a implementação da trait Error e Display para seus tipos de erro. Com ela, você define um enum e anota com #[derive(thiserror::Error)], e a macro gera automaticamente as implementações necessárias.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum MathError {
    #[error("division by zero")]
    DivisionByZero,
    #[error("cannot take square root of negative number: {0}")]
    NegativeSquareRoot(f64),
    #[error("numeric overflow")]
    Overflow,
}

O atributo #[error("...")] define a mensagem de Display, onde {0}, {1}, etc. referem-se aos campos do enum. A macro também implementa Error automaticamente. Se houver um campo que é um erro (por exemplo, um std::io::Error), você pode usar #[source] para indicar que ele é a causa do erro.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum FileError {
    #[error("file not found: {0}")]
    NotFound(String),
    #[error("permission denied")]
    PermissionDenied,
    #[error(transparent)]
    IoError(#[from] std::io::Error),
}

O atributo #[from] gera uma implementação de From<std::io::Error> para FileError, permitindo usar o operador ? diretamente com funções que retornam std::io::Error. O #[error(transparent)] faz com que o erro delegue a exibição e a fonte para o erro interno.

Para usar thiserror, adicione a dependência ao seu Cargo.toml:

[dependencies]
thiserror = "1"

anyhow (visão geral)

A crate anyhow fornece um tipo de erro dinâmico anyhow::Error que pode encapsular qualquer erro que implemente Display e Debug. Ela é útil para prototipagem e para funções que não precisam expor tipos de erro específicos, como binários e scripts.

use anyhow::{Result, Context};

fn read_config(path: &str) -> Result<String> {
    std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config file from {}", path))
}

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

O método with_context adiciona uma mensagem contextual ao erro, facilitando a depuração. Você também pode usar bail! para retornar um erro imediatamente, e ensure! para verificar condições.

use anyhow::{bail, ensure, Result};

fn divide(a: f64, b: f64) -> Result<f64> {
    ensure!(b != 0.0, "division by zero");
    Ok(a / b)
}

fn main() -> Result<()> {
    let result = divide(10.0, 0.0)?;
    println!("{}", result);
    Ok(())
}

anyhow é frequentemente usado em conjunto com thiserror: thiserror para definir erros específicos em bibliotecas, e anyhow para lidar com esses erros em aplicações. Adicione a dependência:

[dependencies]
anyhow = "1"

Boas práticas

  • Use thiserror para definir erros em bibliotecas, pois expõe tipos concretos que os usuários podem tratar.
  • Use anyhow em aplicações (binários) para simplificar o tratamento de erros, especialmente quando não importa o tipo exato do erro.
  • Sempre implemente Display e Debug para seus erros customizados.
  • Evite usar Box<dyn Error> diretamente se você puder usar um tipo concreto, pois isso facilita a manutenção.

Referências

Exercícios

  1. Crie um tipo de erro ConfigError com variantes NotFound(String) e InvalidFormat(String). Implemente Display manualmente.

    ✓ Resposta:
    use std::fmt;
    
    #[derive(Debug)]
    enum ConfigError {
        NotFound(String),
        InvalidFormat(String),
    }
    
    impl fmt::Display for ConfigError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                ConfigError::NotFound(path) => write!(f, "config file not found: {}", path),
                ConfigError::InvalidFormat(detail) => write!(f, "invalid config format: {}", detail),
            }
        }
    }
  2. Implemente a trait Error para o tipo ConfigError do exercício anterior.

    ✓ Resposta:
    use std::error::Error;
    
    impl Error for ConfigError {}
  3. Reescreva o tipo ConfigError usando a crate thiserror.

    ✓ Resposta:
    use thiserror::Error;
    
    #[derive(Error, Debug)]
    pub enum ConfigError {
        #[error("config file not found: {0}")]
        NotFound(String),
        #[error("invalid config format: {0}")]
        InvalidFormat(String),
    }
  4. Crie uma função que lê um arquivo de configuração e retorna Result<String, ConfigError> (usando thiserror). Use std::fs::read_to_string e mapeie o erro de I/O para ConfigError::NotFound.

    ✓ Resposta:
    use std::fs;
    use thiserror::Error;
    
    #[derive(Error, Debug)]
    pub enum ConfigError {
        #[error("config file not found: {0}")]
        NotFound(String),
        #[error("invalid config format: {0}")]
        InvalidFormat(String),
    }
    
    fn read_config(path: &str) -> Result<String, ConfigError> {
        let content = fs::read_to_string(path)
            .map_err(|_| ConfigError::NotFound(path.to_string()))?;
        // Simula validação de formato
        if content.is_empty() {
            return Err(ConfigError::InvalidFormat("empty file".to_string()));
        }
        Ok(content)
    }
  5. Modifique a função do exercício anterior para usar anyhow::Result e adicione contexto com with_context.

    ✓ Resposta:
    use anyhow::{Context, Result};
    
    fn read_config(path: &str) -> Result<String> {
        let content = std::fs::read_to_string(path)
            .with_context(|| format!("failed to read config file from {}", path))?;
        if content.is_empty() {
            anyhow::bail!("config file is empty");
        }
        Ok(content)
    }