Result: el error como valor, no como excepción
Result modela el éxito con Ok o el fallo con Err como un valor ordinario que devuelve la función. Por qué Rust renuncia a las excepciones, qué gana con ello, y la frontera entre un error recuperable y un panic irrecuperable.
Option responde “¿hay valor?”. Pero muchas operaciones no fallan en silencio: fallan por una razón —el fichero no existe, la cadena no era un número, la conexión se cayó— y esa razón hay que transportarla. Ahí entra Result<T, E>, el segundo pilar de esta pareja: un enum que contiene o bien el éxito o bien el error, como un valor cualquiera que la función te devuelve. Rust no tiene excepciones, y esta es la pieza que las sustituye. Entender por qué esa renuncia es una ventaja, y no una carencia, es entender media filosofía del lenguaje.
- Leer
Result<T, E>como elenumde dos variantes:Ok(T)yErr(E). - Entender por qué Rust no tiene excepciones y qué gana al no tenerlas.
- Ver cómo
#[must_use]impide que un error se tire a la basura en silencio. - Trazar la frontera entre un error recuperable (
Result) y uno irrecuperable (panic!).
El éxito o el error, como valor
Como Option, Result es un tipo suma genérico, pero con dos parámetros: el tipo del éxito y el tipo del error.
enum Result<T, E> {
Ok(T),
Err(E),
}
Ok(valor) transporta el resultado bueno; Err(error) transporta la causa del fallo. Ambas variantes están en el preludio. Una función falible lo anuncia en su firma, y quien la llama ve que puede fallar sin leer una sola línea de su cuerpo:
fn dividir(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
return Err("división por cero".to_string());
}
Ok(a / b)
}
match dividir(10.0, 2.0) {
Ok(r) => println!("resultado: {r}"),
Err(e) => println!("error: {e}"),
}
Fíjate en lo que ocurre: el error no interrumpe el flujo, lo acompaña. Sale por el mismo return que el éxito, viaja por el mismo canal, y quien recibe el Result decide qué hacer con match, igual que con cualquier otro enum. Toda la biblioteca estándar que puede fallar habla este idioma: str::parse, std::fs::read_to_string, TcpStream::connect devuelven Result.
Por qué Rust no tiene excepciones
En los lenguajes con excepciones, el fallo viaja por un canal invisible. Una función marcada int leerEdad() puede lanzar una excepción sin que su firma lo diga; el control salta por encima de todo tu código hasta el catch más cercano, dondequiera que esté, y si no hay ninguno, el programa muere. Esto tiene tres costes que Rust no quiso pagar:
- Invisibilidad. La firma miente por omisión: no sabes qué puede fallar sin leer la implementación entera y la de todo lo que llama.
- Olvido fácil. Nada te obliga a capturar; olvidar un
catchcompila sin queja y explota en ejecución. - Desenrollado costoso. Propagar una excepción implica stack unwinding, un mecanismo pesado que ata de manos al optimizador.
Rust elige lo contrario: el fallo es un valor ordinario que aparece en el tipo de retorno. No hay canal oculto, no hay salto invisible, no hay nada que el compilador no pueda ver. La fiabilidad deja de depender de la disciplina del programador para recordar los catch y pasa a estar garantizada por el sistema de tipos.
flowchart TD A[Operacion que puede fallar] --> B[Devuelve Result de T y E] B --> OK[Ok con el valor bueno] B --> ER[Err con la causa del fallo] OK --> C[El llamador decide con match o el operador interrogacion] ER --> C C --> D[Ni canal oculto ni salto invisible] style B fill:#cba6f7,color:#11111b style OK fill:#a6e3a1,color:#11111b style ER fill:#f38ba8,color:#11111b style D fill:#89b4fa,color:#11111b
must_use: no puedes tirar un error a la basura
Un valor solo protege si no lo ignoras. Por eso Result lleva el atributo #[must_use]: descartar un Result sin mirarlo dispara un aviso del compilador.
fn guardar(datos: &str) -> Result<(), std::io::Error> {
std::fs::write("salida.txt", datos)
}
fn main() {
guardar("hola"); // aviso: unused `Result` that must be used
}
warning: unused `Result` that must be used
= note: this `Result` may be an `Err` variant, which should be handled
Es la contrapartida al olvido fácil de las excepciones: aquí, ignorar un fallo es un acto explícito y ruidoso. Si de verdad quieres descartarlo, tienes que decirlo en voz alta con let _ = guardar("hola");, y ese _ deja constancia en el código de que la decisión fue deliberada.
Result frente a panic: recuperable vs irrecuperable
Rust separa los fallos en dos categorías y les da herramientas distintas. No confundirlas es clave de diseño:
- Errores recuperables — cosas esperables del mundo real: un fichero que no está, una entrada del usuario mal formada, una conexión que se cae. El programa puede y debe reaccionar. Se modelan con
Result, y quien llama decide. - Errores irrecuperables — bugs: un índice fuera de rango, una invariante rota, un estado que jamás debería darse. No hay reacción sensata salvo abortar. Se expresan con
panic!, que desenrolla la pila y termina el hilo.
// Recuperable: el llamador se las verá con el Err.
fn leer_puerto(s: &str) -> Result<u16, std::num::ParseIntError> {
s.trim().parse()
}
// Irrecuperable: si esto pasa, el programa está roto; no hay plan B.
fn tercero(v: &[i32]) -> i32 {
assert!(v.len() >= 3, "se esperaban al menos 3 elementos");
v[2] // indexar fuera de rango también sería un panic
}
La prueba del algodón: pregúntate “¿tiene sentido que quien me llama intente recuperarse de esto?”. Si la respuesta es sí, devuelve Result. Si la respuesta es “no, esto es un bug y quiero que se note ya”, usa panic!. Un parse que falla es un Result; un índice negativo calculado por tu propia lógica es un panic!.
La decisión de Rust de no tener excepciones parece técnica, pero es filosófica. Una excepción trata el fallo como algo excepcional, una anomalía que se saca del flujo normal y se manda por un tubo aparte, invisible en las firmas y fácil de olvidar. Rust rechaza esa premisa: en el software real, fallar es parte del flujo normal. Los ficheros no están, las redes se caen, los usuarios teclean basura. Si el fallo es tan ordinario como el éxito, debe viajar por el mismo canal que el éxito —ser un valor, tener un tipo, aparecer en la firma— para que el compilador pueda razonar sobre él con las mismas armas. La consecuencia es enorme: el manejo de errores deja de ser un mecanismo de control de flujo aparte y se vuelve código normal, componible con las mismas herramientas que todo lo demás —match, los combinadores de 7.4, el operador ? de 7.5—. No hay una segunda gramática que aprender para “cuando las cosas van mal”. Y como Result es #[must_use], la vía por defecto no es el olvido silencioso, sino el reconocimiento explícito. Cuando el error es un valor, no hay dónde esconderlo: o lo manejas, o el compilador te señala que estás mirando hacia otro lado. Ese es el fundamento sobre el que 7.4 y 7.5 construyen la ergonomía; sin esta base, no serían posibles.
Aquí E es un simple String para no distraer. En la práctica se usan tipos de error propios (enums que implementan el trait std::error::Error), Box<dyn Error>, y crates como thiserror y anyhow. Todo ese arte —tipos de error componibles, contexto, retrocompatibilidad— es el tema del nivel 23. Por ahora basta con dominar Result como valor.
- Escribe
fn raiz(x: f64) -> Result<f64, String>que devuelvaErrsixes negativo yOk(x.sqrt())en otro caso. - Llámala en un
matchque imprima el resultado o el error. - Provoca el aviso de
#[must_use]ignorando unResult, y luego siléncialo correctamente conlet _ = ...; razona por qué el_explícito es mejor que borrar el aviso. - Escribe dos versiones de “obtener el tercer elemento de un slice”: una que devuelva
Resulty otra que hagapanic!. Argumenta en qué contexto usarías cada una. - Para cada una de estas situaciones decide
Resultopanic!y justifícalo: usuario mete texto donde iba un número; tu función recibe un vector que tú garantizaste no vacío; se pierde la conexión a la base de datos.