Em Rust, o tratamento de erros é uma parte fundamental da linguagem, projetado para ser explícito e seguro. Diferentemente de muitas linguagens que usam exceções, Rust oferece dois mecanismos principais: panic! para erros irrecuperáveis e Result para erros recuperáveis. Nesta aula, exploraremos como usar esses mecanismos de forma idiomática, incluindo o operador ? para simplificar a propagação de erros, técnicas de conversão entre tipos de erro e as melhores práticas para escrever código robusto e expressivo.

panic! vs Result

O panic! é usado para situações em que o programa encontra um estado inválido ou um bug que não pode ser tratado. Quando ocorre um panic!, a thread atual desenrola a pilha (ou aborta, dependendo da configuração) e o programa termina. Exemplos típicos incluem acesso fora dos limites de um array ou divisão por zero. Por outro lado, Result<T, E> é um enum que representa o sucesso (Ok(T)) ou falha (Err(E)) de uma operação que pode falhar de forma esperada, como abrir um arquivo ou fazer uma requisição HTTP. A escolha entre eles depende da natureza do erro: use panic! para erros que indicam bugs ou condições impossíveis, e Result para erros que são esperados e podem ser tratados pelo chamador.

Exemplo de panic!:

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("Divisão por zero!");
    }
    a / b
}

Exemplo de Result:

fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err("Divisão por zero".to_string())
    } else {
        Ok(a / b)
    }
}

No primeiro caso, se b for zero, o programa entra em pânico e para. No segundo, o chamador recebe um Err e pode decidir como lidar com a situação.

O operador ?

O operador ? é uma forma concisa de propagar erros. Ele pode ser usado apenas em funções que retornam Result ou Option. Quando aplicado a um valor Result, ele desempacota o Ok ou retorna o Err imediatamente da função atual. Isso elimina a necessidade de escrever match explícitos para cada chamada que pode falhar, tornando o código mais limpo e legível.

Exemplo sem ?:

fn read_file(path: &str) -> Result<String, std::io::Error> {
    let mut file = match std::fs::File::open(path) {
        Ok(f) => f,
        Err(e) => return Err(e),
    };
    let mut contents = String::new();
    match file.read_to_string(&mut contents) {
        Ok(_) => Ok(contents),
        Err(e) => Err(e),
    }
}

Com ?:

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

Note que o operador ? também funciona com Option: se o valor for None, a função retorna None imediatamente.

Convertendo erros

Frequentemente, uma função que usa ? precisa lidar com diferentes tipos de erro. Por exemplo, uma função que lê um arquivo e faz parsing pode encontrar erros de I/O e erros de parsing. Para usar ? com tipos de erro diferentes, é necessário convertê-los para um tipo comum. Isso é feito implementando o trait From<E> para o tipo de erro alvo. Rust fornece conversões automáticas para tipos como Box<dyn Error> ou String. A crate anyhow e thiserror são populares para simplificar esse processo.

Exemplo usando Box<dyn Error>:

use std::error::Error;

fn process_file(path: &str) -> Result<(), Box<dyn Error>> {
    let content = std::fs::read_to_string(path)?; // io::Error convertido automaticamente
    let number: i32 = content.trim().parse()?; // ParseIntError convertido
    println!("Número: {}", number);
    Ok(())
}

Outra abordagem é definir um tipo de erro próprio e implementar From para cada erro externo. Por exemplo:

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

#[derive(Debug)]
pub struct MyError {
    details: String,
}

impl MyError {
    fn new(msg: &str) -> MyError {
        MyError { details: msg.to_string() }
    }
}

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}", self.details)
    }
}

impl Error for MyError {}

impl From<std::io::Error> for MyError {
    fn from(err: std::io::Error) -> MyError {
        MyError::new(&format!("Erro de I/O: {}", err))
    }
}

impl From<std::num::ParseIntError> for MyError {
    fn from(err: std::num::ParseIntError) -> MyError {
        MyError::new(&format!("Erro de parsing: {}", err))
    }
}

fn process_file(path: &str) -> Result<(), MyError> {
    let content = std::fs::read_to_string(path)?;
    let number: i32 = content.trim().parse()?;
    println!("Número: {}", number);
    Ok(())
}

Boas práticas

Ao lidar com erros em Rust, algumas práticas ajudam a manter o código claro e robusto:

  • Use panic! apenas para bugs ou condições impossíveis: Se um erro pode ser tratado, prefira Result. panic! deve ser reservado para situações como unwrap() em um Option que você sabe que é Some (mas prefira expect() com uma mensagem descritiva).
  • Propague erros com ?: Evite match desnecessários; use o operador ? para delegar o tratamento ao chamador.
  • Defina tipos de erro personalizados para bibliotecas: Em vez de usar String ou Box<dyn Error>, crie um enum que represente os possíveis erros da sua API. Isso permite que os usuários tratem erros específicos.
  • Use crates como thiserror e anyhow: thiserror facilita a definição de tipos de erro com anotações, enquanto anyhow é útil para aplicações que não precisam de tipos de erro específicos, fornecendo anyhow::Result e anyhow::Error.
  • Documente os erros: Em funções que retornam Result, documente quais erros podem ocorrer e em que circunstâncias.

Exemplo de uso de thiserror:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum MyError {
    #[error("Erro de I/O: {0}")]
    Io(#[from] std::io::Error),
    #[error("Erro de parsing: {0}")]
    Parse(#[from] std::num::ParseIntError),
}

fn process_file(path: &str) -> Result<(), MyError> {
    let content = std::fs::read_to_string(path)?;
    let number: i32 = content.trim().parse()?;
    println!("Número: {}", number);
    Ok(())
}

Observações finais

O tratamento de erros em Rust é projetado para ser explícito e seguro, incentivando o programador a considerar todos os caminhos de falha. Dominar o uso de panic!, Result, o operador ? e a conversão de erros é essencial para escrever código Rust idiomático e confiável. Lembre-se de que a escolha entre panic! e Result depende da semântica do erro: erros recuperáveis devem ser modelados com Result, enquanto erros irrecuperáveis (bugs) podem usar panic!. Sempre que possível, prefira propagar erros para que o chamador decida como lidar com eles.

Referências

Exercícios

  1. Crie uma função que lê um arquivo e retorna seu conteúdo como String usando Result. Se o arquivo não existir, retorne um erro personalizado.

    ✓ Resposta:
    use std::fs::File;
    use std::io::{self, Read};
    
    fn read_file(path: &str) -> Result<String, io::Error> {
        let mut file = File::open(path)?;
        let mut contents = String::new();
        file.read_to_string(&mut contents)?;
        Ok(contents)
    }
  2. Escreva uma função que divide dois números e retorna Result<i32, String>. Use o operador ? para propagar o erro de uma função auxiliar que valida a divisão.

    ✓ Resposta:
    fn validate_division(a: i32, b: i32) -> Result<(), String> {
        if b == 0 {
            Err("Divisão por zero".to_string())
        } else {
            Ok(())
        }
    }
    
    fn divide(a: i32, b: i32) -> Result<i32, String> {
        validate_division(a, b)?;
        Ok(a / b)
    }
  3. Defina um enum MyError que possa representar erros de I/O e erros de parsing. Implemente From para ambos os tipos e use em uma função que lê um número de um arquivo.

    ✓ Resposta:
    use std::error::Error;
    use std::fmt;
    
    #[derive(Debug)]
    pub enum MyError {
        Io(std::io::Error),
        Parse(std::num::ParseIntError),
    }
    
    impl fmt::Display for MyError {
        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
            match self {
                MyError::Io(e) => write!(f, "I/O error: {}", e),
                MyError::Parse(e) => write!(f, "Parse error: {}", e),
            }
        }
    }
    
    impl Error for MyError {}
    
    impl From<std::io::Error> for MyError {
        fn from(e: std::io::Error) -> MyError {
            MyError::Io(e)
        }
    }
    
    impl From<std::num::ParseIntError> for MyError {
        fn from(e: std::num::ParseIntError) -> MyError {
            MyError::Parse(e)
        }
    }
    
    fn read_number_from_file(path: &str) -> Result<i32, MyError> {
        let content = std::fs::read_to_string(path)?;
        let number = content.trim().parse()?;
        Ok(number)
    }
  4. Usando a crate thiserror, reescreva o enum MyError do exercício anterior de forma mais concisa.

    ✓ Resposta:
    use thiserror::Error;
    
    #[derive(Error, Debug)]
    pub enum MyError {
        #[error("I/O error: {0}")]
        Io(#[from] std::io::Error),
        #[error("Parse error: {0}")]
        Parse(#[from] std::num::ParseIntError),
    }
    
    fn read_number_from_file(path: &str) -> Result<i32, MyError> {
        let content = std::fs::read_to_string(path)?;
        let number = content.trim().parse()?;
        Ok(number)
    }
  5. Escreva um programa que tenta abrir um arquivo, lê seu conteúdo e imprime. Se o arquivo não existir, imprima uma mensagem amigável e saia. Use panic! ou Result conforme apropriado.

    ✓ Resposta:
    use std::fs::File;
    use std::io::{self, Read};
    
    fn main() {
        match read_file_and_print("example.txt") {
            Ok(()) => println!("Sucesso!"),
            Err(e) => eprintln!("Erro ao ler arquivo: {}", e),
        }
    }
    
    fn read_file_and_print(path: &str) -> Result<(), io::Error> {
        let mut file = File::open(path)?;
        let mut contents = String::new();
        file.read_to_string(&mut contents)?;
        println!("Conteúdo: {}", contents);
        Ok(())
    }