Saltar al contenido

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.

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.

  • 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:

Rust
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:

Rust
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:

Rust
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:

Rust
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.

Rust
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:

Rust
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.

? 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.