Manejo de errores en Rust: una introducción a Result
Una de las razones por las que me agrada trabajar con Rust es su intencionalidad. Este lenguaje require que el programador razone acerca de las ramificaciones en la ejecución, que tome cierta decisión al respecto y que la refleje en el código. El manejo de errores es un área donde la intencionalidad se hace muy evidente.
En otros lenguajes de programación es común operar bajo la idea de que los errores son instancias que se deben manejar. Seguramente alguna vez han escrito o encontrado sentencias de este tipo en Javascript:
try {
return somethingThatCanBreak();
} catch (err) {
return null;
}
El código anterior funciona bajo el principio del control de excepciones, donde todo lo que resulta en una excepción puede ser rodeado por código que captura el error y evita en mayor medida el colapso del sistema.
En este artículo observaremos las diferencias que se derivan al optar por representar operaciones exitosas o erróneas mediante un tipo de datos en lugar de excepciones.
Nota: El código de los ejemplos lo pueden encontrar en este enlace.
Result<T,E>
En Rust existen excepciones recuperables e irrecuperables. Las irrecuperables provocan la terminación inmediata de la ejecución. Resultan ser muy útiles cuando se violan ciertos principios como el acceso a datos en la memoria. Las recuperables son aquellas que no requiren una terminación inmediata, por ejemplo un fallo en la conversión de un string
a un dígito u32
.
La enum``[Result<T, E>](https://doc.rust-lang.org/std/result/index.html)
nos ayuda a interpretar los errores recuperables y ser capaces de resolver de forma explícita cuando ocurren. Esta enum
encapsula o bien una ejecución exitosa Ok(T)
o bien un error Err(E)
en la operación.
// std::result::Result
pub enum Result<T, E> {
Ok(T),
Err(E),
}
Brevemente podemos ver cómo el uso de esta definición es aplicado en el siguiente código
fn get_response() -> Option<String> {
match something_than_can_break() {
Ok(result) => Some(result),
Err(_) => None,
}
}
En este ejemplo decidimos tomar acción ante la posibilidad de tener un error en la operación al devolver None
en nuestra función, similar a lo que vimos antes en Javascript al devolver null
al detectar el error.
Propagando errores
También podemos optar por propagar el error para que otra función que se encuentre debajo en la pila de ejecución se haga cargo de lidiar con él. Esto se realiza con el operador **?**
que es aplicado como sufijo en cada operación que retorna un Result
.
use std::fs::File;
fn parse_log() -> Result<(), std::io::Error> {
let file = File::open("log.txt")?;
/* operaciones con el archivo */
[...]
Ok(())
}
Cualquier función que invoque a parse_log()
podrá también optar por manejar el error o continuar propagándolo, eventualmente alcanzando la función main
y posiblemente interrumpiendo el programa si no se realiza el manejo adecuado.
Múltiples tipos de errores en una sola función
Dado que Rust es un lenguaje riguroso el compilador requiere saber con qué tipo de datos está lidiando para decidir si un código es correcto o no. Por lo anterior encontraremos que si queremos propagar una función con Result
en la cual los tipos de errores no son iguales necesitaremos hacer uso de otras herramientas.
Como ejemplo tenemos una función que lee líneas de un archivo, las convierte a números y los devuelve en un vector.
use std::io::BufReader;
use std::fs::File;
fn read_integers() -> Result<Vec<i64>, std::io::Error> {
let file = File::open("numbers.txt")?;
let mut buffer = BufReader::new(file);
let mut numbers = vec![];
for line in buffer.lines() {
let i = line?;
numbers.push(i.parse()?);
}
Ok(numbers)
}
El compilador rechazará dicha función indicando que la operación parse()
puede retornar un tipo de error que no es compatible con std::io::Error
.
$ cargo run
... couldn't convert the error to `std::io::Error` the trait `std::convert::From<std::num::ParseIntError>` is not implemented for `std::io::Error`
Para resolver dicho error podemos hacer uso de diversas opciones.
Opción uno: tipo de datos local con genéricos
La librería estándar define al traitError
como la base para otros errores específicos. Cualquier librería que quiera retornar un error debe implementar este trait.
Ciertos errores podrán contener un número con un código de error, otros una cadena con un mensaje, otros quizá una estructura de datos, etc. Dado que el compilador también necesita saber qué tamaño tiene un tipo de datos requeriremos hacer uso de Box
dado que esta estructura tiene un tamaño determinado al ser un apuntador.
Con base en esto podemos decir que en general un error en Rust es todo aquel tipo de datos que implementa este trait y lo accederemos mediante un apuntador.
type GenericError = Box<dyn std::error::Error>
Podemos ir un poco más allá y extender la definición para que sea capaz de operar en entornos multi-hilos y ser pasado entre diversas funciones.
// Pasar entre hilos (Send + Sync) y vivir durante todo el programa ('static).
type GenericError = Box<dyn std::error::Error + Send + Sync + 'static>;
Usando esta definición podemos crear un nuevo tipo de Result
que use GenericError
.
type GenericResult<T> = Result<T, GenericError>;
Y usarlo para que nuestra función sea capaz de devolver el vector o cualquier error
fn read_integers() -> GenericResult<Vec<i64>> {
...
}
Opción dos: anyhow
Con el crate anyhow se pueden manejar diversos tipos de errores sin tener que crear los tipos genéricos que vimos anteriormente. Su uso es muy simple dado que sólo requiere importarlo en nuestro código.
use anyhow::Result;
fn read_integers() -> Result<Vec<i64>> { ... }
Opcionalmente puede proveer un mensaje que dé contexto al error que resultó en la operación.
use anyhow::{Context, Result};
fn main() -> Result<()> {
...
let content = std::fs::read(path)
.with_context(|| format!("failed to read instrs from {}", path))?;
...
}
Opción tres: thiserror
El crate thiserror permite definir enums
y structs
con los cuales es posible encapsular otros errores de forma sencilla.
use thiserror::Error;
#[derive(Debug, Error)]
enum ParserErrors {
#[error("Error Reading File")]
IoError(#[from] std::io::Error),
#[error("Error Parsing")]
ParserError(#[from] std::num::ParseIntError)
}
Los elementos del enum
reflejan los tipos de errores que se pueden encontrar en las funciones donde queremos utilizarlos. Además es posible también otorgar más información contextual en caso de que estos sean retornados.
Después de definir este enum podemos usarlo como valor de retorno de nuestra función.
fn read_integers() -> Result<Vec<i64>, ParserErrors> { ... }
Cabe mencionar que este crate no deja ninguna huella cuando generamos el ejecutable o la librería de nuestro programa ya que se limita a expandir el manejo de los errores a través de macros.
Conclusión
Rust nos otorga varias ventajas al optar por Result
en lugar de excepciones en el diseño de nuestros programas. Podemos elegir qué hacer con los errores en lugar de omitir su manejo y caer en negligencias.
Una forma de manejarlos es mediante el uso de match
, otra forma común es su propagación mediante el operador **?**
.
El uso de **?**
modifica el encabezado de nuestra función al convertir el Result
en su valor de retorno. Es muy probable que en nuestra función existan diversos tipos de errores conviviendo por lo cuál habrá que lidiar con ello en el retorno de nuestra función. Para ello podemos hacer uso de genéricos o de crates como anyhow y thiserror.
Rust verifica que los valores del tipo Result
sean usados de forma que no es posible dejar un error sin tratar. Al final nos encontraremos razonando más sobre la ingeniería de errores lo cual es importante en la programación de sistemas.