Errores recuperables con Result

La mayoría de los errores no son lo suficientemente graves como para requerir que el programa se detenga por completo. A veces, cuando una función falla, es por una razón que puede interpretar y responder fácilmente. Por ejemplo, si intenta abrir un archivo y esa operación falla porque el archivo no existe, es posible que desee crear el archivo en lugar de terminar el proceso.

Recordemos el capítulo “Manejo de fallas potenciales con Result en el Capítulo 2 que el enum Result se define como tener dos variantes, Ok y Err, de la siguiente manera:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

T y E son parámetros de tipo genérico: hablaremos de los genéricos con más detalle en el Capítulo 10. Lo que necesita saber ahora es que T representa el tipo del valor que será devuelto en un caso de éxito dentro de la variante Ok, y E representa el tipo del error que será devuelto en un caso de fallo dentro de la variante Err. Debido a que Result tiene estos parámetros de tipo genérico, podemos usar el tipo Result y las funciones definidas en él en muchas situaciones diferentes donde el valor de éxito y el valor de error que queremos devolver pueden diferir.

Llamemos a una función que devuelve un valor Result porque la función podría fallar. En el listado 9-3 intentamos abrir un archivo.

Filename: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}

Listado 9-3: Abriendo un archivo

El tipo de retorno de File::open es un Result<T, E>. El parámetro genérico T ha sido llenado por la implementación de File::open con el tipo del valor de éxito, std::fs::File, que es un manejador de archivo. El tipo de E utilizado en el valor de error es std::io::Error. Este tipo de retorno significa que la llamada a File::open podría tener éxito y devolver un manejador de archivo del que podemos leer o escribir. La llamada a la función también podría fallar: por ejemplo, el archivo podría no existir, o podríamos no tener permiso para acceder al archivo. La función File::open necesita tener una forma de decirnos si tuvo éxito o falló y al mismo tiempo darnos el manejador de archivo o la información de error. Esta información es exactamente lo que transmite el enum Result.

En el caso en que File::open tenga éxito, el valor en la variable greeting_file_result será una instancia de Ok que contiene un manejador de archivo. En el caso en que falla, el valor en greeting_file_result será una instancia de Err que contiene más información sobre el tipo de error que ocurrió.

Necesitamos agregar al código en el listado 9-3 para tomar diferentes acciones dependiendo del valor que File::open devuelve. El listado 9-4 muestra una forma de manejar él Result usando una herramienta básica, la expresión match que discutimos en el Capítulo 6.

Filename: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {error:?}"),
    };
}

Listado 9-4: Usando una expresión match para manejar las variantes Result que podrían devolverse

Ten en cuenta que, al igual que el enum Option, el enum Result y sus variantes se han traído al ámbito por el prelude, por lo que no necesitamos especificar Result:: antes de las variantes Ok y Err en las opciones de match.

Cuando el result es Ok, este código devolverá el valor interno file fuera de la variante Ok, y luego asignaremos ese valor de manejador de archivo a la variable greeting_file. Después del match, podemos usar el manejador de archivo para leer o escribir.

La otra opción en el match es Err, que significa que el File::open ha fallado y el valor interno err de la variante Err contendrá información sobre cómo o por qué falló File::open. En este caso, llamamos a la función panic! y pasamos el valor err al panic!. Esto causa que nuestro programa se bloquee y muestre el mensaje de error que panic! proporciona. Si ejecutamos este código, obtendremos el siguiente mensaje de error:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`
thread 'main' panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Como de costumbre, esta salida nos dice exactamente qué ha salido mal.

Haciendo coincidir diferentes errores

El código en el listado 9-4 será panic! no importa por qué File::open falló. Sin embargo, queremos tomar diferentes acciones para diferentes razones de falla: si File::open falló porque el archivo no existe, queremos crear el archivo y devolver el manejador del nuevo archivo. Si File::open falló por cualquier otra razón, por ejemplo, porque no teníamos permiso para abrir el archivo, todavía queremos que el código dispare el panic! de la misma manera que lo hizo en el listado 9-4. Para esto agregamos una expresión match interna, que se muestra en el listado 9-5.

Filename: src/main.rs

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {e:?}"),
            },
            other_error => {
                panic!("Problem opening the file: {other_error:?}");
            }
        },
    };
}

Listado 9-5: Manejando diferentes tipos de errores de diferentes formas

El tipo de valor que File::open devuelve dentro de la variante Err es io::Error, que es una estructura proporcionada por la biblioteca estándar. Esta estructura tiene un método kind que podemos llamar para obtener un valor io::ErrorKind. El enum io::ErrorKind es proporcionado por la biblioteca estándar y tiene variantes que representan los diferentes tipos de errores que podrían resultar de una operación io. La variante que queremos usar es ErrorKind::NotFound, que indica que el archivo que estamos tratando de abrir aún no existe. Así que hacemos coincidir en greeting_file_result, pero también tenemos una coincidencia interna en error.kind().

La condición que queremos verificar en la coincidencia interna es si el valor devuelto por error.kind() es la variante NotFound del enum ErrorKind. Si es así, intentamos crear el archivo con File::create. Sin embargo, debido a que File::create también podría fallar, necesitamos una segunda opción en la expresión match interna. Cuando no se puede crear el archivo, se imprime un mensaje de error diferente. La segunda opción del match externo permanece igual, por lo que el programa se bloquea en cualquier error además del error de archivo faltante.

Alternativas a usar match con Result<T, E>

¡Eso es mucho match! La expresión match es útil, pero también es bastante verbosa. En el Capítulo 13 aprenderás sobre los closures, que se usan con muchos de los métodos definidos en Result<T, E>. Estos métodos pueden ser más concisos que usar match al manejar valores Result<T, E> en tu código.

Por ejemplo, aquí hay otra forma de escribir la misma lógica que se muestra en el listado 9-5, esta vez usando closures y el método unwrap_or_else:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {error:?}");
            })
        } else {
            panic!("Problem opening the file: {error:?}");
        }
    });
}

Aunque este código tiene el mismo comportamiento que el listado 9-5, no contiene ninguna expresión match y es más fácil de leer. Vuelve a este ejemplo después de leer el Capítulo 13, y busca el método unwrap_or_else en la documentación de la biblioteca estándar. Muchos más de estos métodos pueden limpiar enormes expresiones match anidadas cuando se trata de errores.

Atajos para panic en caso de error: unwrap y expect

Usando match funciona bastante bien, pero puede ser un poco verboso y no siempre comunica bien la intención. El tipo Result<T, E> tiene muchos métodos auxiliares definidos en él para hacer varias tareas más específicas. El método unwrap es un método de atajo implementado exactamente como la expresión match que escribimos en el listado 9-4. Si el valor Result es la variante Ok, unwrap devolverá el valor dentro de Ok. Si el Result es la variante Err, unwrap llamará a la macro panic! por nosotros. Aquí hay un ejemplo de unwrap en acción:

Filename: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

Si ejecutamos este código sin un archivo hello.txt, veremos un mensaje de error de la llamada panic! que el método unwrap hace:

thread 'main' panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Del mismo modo, el método expect nos permite elegir el mensaje de error de panic!. Usando expect en lugar de unwrap y proporcionando buenos mensajes de error puede transmitir tu intención y facilitar el seguimiento de la fuente de un pánico. La sintaxis de expect se ve así:

Filename: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

Nosotros usamos expect de la misma manera que unwrap: para devolver el manejo de archivo o llamar a la macro panic!. El mensaje de error utilizado por expect en su llamada a panic! será el parámetro que pasamos a expect, en lugar del mensaje predeterminado de panic! que usa unwrap. Así es como se ve:

thread 'main' panicked at src/main.rs:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }

En producción, la mayoría de los Rustaceans eligen expect en lugar de unwrap y dan más contexto sobre por qué se espera que la operación siempre tenga éxito. De esa manera, si tus suposiciones se demuestran incorrectas, tienes más información para usar en la depuración.

Propagación de errores

Cuando escribes una función cuyo cuerpo puede generar un error, en lugar de manejar el error dentro de la función, puedes devolver el error al código que llamó la función. Esto se conoce como propagación del error y le da más control al código que llama, donde puede haber más información o lógica que dicte cómo se debe manejar el error que la que tienes disponible en el contexto de tu código.

Por ejemplo, El listado 9-6 muestra una función que lee un nombre de usuario de un archivo. Si el archivo no existe o no se puede leer, esta función devolverá esos errores al código que llamó a la función.

Filename: src/main.rs

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}
}

Listado 9-6: Una función que devuelve errores al código llamado usando match

Esta función se puede escribir de una manera mucho más corta, pero vamos a empezar por hacer mucho de ella manualmente para explorar el manejo de errores; al final, mostraremos la forma más corta. Veamos primero el tipo de retorno de la función: Result<String, io::Error>. Esto significa que la función está devolviendo un valor del tipo Result<T, E> donde el parámetro genérico T se ha rellenado con el tipo concreto String, y el tipo genérico E se ha rellenado con el tipo concreto io::Error.

Si esta función tiene éxito sin ningún problema, el código que llama a esta función recibirá un valor Ok que contiene una String - el nombre de usuario que esta función leyó del archivo. Si esta función encuentra algún problema, el código que llama recibirá un valor Err que contiene una instancia de io::Error que contiene más información sobre cuáles fueron los problemas. Elegimos io::Error como el tipo de retorno de esta función porque eso sucede que es el tipo del valor de error devuelto de ambas operaciones que estamos llamando en el cuerpo de esta función que podrían fallar: la función File::open y el método read_to_string.

El cuerpo de la función comienza llamando a la función File::open. Entonces manejamos el valor Result con una expresión match similar a la del Listado 9-4. Si File::open tiene éxito, el archivo manejador en el patrón de variable file se convierte en el valor en la variable de patrón mutable username_file y la función continúa. En el caso de Err, en lugar de llamar a panic!, usamos la palabra clave return para devolver temprano la función por completo y pasar el valor de error de File::open, ahora en la variable de patrón e, de vuelta al código que llama a esta función como el valor de error de esta función.

Entonces, si tenemos un manejador de archivo en username_file, la función crea un nuevo String en la variable username y llama al método read_to_string en el manejador de archivo en username_file para leer el contenido del archivo en username. El método read_to_string también devuelve un Result porque podría fallar, incluso si File::open tuvo éxito. Así que necesitamos otro match para manejar ese Result: si read_to_string tiene éxito, entonces nuestra función ha tenido éxito, y devolvemos el nombre de usuario del archivo que ahora está en username envuelto en un Ok. Si read_to_string falla, devolvemos el valor de error de la misma manera que devolvimos el valor de error en el match que manejó el valor de retorno de File::open. Sin embargo, no necesitamos decir explícitamente return, porque esta es la última expresión de la función.

El código que llama a este código se encargará de obtener un valor Ok que contiene un nombre de usuario o un valor Err que contiene un io::Error. Es responsabilidad del código que llama decidir qué hacer con esos valores. Si el código que llama obtiene un valor Err, podría llamar a panic! y bloquear el programa, usar un nombre de usuario predeterminado o buscar el nombre de usuario en algún lugar que no sea un archivo, por ejemplo. No tenemos suficiente información sobre lo que el código que llama realmente está tratando de hacer, por lo que propagamos toda la información de éxito o error hacia arriba para que la maneje apropiadamente.

Este patrón de propagación de errores es tan común en Rust que Rust proporciona el operador de interrogación ? para hacer esto más fácil.

Un atajo para propagar errores: el operador ?

El listado 9-7 muestra una implementación de read_username_from_file que tiene la misma funcionalidad que en el Listado 9-6, pero esta implementación utiliza el operador ?.

Filename: src/main.rs

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
}

Listado 9-7: Una función que devuelve errores al código llamado usando el operador ?

El ? colocado después de un valor Result se define para funcionar de casi la misma manera que las expresiones match que definimos para manejar los valores Result en el listado 9-6. Si el valor de Result es un Ok, el valor dentro del Ok se devolverá de esta expresión, y el programa continuará. Si el valor es un Err, él Err se devolverá de toda la función como si hubiéramos usado la palabra clave return para que el valor de error se propague al código que llama.

Hay una diferencia entre lo que hace la expresión match del listado 9-6 y lo que hace el operador ?: los valores de error que tienen el operador ? llamado en ellos pasan a través de la función from, definida en el trait From en la biblioteca estándar, que se usa para convertir valores de un tipo a otro. Cuando el operador ? llama a la función from, el tipo de error recibido se convierte en el tipo de error definido en el tipo de retorno de la función actual. Esto es útil cuando una función devuelve un tipo de error para representar todas las formas en que una función podría fallar, incluso si las partes podrían fallar por muchas razones diferentes.

Por ejemplo, podríamos cambiar la función read_username_from_file en el listado 9-7 para devolver un tipo de error personalizado llamado OurError que definimos. Si también definimos impl From<io::Error> for OurError para construir una instancia de OurError a partir de un io::Error, entonces el operador ? llama en el cuerpo de read_username_from_file llamará a from y convertirá los tipos de error sin necesidad de agregar más código a la función.

En el contexto del listado 9-7, el ? al final de la llamada a File::open devolverá el valor dentro de un Ok a la variable username_file. Si ocurre un error, el ? operador devolverá temprano toda la función y dará cualquier valor Err al código que llama. Lo mismo se aplica al ? al final de la llamada a read_to_string.

El operador ? elimina mucho código repetitivo y realiza esta función de implementación más simple. Incluso podríamos acortar aún más este código encadenando llamadas de método inmediatamente después del ?, como se muestra en el Listado 9-8.

Filename: src/main.rs

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}
}

Listado 9-8: Método de encadenamiento llamado después del operador ?

Hemos movido la creación del nuevo String en username al principio de la función; esa parte no ha cambiado. En lugar de crear una variable username_file, hemos encadenado la llamada a read_to_string directamente sobre el resultado de File::open("hello.txt")?. Todavía tenemos un ? al final de la llamada a read_to_string, y todavía devolvemos un valor Ok que contiene username cuando tanto File::open como read_to_string tienen éxito en lugar de devolver errores. La funcionalidad es nuevamente la misma que en el listado 9-6 y el listado 9-7; esta es solo una forma diferente y más ergonómica de escribirla.

El listado 9-9 muestra una forma de hacer esto aún más conciso usando fs::read_to_string.

Filename: src/main.rs

#![allow(unused)]
fn main() {
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}
}

Listado 9-9: Usando fs::read_to_string en lugar de abrir y luego leer el archivo

Leer un archivo en un String es una operación bastante común, por lo que la biblioteca estándar proporciona la conveniente función fs::read_to_string que abre el archivo, crea un nuevo String, lee el contenido del archivo, coloca el contenido en ese String y lo devuelve. Por supuesto, usar fs::read_to_string no nos da la oportunidad de explicar todo el manejo de errores, por lo que lo hicimos de la manera más larga primero.

Donde se puede usar el operador ?

El operador ? solo puede usarse en funciones cuyo tipo de retorno sea compatible con el valor que se usa con el operador ?. Porque el operador ? está definido para realizar una devolución temprana de un valor de la función, de la misma manera que la expresión match que definimos en el listado 9-6. En el listado 9-6, el match estaba usando un valor Result, y el brazo de devolución temprana devolvió un valor Err(e). El tipo de retorno de la función debe ser un Result para que sea compatible con este return.

En el listado 9-10, veamos el error que obtendremos si usamos el operador ? en una función main con un tipo de retorno incompatible con el tipo de valor que usamos ?:

Filename: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}

Listado 9-10: Intentando usar el ? en la función main que devuelve () no se compilará

Este código abre un archivo, que puede fallar. El operador ? sigue el valor Result devuelto por File::open, pero esta función main tiene el tipo de retorno de (), no Result. Cuando compilamos este código, obtenemos el siguiente mensaje de error:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let greeting_file = File::open("hello.txt")?;
  |                                                ^ cannot use the `?` operator in a function that returns `()`
  |
  = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` (bin "error-handling") due to 1 previous error

Este error señala que solo podemos usar el operador ? en una función que devuelve Result o Option o en cualquier otro tipo que implemente FromResidual.

Para corregir el error, tienes dos opciones. Una opción es cambiar el tipo de retorno de tu función para que sea compatible con el valor que estás usando el operador ? mientras no tengas restricciones que lo impidan. La otra técnica es usar un match o uno de los métodos Result<T, E> para manejar el Result<T, E> de la manera que sea apropiada.

El mensaje de error también menciona que el operador ? también se puede usar con valores Option<T>. Al igual que con el uso de ? en Result, solo puedes usar ? en Option en una función que devuelve Option. El comportamiento del operador ? cuando se llama en un Option<T> es similar a su comportamiento cuando se llama en un Result<T, E>: si el valor es None, el None se devolverá temprano desde la función en ese punto. Si el valor es Some, el valor dentro de Some es el valor resultante de la expresión y la función continúa. El listado 9-11 tiene un ejemplo de una función que encuentra el último carácter de la primera línea en el texto dado:

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

fn main() {
    assert_eq!(
        last_char_of_first_line("Hello, world\nHow are you today?"),
        Some('d')
    );

    assert_eq!(last_char_of_first_line(""), None);
    assert_eq!(last_char_of_first_line("\nhi"), None);
}

Listado 9-11: Using the ? operator on an Option<T> value

Esta función devuelve Option<char> porque es posible que haya un carácter allí, pero también es posible que no lo haya. Este código toma el argumento de string slice text y llama al método lines en él, que devuelve un iterador sobre las líneas en el string. Debido a que esta función quiere examinar la primera línea, llama a next en el iterador para obtener el primer valor del iterador. Si text es un string vacío, esta llamada a next devolverá None, en cuyo caso usamos ? para detener y devolver None desde last_char_of_first_line. Si text no es un string vacío, next devolverá un valor Some que contiene un string slice de la primera línea en text.

El ? extrae el string slice, y podemos llamar a chars en ese string slice para obtener un iterador de sus caracteres. Estamos interesados en el último carácter en esta primera línea, por lo que llamamos a last para devolver el último elemento en el iterador. Esto es un Option porque es posible que la primera línea sea el string vacío, por ejemplo, si text comienza con una línea en blanco pero tiene caracteres en otras líneas, como en "\nhi". Sin embargo, si hay un último carácter en la primera línea, se devolverá en la variante Some. El operador ? en el medio nos da una forma concisa de expresar esta lógica, lo que nos permite implementar la función en una línea. Si no pudiéramos usar el operador ? en Option, tendríamos que implementar esta lógica usando más llamadas de método o una expresión match.

Ten en cuenta que puedes usar el operador ? en una función que devuelve Result y puedes usar el operador ? en una función que devuelve Option, pero no puedes mezclar y combinar. El operador ? no convertirá automáticamente un Result en un Option o viceversa; en esos casos, puedes usar métodos como el método ok en Result o el método ok_or en Option para hacer la conversión explícitamente.

Hasta ahora, todas las funciones main que hemos usado devuelven (). La función main es especial porque es el punto de entrada y salida de los programas ejecutables, y hay restricciones sobre cuál puede ser su tipo de retorno para que los programas se comporten como se espera.

Por suerte, main también puede devolver un Result<(), E>. El Listado 9-12 tiene el código del listado 9-10, pero hemos cambiado el tipo de retorno de main para que sea Result<(), Box<dyn Error>> y hemos agregado un valor de retorno Ok(()) al final. Este código ahora se compilará:

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}

Listado∂ 9-12: Cambiando main devuelve Result<(), E> permitiendo el uso del operador ? en valores Result

El Box<dyn Error> tipo es un trait object, que hablaremos en la sección “Usando Trait Objects que permiten valores de diferentes tipos” en el Capítulo 17. Por ahora, puedes leer Box<dyn Error> para significar “cualquier tipo de error”. Usar ? en un valor Result en una función main con el tipo de error Box<dyn Error> está permitido, porque permite que cualquier valor Err se devuelva temprano. A pesar de que el cuerpo de esta función main solo devolverá errores de tipo std::io::Error, al especificar Box<dyn Error>, esta firma seguirá siendo correcta incluso si se agrega más código que devuelva otros errores al cuerpo de main.

Cuando una función main devuelve un Result, el ejecutable puede salir con un valor de 0 si main devuelve Ok(()) y saldrá con un valor distinto de 0 si main devuelve un Err. Los ejecutables escritos en C devuelven enteros cuando salen: los programas que salen con éxito devuelven el entero 0, y los programas que devuelven un error devuelven algún entero distinto de 0. Rust también devuelve enteros de ejecutables para ser compatibles con esta convención.

La función main puede devolver cualquier tipo que implemente el trait std::process::Termination, que incluye una función report que devuelve un ExitCode. Consulta la documentación de la biblioteca estándar para obtener más información sobre la implementación del trait Termination para tus propios tipos.

Ahora que hemos discutido los detalles de llamar a panic! o devolver Result, volvamos al tema de cómo decidir cuál es apropiado usar en qué casos.