Em Rust, o tratamento de erros é uma parte fundamental da linguagem, projetado para ser seguro e expressivo. Diferente de muitas linguagens que usam exceções, Rust oferece dois caminhos principais: panic! para erros irrecuperáveis e Result para erros recuperáveis. Nesta aula, exploraremos como usar esses mecanismos de forma idiomática, com foco em código claro e manutenível.

panic! vs Result

O macro panic! é usado para situações em que o programa encontra um erro do qual não pode se recuperar, como um índice de array fora dos limites ou uma falha de invariante lógica. Quando panic! é chamado, o programa imprime uma mensagem de erro, desenrola a pilha (opcionalmente) e termina. Exemplo:

fn main() {
    let v = vec![1, 2, 3];
    v[99]; // isso causa panic!
}

Por outro lado, Result<T, E> é um enum que representa o sucesso (Ok(T)) ou falha (Err(E)) de uma operação. É usado para erros esperados e recuperáveis, como falha ao abrir um arquivo ou ao fazer parsing de uma string. Exemplo:

use std::fs::File;

fn open_file(path: &str) -> Result<File, std::io::Error> {
    File::open(path)
}

A escolha entre panic! e Result depende da gravidade do erro. Use panic! para bugs ou estados impossíveis; use Result para erros que o chamador pode e deve tratar.

O operador ?

O operador ? é uma forma concisa de propagar erros. Ele pode ser usado em funções que retornam Result ou Option. Se o valor for Ok ou Some, ele extrai o valor interno; caso contrário, retorna o erro ou None prematuramente. Exemplo:

use std::fs::File;
use std::io::{self, Read};

fn read_file(path: &str) -> Result<String, io::Error> {
    let mut file = File::open(path)?; // se Err, retorna o erro
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

O operador ? só pode ser usado em funções que retornam Result, Option ou tipos que implementam FromResidual. Ele reduz drasticamente o código boilerplate de match e torna o fluxo mais limpo.

Convertendo erros

Frequentemente, precisamos converter um tipo de erro em outro. Rust usa traits como From e Into para isso. Por exemplo, ao combinar operações que retornam std::io::Error e std::num::ParseIntError, podemos criar um tipo de erro personalizado e implementar From para cada um. Exemplo:

use std::fs::File;
use std::io::{self, Read};
use std::num::ParseIntError;

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

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

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

fn read_and_parse(path: &str) -> Result<i32, MyError> {
    let mut file = File::open(path)?; // converte io::Error em MyError
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    let num = contents.trim().parse::<i32>()?; // converte ParseIntError
    Ok(num)
}

O operador ? usa From automaticamente para converter o erro para o tipo de retorno. Também podemos usar map_err para conversões explícitas.

Boas práticas

Ao tratar erros em Rust, siga estas boas práticas:

  • Prefira Result a panic! para erros que podem ser tratados pelo chamador. panic! é para bugs ou situações irremediáveis.
  • Use o operador ? para propagar erros de forma concisa, a menos que precise de tratamento adicional.
  • Crie tipos de erro personalizados usando enumerações, implementando Display e Error (do trait std::error::Error). Isso facilita a depuração e o uso de bibliotecas como anyhow e thiserror.
  • Não engula erros com unwrap() ou expect() em código de produção. Use-os apenas em protótipos ou quando o erro é impossível.
  • Documente os erros que sua função pode retornar, especialmente em APIs públicas.
  • Considere usar crates como anyhow para erros ad-hoc em aplicações, e thiserror para bibliotecas que precisam de tipos de erro bem definidos.

Exemplo de tipo de erro com Display e Error:

use std::fmt;

#[derive(Debug)]
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, "IO error: {}", e),
            MyError::Parse(e) => write!(f, "Parse error: {}", e),
        }
    }
}

impl std::error::Error for MyError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            MyError::Io(e) => Some(e),
            MyError::Parse(e) => Some(e),
        }
    }
}

Referências

Exercícios

  1. Escreva uma função que divide dois números, retornando Result<f64, String>. Se o divisor for zero, retorne um erro com a mensagem "division by zero".

    ✓ Resposta:
    fn divide(a: f64, b: f64) -> Result<f64, String> {
        if b == 0.0 {
            Err(String::from("division by zero"))
        } else {
            Ok(a / b)
        }
    }
  2. Use o operador ? para reescrever a função abaixo de forma mais concisa:

    fn read_file(path: &str) -> Result<String, std::io::Error> {
        let result = std::fs::read_to_string(path);
        match result {
            Ok(s) => Ok(s),
            Err(e) => Err(e),
        }
    }

    ✓ Resposta:
    fn read_file(path: &str) -> Result<String, std::io::Error> {
        std::fs::read_to_string(path)
    }
  3. Crie um tipo de erro personalizado MyError que possa conter um io::Error ou um ParseIntError, e implemente From para ambos. Depois, escreva uma função que leia um arquivo, parseie um número e retorne o número como i32, usando ?.

    ✓ Resposta:
    use std::fs;
    use std::io;
    use std::num::ParseIntError;
    
    #[derive(Debug)]
    enum MyError {
        Io(io::Error),
        Parse(ParseIntError),
    }
    
    impl From<io::Error> for MyError {
        fn from(err: io::Error) -> MyError {
            MyError::Io(err)
        }
    }
    
    impl From<ParseIntError> for MyError {
        fn from(err: ParseIntError) -> MyError {
            MyError::Parse(err)
        }
    }
    
    fn read_and_parse(path: &str) -> Result<i32, MyError> {
        let contents = fs::read_to_string(path)?;
        let num = contents.trim().parse::<i32>()?;
        Ok(num)
    }
  4. Explique por que é considerado má prática usar unwrap() em código de produção e dê um exemplo de como substituí-lo por um tratamento adequado.

    ✓ Resposta:

    unwrap() causa panic! se o valor for Err ou None, o que pode derrubar o programa inesperadamente. Em produção, é melhor tratar o erro adequadamente, por exemplo, retornando um Result para o chamador ou usando match para lidar com cada caso. Exemplo:

    // Ruim:
    let file = File::open("foo.txt").unwrap();
    
    // Bom:
    let file = match File::open("foo.txt") {
        Ok(f) => f,
        Err(e) => return Err(e),
    };
  5. Implemente uma função que retorne Result<Vec<String>, io::Error> que leia um arquivo linha por linha e retorne um vetor com as linhas (ignorando linhas vazias). Use o operador ?.

    ✓ Resposta:
    use std::fs::File;
    use std::io::{self, BufRead, BufReader};
    
    fn read_lines(path: &str) -> Result<Vec<String>, io::Error> {
        let file = File::open(path)?;
        let reader = BufReader::new(file);
        let mut lines = Vec::new();
        for line in reader.lines() {
            let line = line?;
            if !line.is_empty() {
                lines.push(line);
            }
        }
        Ok(lines)
    }