Manejo de errores en Rust

Aprende Rust → Lección 7

Rust no tiene excepciones. Un error no interrumpe el programa por su cuenta ni viaja invisible por la pila de llamadas: es un valor corriente que una función devuelve y que alguien tiene que mirar. Esta lección explica las dos cajas donde viven esos valores, el operador que evita escribir cien líneas de comprobaciones, y cuándo está justificado rendirse y detener el programa.

Dos cajas: una para lo que puede faltar, otra para lo que puede fallar

Rust no tiene null. Tampoco tiene un mecanismo para lanzar errores hacia arriba sin avisar. En su lugar hay dos tipos que envuelven el resultado y obligan a abrirlo antes de usarlo.

Option y Result Option<T> para lo que puede no existir Some(valor) hay algo dentro None no hay nada, y está bien Sustituye a null: el compilador no te deja usar el valor sin comprobar antes el caso vacío. Result<T, E> para lo que puede salir mal Ok(valor) salió bien Err(error) salió mal, y por qué Sustituye a las excepciones: el fallo viaja en el valor de retorno, a la vista.
La diferencia es la intención: None es una ausencia normal; Err es algo que no debería haber pasado.

Una función que puede no encontrar nada devuelve un Option:

fn dividir(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 {
        None
    } else {
        Some(a / b)
    }
}

Y una que puede fallar devuelve un Result, donde el error lleva información sobre qué ocurrió:

use std::fs;

fn main() {
    match fs::read_to_string("config.txt") {
        Ok(texto) => println!("{texto}"),
        Err(e) => eprintln!("No se pudo leer la configuración: {e}"),
    }
}

Fíjate en lo que acaba de pasar: no hubo que acordarse de comprobar nada. Si intentas usar el contenido del archivo sin abrir el Result, el programa no compila. El olvido, que es la causa de la mayoría de los errores mal gestionados en otros lenguajes, aquí no es una opción.

Abrir la caja sin escribir un match cada vez

Escribir un match completo para cada operación sería agotador, así que ambos tipos traen métodos para los casos frecuentes.

Método Qué hace Cuándo usarlo
unwrap_or(x) Devuelve el valor o x si no hay Cuando tienes un valor por defecto razonable
unwrap_or_else(f) Igual, pero calcula el defecto solo si hace falta Cuando el valor por defecto es caro de obtener
map(f) Transforma el valor si existe, y si no lo deja igual Para encadenar operaciones sin desempaquetar
ok() Convierte un Result en Option, descartando el error Cuando el motivo del fallo da igual
expect("mensaje") Saca el valor o detiene el programa con ese mensaje Solo cuando un fallo ahí es realmente imposible
unwrap() Igual, pero sin mensaje Prototipos y pruebas; casi nunca en producción

Encadenados se leen bastante bien. Este ejemplo lee un puerto de una variable de entorno y recurre al 8080 si no está definida o si no es un número:

use std::env;

fn main() {
    let puerto: u16 = env::var("PUERTO")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(8080);

    println!("Escuchando en el puerto {puerto}");
}

El operador ?, que es donde Rust se vuelve cómodo

Hasta aquí todo se gestiona en el sitio donde ocurre. Pero lo habitual es que una función no sepa qué hacer con el error y prefiera devolvérselo a quien la llamó. Para eso está el signo de interrogación.

Puesto detrás de una expresión que devuelve Result, hace dos cosas según el caso: si es Ok, saca el valor y sigue; si es Err, abandona la función ahí mismo y devuelve ese error.

El operador interrogación leer_archivo()? devuelve un Result Ok Err Saca el valor y la función continúa Sale de la función devolviendo ese mismo error sigue la línea siguiente lo gestiona quien llamó
Una sola marca sustituye al match de propagación que habría que escribir a mano.

La diferencia en el código es considerable. Sin el operador, propagar un error se escribe así:

use std::fs;
use std::io;

fn leer_usuario() -> Result<String, io::Error> {
    let texto = match fs::read_to_string("usuario.txt") {
        Ok(t) => t,
        Err(e) => return Err(e),
    };

    Ok(texto.trim().to_string())
}

Y con él, así:

use std::fs;
use std::io;

fn leer_usuario() -> Result<String, io::Error> {
    let texto = fs::read_to_string("usuario.txt")?;
    Ok(texto.trim().to_string())
}
La condición para usarlo. El operador ? solo funciona dentro de una función que devuelva Result u Option, porque lo que hace es salir devolviendo ese tipo. Si lo pones en una función que devuelve otra cosa, el compilador te lo dirá.

Cuando los errores son de tipos distintos

Aquí aparece el primer tropiezo real. Imagina una función que lee un archivo y además convierte su contenido a número: el primer paso falla con un error de entrada y salida, y el segundo con un error de conversión. Son tipos diferentes, así que no caben en el mismo Result.

La salida rápida es declarar que se devuelve «cualquier cosa que sea un error», con Box<dyn Error>. El operador ? se encarga de convertir:

use std::error::Error;
use std::fs;

fn leer_puerto() -> Result<u16, Box<dyn Error>> {
    let texto = fs::read_to_string("puerto.txt")?;   // error de E/S
    let puerto: u16 = texto.trim().parse()?;         // error de conversión
    Ok(puerto)
}

Incluso main puede devolver un Result, lo que permite usar el operador en el punto de entrada. Si termina en Err, el programa imprime el error y sale con código distinto de cero:

use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let puerto = leer_puerto()?;
    println!("Puerto configurado: {puerto}");
    Ok(())
}

Cuando quieres errores propios

Para una biblioteca, Box<dyn Error> se queda corto: quien la use querrá distinguir un fallo de otro para reaccionar distinto. Lo habitual es declarar un enum con los casos posibles e implementar Display para describirlos:

use std::fmt;

#[derive(Debug)]
enum ErrorConfig {
    NoEncontrado,
    PuertoInvalido(String),
}

impl fmt::Display for ErrorConfig {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ErrorConfig::NoEncontrado => write!(f, "no se encontró el archivo de configuración"),
            ErrorConfig::PuertoInvalido(v) => write!(f, "«{v}» no es un puerto válido"),
        }
    }
}

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

Con eso, quien reciba un ErrorConfig puede hacerle match y decidir: si falta el archivo quizá aplique valores por defecto, pero si el puerto es inválido probablemente deba avisar y detenerse.

Cuándo sí está bien detener el programa

Rust también permite rendirse, con panic!. Detiene el hilo, imprime el mensaje y, si está activado, la traza de llamadas. unwrap y expect son atajos que hacen eso cuando encuentran un None o un Err.

La regla que usa la comunidad es sencilla: un error es recuperable, un pánico es un fallo del programador. Si el archivo de configuración no existe, eso puede pasar y hay que manejarlo. Si un índice se sale de un arreglo del que tú mismo acabas de comprobar el tamaño, eso no debería pasar nunca y el pánico es la respuesta correcta.

El atajo que se paga caro. Es tentador resolver cada Result con unwrap() para que compile y seguir adelante. Funciona mientras pruebas, pero convierte cada error previsible en una caída en producción. Si necesitas avanzar rápido, usa al menos expect("…") con un mensaje que explique qué suponías: cuando falle dentro de seis meses, ese texto te ahorrará la tarde.

En resumen

Situación Qué usar
Puede no haber valor, y es normal Option<T>
La operación puede fallar Result<T, E>
Quiero tratar el fallo aquí mismo match sobre el resultado
Tengo un valor por defecto unwrap_or
Que lo resuelva quien me llamó el operador ?
Mezclo errores de varios tipos Box<dyn Error>
Escribo una biblioteca un enum propio con Display
Esto no debería ocurrir jamás expect("…") o panic!

La idea que conviene llevarse es que en Rust el manejo de errores no es una capa que se añade al final, sino parte de la firma de cada función. Leyendo el tipo que devuelve ya sabes si puede fallar y de qué manera, antes de mirar una sola línea de su cuerpo.

Para verlo explicado

Miniatura del vídeo «Manejo de errores con Option o Result 🦀 rust», del canal edard3v

«Manejo de errores con Option o Result 🦀 rust», del canal edard3v (7:54). Es material de otro canal que recomendamos como complemento, no una producción de decodigo.com. El reproductor solo se carga al pulsar, así que YouTube no recibe nada tuyo hasta entonces. También puedes verlo en YouTube.