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
matchde propagación que?elimina y su semántica dereturntemprano. - Desazucarar
?sobre los traitsTry,FromResidualy el tipoControlFlow. - Entender que
?convierte el error propagado llamando aFrom::from. - Unificar errores heterogéneos hacia un error de dominio con
impl Fromy conBox<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.
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
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.
- Escribe
suma_ficherocon losmatchmanuales y luego con?; compara línea a línea qué desapareció y qué se conservó. - Define
enum MiErrorcon dos variantes y susimpl From, y verifica que dos?sobre errores de tipos distintos compilan sin conversión visible. - 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 alenum. - Escribe el desazucarado conceptual de
x?(conmatchyFrom::from) y señala en qué punto exacto entra la conversión. - Investiga
thiserror: escribe el mismoenum MiErrorcon su macro#[derive(Error)]y confirma que genera losFromy elDisplayque tú harías a mano.