Title: Manejo de excepciones en Rust

Date: 2019-12-20
Content:

Uno de los temas que mas me ha costado aprender en Rust ha sido la manera de manejar los posibles errores que se puedan tener en tiempo de ejecución ya que, a diferencia de los demás lenguajes que ya había manejado (Python, Ruby o Java) ya que el concepto de "excepción" no existe como tal, sino que todo se trabaja mediante "panic!", "Option" y "Result" y unos métodos unwrap o unwrap_or y en este post explicaré cada uno de ellos.

panic!

panic! es un macro (no entraré en la definición de macro en este momento, esto será para una futura entrada. Pero para fines prácticos, es algo parecido a una función) que lo que hace es terminar con el hilo principal de ejecución de manera completamente abrupta y es imposible evitar su comportamiento. Así que nuestro objetivo es intentar tener el menor número de ejecuciones de panic!, en la medida de lo posible, y solo ejecutarlo cuando sea 100% necesario.
Es imposible de detener porque a nivel de ensamblador se está mandando llamar a la instrucción ud2 la cual genera un código de ejecución inválido y eso detiene en seco el hilo principal.

// Rust
fn main() {
    panic!();
}

; Ensamblador
...
example::main:
    push    rax
    lea     rdi, [rip + .L__unnamed_9]
    lea     rdx, [rip + .L__unnamed_10]
    mov     rax, qword ptr [rip + std::panicking::begin_panic@GOTPCREL]
    mov     esi, 14
    call    rax
    ud2
    ^ ^
    ╚═╩═ Generación de un código inválido ejecución
...

Su funcionamiento es de lo mas sencillo y tiene un cierto parecido a la función sys.exit de Python, solo que el macro panic! acepta un str como parámetro para indicarle al usuario cual fue el posible error y este es opcional.

// Código
fn main() {
    panic!();
}

// Salida
...
thread 'main' panicked at 'explicit panic', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.


// Código
fn main() {
    panic!("Error en la función 'main'");

}

// Salida
...
thread 'main' panicked at 'Error en la función 'main'', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

Option

Ahora si comenzamos con el manejo de excepciones como tal.
Option es un Enum (Si no estás familiarizado con el término "Enum" pero si con el término "Tipo", en tu mente reemplaza uno por otro, para este caso son parecidos) que puede contener 2 posibles valores mutuamente excluyentes, y son "algún valor" o "ninguno", que traducido a lenguaje Rust sería un Some o un None.
Este Enum se tendrá que usar cuando nuestra función es posible que devuelva un valor o que no devuelva nada.
La declaración en el campo a retornar es muy simple, solo se debe indicar que la función retornará una instancia del Enum Option, un diamante (<>) y dentro del diamante el tipo de datos del posible valor a retornar.
El valor a retornar debe estar "encapsulado" dentro de otro Enum llamado Some y el dato dentro de ella debe ser del mismo tipo que la declarada dentro del diamante en la cabecera de la función.

fn factorial(n: i32) -> Option<i32> {
                        ^ ^ ^  ^ ^
                        ║ ║ ║  ╚═╩═ Tipo de dato del posible valor a retornar
                        ╚═╩═╩═ Nombre del Enum
    if n < 0 {
        None
        ^ ^
        ╚═╩═ Se retorna el Enum "None" cuando no se retorna un valor
    } else if n == 0 {
        Some(1)
        ^ ^  ^
        ║ ║  ╚═ Valor que se retornará
        ╚═╩═ Enum Some
    } else {
        let mut total = 1;
        for value in 1..n {
            total *= value;
        }
        Some(total)
        ^ ^  ^ ^ ^
        ║ ║  ╚═╩═╩═ Valor que se retornará
        ╚═╩═ Enum Some
    }
}

Como se puede ver en el ejemplo, la función factorial puede recibir un parámetro del tipo i32 y retornará un posible valor cuyo tipo de dato será también i32 y dentro de la función se retornan el Enum None en caso de que no se deseé retornar ningún valor, o un Enum Some cuando la función deba retornar un valor. simple, eh? Ahora vamos a ver como lidiar con lo que retorne esa función.
Una de las maneras mas sencillas de trabajar con los Option es con la estructura de control match, poniendo las posibles opciones como criterios de coincidencia dentro.
Aquí un ejemplo:

fn factorial(n: i32) -> Option<i32> { ... }

fn main() {
    let valor = factorial(10);
                ^ ^ ^ ^ ^ ^ ^
                ╚═╩═╩═╩═╩═╩═╩═ Llamada a la función "factorial"
    match valor {
        Some(1) => println!("Posible factorial de 0 o 1"),
        ^ ^  ^
        ║ ║  ╚═ Criterio de coincidencia exacto
        ╚═╩═ Enum Some
        Some(n) => println!("El factorial es: {}", n),
        ^ ^  ^
        ║ ║  ╚═ Criterio de coincidencia con un identificador
        ║ ║     Se puede acceder a el desde el bloque de ejecución
        ╚═╩═ Enum Some
        None => println!("No se puede sacar el factorial de un número negativo"),
        ^ ^
        ╚═╩═ Enum None
    }
}

Como se puede ver en el ejemplo, utilizando match se pueden declarar los posibles criterios de coincidencia, ya sea con valores (Some(1)) para que solo se ejecute el bloque si la función retorna explícitamente un Some con valor "1". O también con una variable Some(n) y esa variable se puede utilizar dentro del bloque a ejecutar y su Scope no va mas allá. Y por último también se puede hacer la coincidencia con el Enum None cuando la función no deba retornar nada.
Como podemos ver, la utilización del Enum Option es una manera mas elegante de hacer cosas hacky como devolver un "-1" cuando una función puede devolver un valor numérico.

Result

Result es otro Enum que tiene cierto parecido a Option pero con una ligera diferencia y es que se debe retornar un Result cuando la función deba retornar un error por culpa de alguno de los parámetros introducidos. Para indicar que una función retornará un Result se debe colocar en la cabecera de la función el nombre del Enum Result, un diamante y dentro del diamante 2 tipos de datos, el primero corresponderá al tipo de dato de una respuesta exitosa y el segundo corresponderá al tipo de dato de la respuesta errónea.
Los valores a retornar deberán ser "encapsulados" en el Enum Ok y en el Enum Err y debe corresponder al tipo de dato correspondiente al colocado dentro del diamante.
Como por ejemplo:

fn div(num1: i32, num2: i32) -> Result<f64, &'static str> {
                                ^ ^ ^  ^ ^  ^ ^ ^ ^ ^ ^
                                ║ ║ ║  ║ ║  ╚═╩═╩═╩═╩═╩═ Tipo de dato del error
                                ║ ║ ║  ╚═╩═ Tipo de datos para el caso de éxito
                                ╚═╩═╩═ Nombre del Enum
    if num2 == 0 {
        Err("No se puede dividir un número entre 0")
        ^ ^  ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
        ║ ║  ╚═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═ Valor que retornará como error
        ╚═╩═ Nombre del Enum
    } else {
        Ok(num1 as f64 / num2 as f64)
        ^  ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
        ║  ╚═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═ Valor que retornará en caso de éxito
        ╚═ Nombre del Enum
    }
}

Como se puede ver en el ejemplo, en la cabecera de la función, en la parte donde se especifica que va a retornar, se debe colocar el Enum Result y dentro de un diamante los 2 tipos de datos, en este caso será un f64 para los casos de éxito y un &'static str (str estático) para los casos erróneos y dentro de la propia función se "encapsulan" los posibles valores de retorno en los Enum Ok y Err para los casos de éxito y de error según correspondan.
Ahora, la manera de lidiar con el valor de una función que retorna un Result es muy similar a la manera de tratar con los Enum Option... con un match` y dentro, todas los posibles criterios de coincidencia.

fn div(num1: i32, num2: i32) -> Result<f64, &'static str> { ... }

fn main() {
    let valor = div(10, 2);
                ^ ^ ^ ^ ^
                ╚═╩═╩═╩═╩═ Llamada a la función "div"
    match valor {
        Ok(3.14159) => println!("wow, tu división retorna el número pi"),
        ^  ^ ^ ^ ^
        ║  ╚═╩═╩═╩═ Criterio de coincidencia exacto
        ╚═ Enum Ok
        Ok(n) => println!("El resultado es: {}", n),
        ^  ^
        ║  ╚═ Criterio de coincidencia con un identificador
        ║     Se puede acceder a el desde el bloque de ejecución
        ╚═ Enum Ok
        Err(e) => println!("{}", e),
        ^ ^ ^
        ║ ║ ╚═ Criterio de coincidencia con un identificador
        ║ ║    Se puede acceder a el desde el bloque de ejecución
        ╚═╩═ Enum Err
    }
}

Se manda a comparar el valor retornado por la función utilizando un match y los criterios deben ser los Enum Ok con un valor exacto, con una variable que se puede utilizar dentro del bloque de ejecución o el Enum Err que sigue las mismas reglas, se puede poner un valor exacto o una variable para utilizar dentro del bloque de ejecución.

pro-tip 1:

Si por algún motivo no te interesa el valor retornado dentro del Ok, Err o Some puede poner como identificador un "_", así el compilador ignorará ese valor por completo y será un criterio de coincidencia válido, como por ejemplo:

fn div(num1: i32, num2: i32) -> Result<f64, &'static str> { ... }

fn main() {
    match div(10, 0) {
        Ok(_) => println!("La división se ejecutó correctamente"),
        ^  ^
        ║  ╚═ Criterio de coincidencia con "_"
        ║     El identificador al ser un "_", el valor nunca es guardado en memoria y es inaccesible
        ╚═ Enum Ok
        Err(_) => println!("Error ejecutando la división"),
        ^ ^ ^
        ║ ║ ╚═ Criterio de coincidencia con "_"
        ║ ║    El identificador al ser un "_", el valor nunca es guardado en memoria y es inaccesible
        ╚═╩═ Enum Err
    }
}

pro-tip 2:

Para mantener mas elegante nuestro código, es recomendable no retornar str's en los mensajes de error de los Result, es mucho mejor implementar Enum's propios para cada posible error.

enum MathError {
    ZeroDivision,
    Domain
}

fn div(num1: i32, num2: i32) -> Result<f64, MathError> {
                                            ^ ^ ^ ^ ^
                                            ╚═╩═╩═╩═╩═ Nombre de la estructura que contiene los posibles errores
    if num2 == 0 {
        Err(MathError::ZeroDivision)
            ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
            ╚═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═ Valor dentro del Enum que contiene nuestro error deseado
    } else {
        Ok(num1 as f64 / num2 as f64)
    }
}

fn main() {
    match div(10, 0) {
        Ok(n) => println!("El resultado es: {}", n),
        Err(MathError::ZeroDivision) => println!("Oh no!, División entre 0"),
            ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
            ╚═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═╩═ Criterio de coincidencia más explícito para los errores
        _ => println!("Error desconocido"),
        ^
        ╚═ Como nuestro Enum de errores tiene mas de un valor aparte del "ZeroDivision",
           se debe declarar una opción por defecto como criterio de coincidencia 
    }
}

Los métodos unwrap

Los match son maneras muy elegantes para manejar los Option o los Result, pero en algunas ocasiones quizás querremos lidiar con ellos de una manera menos verbosa y justo para ese motivo existe la gama de métodos unwrap.
El comportamiento de los métodos unwarp dependerá si el Option o el Result tiene un resultado exitoso y son los siguientes:

Nombre del método Comportamiento Ejemplo
unwrap Intenta obtener el valor encapsulado dentro de un Some o un Ok. Sí la función no devolvió alguno de ellos y se ejecutará el macro panic!. No recibe parámetros. let valor = factorial(1).unwrap(0);
unwrap_or Intenta obtener el valor encapsulado dentro de un Some o un Ok. Si la función devolvió un None o un Err, la función devolverá el valor pasado como parámetro que deberá ser del mismo tipo de dato declarado en el diamante para el caso de éxito. Recibe como parámetro el valor a devolver. let valor = div(10, 0).unwrap_or(0f64);
unwrap_or_else Intenta obtener el valor encapsulado dentro de un Some o un Ok. En caso de que la función haya devuelto un None o un Err la función retornará el valor que devuelva el closure pasado como parámetro. Recibe como parámetro el closue a ejecutar, en caso de que la función retorne un Result, el closure deberá tener un argumento y si retorna un Option el closure no debe tener argumentos. let valor = factorial(-1).unwrap_or_else(|| 0); let valor = div(10, 0).unwrap_or_else(|_| 0f64);
unwrap_or_default Intentará obtener el valor encapsulado dentro de un Some o un Ok. Si el valor fue ninguno o un error, el método retornará los valores por defecto de cada tipo de dato. No recibe ningún parámetro. let valor = factorial(0).unwrap_or_default();
expect Intenta obtener el valor encapsulado dentro de un Some o un Ok. Si la función retornó un None o un Err, el método mandará a llamar al macro panic! con el mensaje que se le pase como parámetro. Recibe un parámetro del tipo "str" let valor = div(0, 0).expect("Oh no");

Espero que les haya gustado y que les haya servido la información aquí proporcionada. Pueden comentarme mediante mi twitter @kirbylife si algo de lo aquí leído es incorrecto o confuso. Con gusto los leeré :).

Fuentes

  1. https://doc.rust-lang.org/std/result/enum.Result.html
  2. https://doc.rust-lang.org/std/option/enum.Option.html
  3. The Rust programming language. Por Steve Klabnik y Carol Nichols.
  4. https://rust.godbolt.org (para traducción de código Rust a ensamblador).
  5. https://learning-rust.github.io/docs/e3.option_and_result.html

Actualizaciones

23-12-2019: Arreglados algunos typos. Gracias Nacho.
28-02-2020: Arreglados algunos typos. Gracias VMS.
17-06-2020: Arreglado un typo. Gracias Marc.