? más allá de Result: Option, main y closures
El operador ? es polimórfico sobre el trait Try: funciona sobre Option con la misma simetría que sobre Result, aunque los dos mundos no se mezclan sin un puente explícito. Cómo main devuelve Result gracias al trait Termination, y la trampa clásica del ? dentro de un closure, que retorna del closure y no de la función que lo rodea, con el patrón collect para recuperar el cortocircuito.
El operador ? no es una característica de Result: es polimórfico sobre el trait Try, y eso abre tres fronteras que separan a quien lo usa por imitación de quien lo entiende. La primera es Option, donde ? propaga la ausencia con la misma elegancia con que propaga el error —pero sin mezclar los dos mundos sin permiso—. La segunda es main, que puede devolver Result y usar ? en la cima del programa gracias a un trait discreto llamado Termination. La tercera es la más traicionera: ? dentro de un closure, donde retorna del closure y no de la función que lo rodea, una trampa que solo se domina entendiendo que ? es, en el fondo, un return.
- Usar
?sobreOptiony entender por quéOptionyResultno se mezclan sin un puente. - Devolver
Resultdesdemainy comprender el traitTerminationy el código de salida. - Reconocer la trampa del
?dentro de un closure y su semántica dereturnlocal. - Recuperar el cortocircuito en iteradores con
collect,try_foldy afines.
? sobre Option, y por qué los mundos no se mezclan
? funciona sobre Option con la simetría que ya esperas: si es Some(v) se evalúa a v; si es None, ejecuta return None. La única condición es que la función retorne Option.
fn iniciales(nombre: &str, apellido: &str) -> Option<String> {
let a = nombre.chars().next()?; // si esta vacio, return None
let b = apellido.chars().next()?;
Some(format!("{a}.{b}."))
}
En términos del trait Try, el residuo de Option es Option<Infallible>: None no lleva causa, solo señala ausencia. De ahí una restricción de tipos importante: no puedes usar ? sobre un Option dentro de una función que devuelve Result, ni al revés, porque los residuos no coinciden y None no tiene un error que convertir. El puente es explícito y lo eliges tú, dándole significado a la ausencia:
fn puerto(cfg: &Config) -> Result<u16, MiError> {
let bruto = cfg.get("port").ok_or(MiError::FaltaPuerto)?; // Option -> Result
let n = bruto.parse()?; // ya en el mundo Result
Ok(n)
}
ok_or (o ok_or_else para construir el error de forma perezosa) convierte un None en el Err que tú decidas; en sentido inverso, .ok() descarta el error de un Result para volver a Option. La costura es deliberada: pasar de “no hay valor” a “falló por esta razón” es una decisión de diseño, no una coerción automática.
main que devuelve Result: el trait Termination
main puede devolver Result, lo que habilita ? en la cima del programa:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let contenido = std::fs::read_to_string("config.toml")?;
println!("{contenido}");
Ok(())
}
Esto no es un caso especial cableado: main puede devolver cualquier tipo que implemente el trait Termination, cuyo método produce un ExitCode. La biblioteca estándar lo implementa para (), para Result<T, E> con E: Debug, para Infallible y para ExitCode. Cuando main devuelve Ok(()), el proceso sale con código de éxito; cuando devuelve Err(e), el runtime imprime Error: {e:?} (nota: usa Debug, no Display) en stderr y sale con código de fallo. Para controlar el código exacto, devuelve un ExitCode:
use std::process::ExitCode;
fn main() -> ExitCode {
if entorno_ok() { ExitCode::SUCCESS } else { ExitCode::from(2) }
}
Conviene contrastarlo con std::process::exit, que termina el proceso de inmediato sin ejecutar los destructores de la pila; devolver desde main es la salida ordenada, exit es la abrupta. Y esta misma maquinaria sirve en tests: una función de test puede devolver Result y usar ?, fallando limpiamente si algo devuelve Err.
flowchart TD M[main devuelve un tipo Termination] --> R[Devuelve Ok o unit] M --> E[Devuelve Err] R --> C0[Proceso sale con codigo de exito] E --> P[Runtime imprime Error con Debug en stderr] P --> C1[Proceso sale con codigo de fallo] style M fill:#cba6f7,color:#11111b style R fill:#a6e3a1,color:#11111b style E fill:#f38ba8,color:#11111b style C1 fill:#89b4fa,color:#11111b
? dentro de closures e iteradores
Aquí está la trampa que atrapa a casi todo el mundo. Un closure tiene su propio tipo de retorno, así que un ? dentro de él retorna del closure, no de la función que lo rodea. Este código no propaga el error hacia procesar: intenta devolver un Err desde el closure de map, cuyo tipo de retorno no es Result, y no compila como se espera:
fn procesar(lineas: &[&str]) -> Result<Vec<i32>, std::num::ParseIntError> {
let nums = lineas.iter().map(|l| l.parse::<i32>()).collect(); // sin ? dentro
nums
}
La solución idiomática no es meter ? en el closure, sino dejar que el closure produzca Result y recolectar hacia un Result. Esta es una de las conversiones más elegantes de la biblioteca: collect sobre un iterador de Result puede producir un Result<Vec<_>, _> que es Ok con todos los valores si todos fueron Ok, o el primer Err, cortocircuitando el resto:
fn procesar(lineas: &[&str]) -> Result<Vec<i32>, std::num::ParseIntError> {
lineas.iter().map(|l| l.parse::<i32>()).collect() // Result<Vec<i32>, _>
}
El cortocircuito que ? da en código secuencial, collect::<Result<_, _>>() lo da en una tubería de iteradores. La misma transposición existe para Option. Cuando la lógica por elemento es más rica, try_fold y try_for_each propagan el primer fallo a través de un pliegue, y operaciones como sum o product sobre un iterador de Result devuelven Result. Para el caso general en que quieres ? con alcance local dentro de un bloque, existen los try blocks, aún inestables, que acotan el return del ? a un bloque en vez de a la función entera.
Si te encuentras escribiendo .map(|x| algo(x)?) y el compilador se queja, no fuerces el tipo: el ? está intentando retornar del closure. Dos salidas limpias: deja que el closure devuelva Result y cierra la tubería con .collect::<Result<Vec<_>, _>>(), o cambia a try_fold/try_for_each si necesitas acumular. El ? con alcance de bloque llegará con los try blocks estables.
Lo que estas tres fronteras revelan, juntas, es que ? nunca fue “el operador de Result”. Es el operador de una forma —la de un cómputo que o bien continúa con un valor o bien se corta con un residuo— y esa forma la captura el trait Try. Result la habita con su Err, Option con su None, ControlFlow con su Break; mañana un tipo tuyo podría habitarla. Por eso el mismo carácter propaga un error tipado, una ausencia sin causa, o un corte de un pliegue, sin que el compilador tenga un caso especial para cada uno: hay una sola abstracción y muchos habitantes. Que Option y Result no se mezclen sin ok_or no es una carencia, sino coherencia: sus residuos son distintos —uno lleva causa, el otro no— y forzar la mezcla sería inventar una causa que no existe, así que Rust te obliga a decidirla. Que main participe del juego vía Termination muestra que la abstracción llega hasta el borde mismo del programa, donde el Result se traduce en un código de salida del proceso. Y la trampa del closure, lejos de ser un defecto, es la prueba más limpia de que ? es un return: retorna de la función más cercana que lo encierra, y un closure es una función. Quien interioriza esto deja de memorizar reglas —“? va aquí sí y allá no”— y empieza a derivarlas de un solo principio: ? corta el cómputo actual y entrega su residuo a quien sepa reconstruirlo. Desde ese principio, ok_or, collect hacia Result, Termination y el alcance local del closure dejan de ser trucos sueltos y se vuelven consecuencias inevitables de una misma idea. Esa es la diferencia entre usar ? y entenderlo.
- Escribe una función que devuelva
Optiony encadene dos?sobrechars().next(); luego intenta usar uno de esos?en una función que devuelveResulty lee el error del compilador. - Tiende el puente con
ok_orpara convertir unNoneen unErrde dominio, y con.ok()en sentido inverso; explica por qué la conversión debe ser explícita. - Convierte tu
mainenfn main() -> Result<(), Box<dyn std::error::Error>>, provoca unErry observa que el mensaje impreso usaDebug; luego cámbialo a-> ExitCodey devuelve un código propio. - Reproduce la trampa: intenta usar
?dentro de un.map(...)y arréglalo con.collect::<Result<Vec<_>, _>>(); demuestra que corta en el primerErr. - Reescribe esa misma lógica con
try_foldacumulando una suma, y razona en qué se diferencia delcollecten cuanto a lo que construye.