wandres.dev
MANEJO DE ERRORES · Result, ?, panic

El operador ?: propagación, desazucarado y conversión con From

El operador ? colapsa el match de propagación en un carácter que desenvuelve el Ok o hace return temprano del Err. Su desazucarado real sobre los traits Try, FromResidual y ControlFlow, y la pieza decisiva: al propagar convierte el error llamando a From::from, lo que permite unificar errores heterogéneos hacia un error de dominio sin una sola línea de conversión visible.

⏱ 18 min

Result hace del fallo un valor honesto; falta hacerlo ergonómico. El gesto más frecuente al programar con Result es también el más aburrido: una función llama a otra falible y, si falla, quiere propagar el fallo hacia arriba en vez de manejarlo aquí. Hacerlo a mano con match es puro ceremonial que entierra la lógica real. El operador ? colapsa ese patrón en un carácter, y al hacerlo resuelve el dilema histórico entre legibilidad y honestidad que hundió tanto a los códigos de error verbosos como a las excepciones comprobadas. Pero ? no es magia opaca: es azúcar sobre un puñado de traits, y su pieza más poderosa es que convierte el error mientras lo propaga.

🎯 Al terminar esta lección sabrás
  • Reconocer el match de propagación que ? elimina y su semántica de return temprano.
  • Desazucarar ? sobre los traits Try, FromResidual y el tipo ControlFlow.
  • Entender que ? convierte el error propagado llamando a From::from.
  • Unificar errores heterogéneos hacia un error de dominio con impl From y con Box<dyn Error>.

De try! a ?: la propagación como un carácter

Considera una función que lee un fichero y suma sus líneas. Cada paso falla distinto, y queremos devolver el fallo al llamador. A mano:

fn suma_fichero(ruta: &str) -> Result<i32, MiError> {
    let texto = match std::fs::read_to_string(ruta) {
        Ok(t) => t,
        Err(e) => return Err(MiError::from(e)),   // propaga convirtiendo
    };
    let mut total = 0;
    for linea in texto.lines() {
        total += match linea.trim().parse::<i32>() {
            Ok(n) => n,
            Err(e) => return Err(MiError::from(e)),
        };
    }
    Ok(total)
}

Esos match no aportan lógica: solo dicen “si va bien sigue, si va mal devuélvelo ya”. El operador ? los borra por completo:

fn suma_fichero(ruta: &str) -> Result<i32, MiError> {
    let texto = std::fs::read_to_string(ruta)?;   // io::Error propagado y convertido
    let mut total = 0;
    for linea in texto.lines() {
        total += linea.trim().parse::<i32>()?;     // ParseIntError propagado y convertido
    }
    Ok(total)
}

?, tras una expresión Result, se evalúa a v si es Ok(v) y ejecuta return Err(...) de inmediato si es Err. Históricamente esto lo hacía la macro try!, que ? reemplazó al estabilizarse en 2016. La diferencia con un catch es que el fallo sigue completamente explícito: está en el carácter, y el tipo de retorno sigue siendo Result, así que el compilador obliga a que toda la función sea honesta sobre su falibilidad.

Desazucarado: Try, FromResidual y ControlFlow

Conceptualmente, expr? sobre un Result equivale a:

match expr {
    Ok(v) => v,
    Err(e) => return Err(From::from(e)),   // ojo: From::from, no e a secas
}

Ese From::from es la clave que veremos en la sección siguiente. Pero el mecanismo real es más general y no está atado a Result. ? se apoya en el trait Try y en el tipo ControlFlow, con dos variantes Continue y Break. El desazucarado exacto, hoy todavía tras la puerta inestable try_trait_v2 (aunque el operador ? lleve años estable), es aproximadamente:

match Try::branch(expr) {
    ControlFlow::Continue(v) => v,                      // sigue con el valor
    ControlFlow::Break(residuo) => return FromResidual::from_residual(residuo),
}

Try::branch decide si la expresión “continúa” (un Ok, un Some) o “rompe” (un Err, un None), y produce un residuo que captura la razón del corte. FromResidual::from_residual reconstruye el tipo de retorno a partir de ese residuo. Para Result, el residuo es el Err y from_residual es quien invoca From::from sobre el error. Esta abstracción es la que permite que el mismo operador ? funcione sobre Result, sobre Option y sobre ControlFlow, sin casos especiales cableados en el compilador: cada tipo implementa Try a su manera.

💡
? es un return, no una expresión que se queda en su sitio

El matiz que más confunde: ? puede hacer return de la función que lo contiene. No transforma un valor en su sitio como haría un combinador; ante un fallo, abandona la función entera. Por eso su tipo de retorno debe ser compatible (Result, Option, o cualquier tipo Try), y por eso —lo verás en la próxima lección— dentro de un cierre el ? retorna del cierre, no de la función que lo rodea.

Convertir el error con From: la costura invisible

Aquí está el poder que eleva ? de comodidad a pieza de diseño. Cuando propagas un Err(e) de tipo F desde una función cuyo error declarado es de tipo E, el operador ? convierte F en E automáticamente llamando a From::from. Por eso suma_fichero compilaba: io::Error y ParseIntError son tipos distintos, pero ambos se convierten a MiError porque tú provees las conversiones.

enum MiError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
}

impl From<std::io::Error> for MiError {
    fn from(e: std::io::Error) -> Self { MiError::Io(e) }
}
impl From<std::num::ParseIntError> for MiError {
    fn from(e: std::num::ParseIntError) -> Self { MiError::Parse(e) }
}

Con esos dos impl From, cada ? inserta la conversión adecuada sin una línea visible. Unificar errores heterogéneos hacia un error de dominio se reduce a escribir un From por cada origen. Y si no quieres definir un enum —en una aplicación, un script, un prototipo— el tipo Box<dyn std::error::Error> absorbe cualquier error, porque la biblioteca estándar provee un From<E: Error> genérico hacia él:

fn cargar(ruta: &str) -> Result<i32, Box<dyn std::error::Error>> {
    let texto = std::fs::read_to_string(ruta)?;   // io::Error -> Box<dyn Error>
    let n: i32 = texto.trim().parse()?;           // ParseIntError -> Box<dyn Error>
    Ok(n)
}

Esta es la misma costura que usan los crates del ecosistema: thiserror genera por macro tus impl From y Display para el enum de dominio, y anyhow te da un Box<dyn Error> enriquecido. Ninguno inventa un mecanismo nuevo; ambos descansan sobre ? y From.

flowchart TD
A[expresion seguida de interrogacion] --> B[Es Ok o Continue]
A --> C[Es Err de tipo F]
B --> D[Se desenvuelve y el programa sigue]
C --> E[return con From from sobre el error]
E --> F[El error F se convierte al error E de la funcion]
F --> G[El llamador recibe un error de dominio unificado]
style B fill:#a6e3a1,color:#11111b
style C fill:#f38ba8,color:#11111b
style D fill:#89b4fa,color:#11111b
style G fill:#cba6f7,color:#11111b
? disuelve el dilema entre legibilidad y honestidad

Durante décadas, el manejo de errores pareció una elección forzosa entre dos males. Los códigos de retorno del C clásico —y su heredero, el if err != nil de Go— hacen el fallo explícito y honesto, pero al precio de sepultar la lógica bajo comprobaciones repetidas en cada línea: el camino feliz se vuelve ilegible. Las excepciones logran lo contrario, un camino feliz limpio, pero al precio de volver el fallo invisible: no aparece en las firmas, salta por canales ocultos, es fácil de olvidar. Parecía que había que elegir: o legibilidad, o honestidad. El operador ? demuestra que la disyuntiva era falsa. El fallo sigue siendo un valor —explícito, tipado, imposible de ignorar por descuido gracias a #[must_use]— y a la vez su propagación es un solo carácter que no rompe la lectura del camino feliz. Consigues las dos cosas que se creían incompatibles, y ese es exactamente el clavo que Java no supo clavar con las excepciones comprobadas: tenían la honestidad, pero la fricción de propagarlas empujó a la gente a silenciarlas. ? elimina la fricción sin sacrificar la honestidad. Y lo más elegante es cuán poco cuesta: ? no es una construcción monolítica del compilador, sino azúcar sobre Try, FromResidual y From, que a su vez operan sobre Result y Option, que no son más que enum con optimización de nicho. Todo el edificio del manejo de errores idiomático de Rust —hasta thiserror y anyhow— se sostiene sobre esa base minúscula y coherente: tipos suma, valores en vez de excepciones, y una conversión con From cosida en cada ?. Dominar este operador no es aprender un atajo; es ver la columna vertebral sobre la que se levanta, en pie, el resto del lenguaje.

⚔️ Propaga y convierte con una sola marca
  1. Escribe suma_fichero con los match manuales y luego con ?; compara línea a línea qué desapareció y qué se conservó.
  2. Define enum MiError con dos variantes y sus impl From, y verifica que dos ? sobre errores de tipos distintos compilan sin conversión visible.
  3. Reescribe la misma función con Box<dyn std::error::Error> como error; explica qué conversión genérica de la biblioteca lo hace posible y qué pierdes frente al enum.
  4. Escribe el desazucarado conceptual de x? (con match y From::from) y señala en qué punto exacto entra la conversión.
  5. Investiga thiserror: escribe el mismo enum MiError con su macro #[derive(Error)] y confirma que genera los From y el Display que tú harías a mano.