Los enumerados Option y Result

Ahora que se conocen los enumerados (enum) y los genéricos, se pueden comprender dos enumerados fundamentales en Rust que permiten hacer que el código sea más seguro: Option y Result.

En primer lugar se trata Option.

Option

Tiene dos posibilidades: Some(valor) y None y se utiliza cuando se da el caso de que un valor pueda existir o no.

Cuando el valor existe, se usa Some(valor). Cuando no existe es None.

El código siguiente, sin Option, da error (panic).

    // ⚠️
fn toma_el_quinto(valor: Vec<i32>) -> i32 {
    valor[4]
}

fn main() {
    let new_vec = vec![1, 2];
    let index = toma_el_quinto(new_vec);
}

Muestra el siguiente mensaje de error:

thread 'main' panicked at 'index out of bounds: the len is 2 but the index is 4', src\main.rs:34:5

Que el programa "entre en pánico" (panicked) significa que se detiene de forma controlada antes de que suceda el error. Rust comprueba que la función quiere obtener un valor imposible y se detiene antes del error. Recorre la pila de llamadas de función para tratar de encontrar un lugar en que se contemple el error y si no lo encuentra, como es el caso, se detiene y viene a decir "lo siento, no puedo obtener el quinto valor de este vector".

Para evitar este error, se modificará el tipo que devuelve la función de i32 a Option<i32>. Que significa que a partir de ahora esta función puede devolver Some(i32) si existe el índice, o None cuando no existe. En este caso, el valor de retorno cuando existe, i32, estará "envuelto" en un tipo Option, es decir: la función lo devuelve dentro de un Option, en concreto Some(i32). Por lo tanto, al terminar la función, hace falta algún tipo de código para que el valor embebido en Some se pueda usar.

fn toma_el_quinto(valor: Vec<i32>) -> Option<i32> {
    if valor.len() < 5 { // .len() devuelve la longitud del vector (el número de elementos).
                         // Debería ser al menos 5 si se quiere recuperar el dato en esa posición.
        None            // cuando no lo es, devuelve None
    } else {
        Some(valor[4])
    }
}

fn main() {
    let new_vec = vec![1, 2];
    let bigger_vec = vec![1, 2, 3, 4, 5];
    println!("{:?}, {:?}", toma_el_quinto(new_vec), toma_el_quinto(bigger_vec));
}

El código anterior imprime None, Some(5). Ya no falla "en pánico". Pero, ¿cómo se recupera ahora el valor 5?.

Para obtenerlo, se puede usar alguna de las funciones que tiene el tipo Option. La función unwrap() recupera el valor contenido en el Some, pero también entra en pánico si contiene un None. Es decir, que solo se debe usar cuando se sabe que el valor retornado es un Some(valor).

fn toma_el_quinto(valor: Vec<i32>) -> Option<i32> {
    if valor.len() < 5 { 
        None            
    } else {
        Some(valor[4])
    }
}

fn main() {
    let new_vec = vec![1, 2];
    let bigger_vec = vec![1, 2, 3, 4, 5];
    println!("{:?}, {:?}", 
        toma_el_quinto(new_vec).unwrap(), // esta fila falla ya que contiene None.
        toma_el_quinto(bigger_vec).unwrap());
}

El mensaje que devuelve el código anterior es:

thread 'main' panicked at 'called `Option::unwrap()` on a `None` valor', src/main.rs:13:33

La forma correcta de recuperar el valor de un Option, contemplando previamente ambas posibilidades es utilizar match. De este forma, se puede decidir qué acción realizar, imprimir o no el valor, en función del resultado. Por ejemplo:

fn toma_el_quinto(valor: Vec<i32>) -> Option<i32> {
    if valor.len() < 5 {
        None
    } else {
        Some(valor[4])
    }
}

fn manejar_opcion(mi_opcion: Vec<Option<i32>>) {
  for item in mi_opcion {
    match item {
      Some(numero) => println!("¡Encontré un {}!", numero),
      None => println!("¡Encontré un None!"),
    }
  }
}

fn main() {
    let new_vec = vec![1, 2];
    let bigger_vec = vec![1, 2, 3, 4, 5];
    let mut option_vec = Vec::new(); // se crea un vector para guardar los valores option
                                     // El vector es de tipo: Vec<Option<i32>>. Es decir, un vector de Option<i32>.

    option_vec.push(toma_el_quinto(new_vec)); // guarda "None" en el vec
    option_vec.push(toma_el_quinto(bigger_vec)); // guarda "Some(5)" en el vec

    manejar_opcion(option_vec); // revisa el vector y realiza la acción que corresponda
                               // Imprime el valor si es un Some. Y no lo toca, y lo indica, si es un None.
}

Da como resultado:

¡Encontré un None!
¡Encontré un 5!

Option utiliza genéricos en su definición, para poder definir el contenido de Some en cada caso:

enum Option<T> {
    None,
    Some(T),
}

fn main() {}

El punto importante a recordar es que con Some se incluye un valor de tipo T (cualquier tipo). Se observa que los símbolos de < y > después del nombre del enumerado Option contienen el parámetro de tipo, que es lo que le indica al compilador que este enumerado es genérico. En este caso, el enumerado puede ser uno de los dos structs: None o Some(T). Además, se observa que T no tiene ningún trait (rasgo) obligatorio como Display u otro que limite los posibles tipos de datos que se puedan incluir dentro de Some. Además, en el caso de None, ni siquiera existe un tipo de datos (ni siquiera se usa el parámetro de tipo T).

Por esto último, no se puede usar lo siguiente en la sentencia match:

#![allow(unused)]
fn main() {
// 🚧
Some(valor) => println!("El valor es {}", valor),
None(valor) => println!("El valor es {}", valor),
}

None solo es None...

Hay formas más fáciles de usar Option. En el código siguiente, se usa el método .is_some() para preguntar si el tipo del Option es Some (también hay otro método complementario denominado .is_none()).

fn toma_el_quinto(valor: Vec<i32>) -> Option<i32> {
    if valor.len() < 5 {
        None
    } else {
        Some(valor[4])
    }
}

fn main() {
    let new_vec = vec![1, 2];
    let bigger_vec = vec![1, 2, 3, 4, 5];
    let vec_of_vecs = vec![new_vec, bigger_vec];
    for vec in vec_of_vecs {
        let numero_interno = toma_el_quinto(vec);
        if numero_interno.is_some() {
            // .is_some() devuelve true si es Some, false si es None
            println!("Tenemos: {}", numero_interno.unwrap()); // ahora es seguro usar .unwrap() ya que es seguro que es Some
        } else {
            println!("No tenemos nada.");
        }
    }
}

Esto imprime:

No tenemos nada.
Tenemos: 5

Result

Es similar a Option, pero su uso es diferente:

  • Option trata sobre Some o None. La existencia o no de un valor.
  • Result trata sobre Ok o Error. La existencia de un resultado correcto o no (la existencia de un error).

Por eso, se debe usar Option cuando el razonamiento es "puede que haya un valor o puede que no". Pero se debe usar Result cuando el razonamiento es "puede que esto falle".

Si se comparan las definiciones de ambos enumerados:

enum Option<T> {
    None,
    Some(T),
}

enum Result<T, E> {
    Ok(T),
    Err(E),
}

fn main() {}

Se observa que Result tiene un valor dentro de Ok y también el caso de Err. Se trata de poder gestionar la información específica que describe el error que haya sucedido cuando este se produzca.

Al tener dos parámetros de tipo, Result<T, E>, se debe indicar qué se devuelve cuando el resultado es correcto, Ok(T), y qué se devuelve cuando el resultado es erróneo, Err(E). Puede ser cualquier cosa que se decida, incluso:

fn check_error() -> Result<(), ()> {
    Ok(())
}

fn main() {
    check_error();
}

check_error indica que se devuelva (), tanto en el caso de Ok, como en el caso de Err. Aunque en el ejemplo, siempre devuelve Ok(()).

En todo caso, con este código, el compilador da un aviso interesante:

warning: unused `std::result::Result` that must be used
 --> src\main.rs:6:5
  |
6 |     check_error();
  |     ^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_must_use)]` on by default
  = note: this `Result` may be an `Err` variant, which should be handled

El compilador avisa de que se prevé que devuelva Result, por lo que el código podría haber devuelto un Err, pero no se está tratando dicha posibilidad de error.

A continuación se presenta un código que trata el posible caso de error:

fn dar_resultado(input: i32) -> Result<(), ()> {
    if input % 2 == 0 {
        return Ok(())
    } else {
        return Err(())
    }
}

fn main() {
    if dar_resultado(5).is_ok() {
        println!("Es correcto")
    } else {
        println!("Es un error")
    }
}

Este código imprime Es un error. Así se ha gestionado el error.

Se puede recordar que para Option y para Result, dos métodos, respectivamente, para chequear de forma sencilla el tipo concreto, son: .is_some(), is_none(), is_ok() y is_err().

En ocasiones, una función con Result usará String para el valor contenido en Err. Aunque no es la mejor forma, es mejor que lo visto hasta el momento:

fn comprueba_si_es_cinco(numero: i32) -> Result<i32, String> {
    match numero {
        5 => Ok(numero),
        _ => Err("Lo siento, el número no era cinco.".to_string()), // Este es el mensaje de error
    }
}

fn main() {
    let mut result_vec = Vec::new(); // Crea un vector para contener el resultado

    for numero in 2..7 {
        result_vec.push(comprueba_si_es_cinco(numero)); // guarda cada resultado
    }

    println!("{:?}", result_vec);
}

El vector imprime lo siguiente:

[Err("Lo siento, el numero no era cinco."), Err("Lo siento, el numero no era cinco."), Err("Lo siento, el numero no era cinco."), Ok(5),
Err("Lo siento, el numero no era cinco.")]

Igual que en el caso de Option, .unwrap() sobre un valor de tipo Err "provoca el pánico" del programa.

    // ⚠️
fn main() {
    let valor_error: Result<i32, &str> = Err("Hubo un error"); // Crea un Result que es un Err
    println!("{}", valor_error.unwrap()); // Intenta recuperar el valor del resultado (asumiendo que sería correcto, cosa que no es)
}

El programa falla (panic) e imprime:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: "Hubo un error"', src/main.rs:4:32

Esta información, src/main.rs:4:32 significa que el pánico se ha producido en "main.rs en el directorio src, en la línea 4 y columna 32". Por lo que se puede mirar ahí para ver cuál es el problema y resolverlo.

También se pueden crear nuevos tipos de error, que es lo habitual. Las funciones de la librería estándar y otras librerías lo suelen hacer. Por ejemplo, esta función de la librería estándar:

#![allow(unused)]
fn main() {
// 🚧
pub fn from_utf8(vec: Vec<u8>) -> Result<String, FromUtf8Error>
}

Esta función toma un vector de bytes (u8) e intenta convertirlo en una String. Como puede ser que los bytes no se correspondan con códigos válidos en UTF8, en lugar de devolver un String, devuelve un Result con un String en el caso de éxito y un error FromUtf8Error en el caso de que falle la conversión. Se puede usar cualquier nombre para el caso de fallo.

En ocasiones, el uso de match con Option y Result requiere mucho código. Por ejemplo, el método .get() devuelve un Option sobre un Vec.

fn main() {
    let my_vec = vec![2, 3, 4];
    let get_one = my_vec.get(0); // recupera el primer número
    let get_two = my_vec.get(10); // recupera None
    println!("{:?}", get_one);
    println!("{:?}", get_two);
}

Que imprime:

Some(2)
None

Para obtener los valores, se puede usar match.

fn main() {
    let my_vec = vec![2, 3, 4];

    for index in 0..10 {
      match my_vec.get(index) {
        Some(number) => println!("El número es: {}", number),
        None => {}
      }
    }
}

Este código es correcto, pero queremos hacer nada en el caso de None. En estos casos, se puede optar por escribir un código más compacto mediante el uso de if let que permite "hacer algo si coincide un valor" y "no hacer nada en el resto de casos".

fn main() {
    let my_vec = vec![2, 3, 4];

    for index in 0..10 {
      if let Some(number) = my_vec.get(index) {
        println!("The número es: {}", number);
      }
    }
}

Importante, a recordar: if let Some(number) = my_vec(index) significa que "compruebe si el valor de my_vec(index) es compatible con Some(number).

Además, hay que tener en cuenta que se usa =.

También existe while let que es un bucle for en el que se comprueba como en if let. Por ejemplo, si se dispone de los siguientes datos del tiempo procedentes de estaciones meteorológicas:

["Berlin", "cloudy", "5", "-7", "78"]
["Athens", "sunny", "not humid", "20", "10", "50"]

Y se desea obtener solo los números. Para ello, se puede usar el método parse::<i32>(). Este método intenta convertir un &str en un i32 y lo entrega en un valor de tipo Resultya que podría no funcionar si se le pasa algo que no es un número entero.

En el siguiente ejemplo, también se usará .pop() para extraer el último elemento del vector.

fn main() {
    let weather_vec = vec![
        vec!["Berlin", "cloudy", "5", "-7", "78"],
        vec!["Athens", "sunny", "not humid", "20", "10", "50"],
    ];
    for mut city in weather_vec {
        println!("Para la ciudad de {}:", city[0]); // En los datos, el primer elemento siempre es el nombre de la ciudad
        while let Some(information) = city.pop() {
            // Esto significa: continua mientras hay valores
            // Cuando no queden valores pop retorna None
            // y se saldrá del bucle while
            if let Ok(number) = information.parse::<i32>() {
                // Intenta obtener un entero
                // Devuelve un Result. Si es Ok(number), se imprimirá
                println!("El número es: {}", number);
            }  // No se hace nada, si no era un número, casos en los que se devuelve Err
        }
    }
}

Que imprimirá:

Para la ciudad de Berlin:
El número es: 78
El número es: -7
El número es: 5
Para la ciudad de Athens:
El número es: 50
El número es: 10
El número es: 20