Rust: Tipos nulos (Option) y gestión de errores (Result)

Option, Tipos nulos

En rust no existe el tipo especial null, como en C/C++, y al ser fuertemente tipado, no se puede asignar algo de diferente tipo a una variable, por lo tanto no es posible tener variables declaradas de un tipo, por ejemplo File y que valga null si no está inicializado o algo así, ni tampoco se puede devolver null o algo así, como se haría en C++, en una función que declara como valor de retorno un tipo, y por alguna razón no tiene sentido para una entrada determinada.

Lo que se usa en rust para solucionar este tipo de problemas y poder asignar valores nulos a variables o en devoluciones es el enum Option, que se define como:

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

None sería el tipo nulo en rust, y Some(T) sería cuando tenemos un valor. Por lo tanto, si queremos declarar una variable que en principio pueda ser nula, la declararemos como Option:

struct User<'a> {
    username: &'a str,
    email: Option<&'a str>,
}

fn send_email(u: &User) {
    match u.email {
        Some(email) => println!("correo para {}", email),
        None => println!("{} no tiene correo asignado", u.username)
    }
}

fn main() {
    let mut u = User{ username: "danigm", email: None };
    send_email(&u);

    u.email = Some("danigm@wadobo.com");
    send_email(&u);
}

ejecutar

En este ejemplo, declaramos una estructura con dos atributos, el nombre de usuario que es de tipo &str y el email, que es de tipo Option<&str>, para poder tener usuarios que no tengan email asociado.

En la función send_email se utiliza el match para diferenciar si el valor es None o si tiene una cadena asociada y se actúa en consecuencia.

Además de poder diferenciar el tipo Option con patter matching, también dispone de algunos métodos que se pueden llamar para ver si tiene un valor o no:

if u.email.is_some() {
    println!("email definido: {}", u.email.unwrap());
}

if u.email.is_none() {
    println!("email no definido");
}

println!("email: {}", u.email.unwrap_or("no definido"));

En este ejemplo se usan los métodos is_some e is_none, que te devuelven un bool si el tipo es lo que preguntamos, y también se usan el unwrap, que lo que hace es devolver el valor dentro del Some, por lo que daría un panic si el Option es None. Y por último también se usa el unwrap_or, que es similar al unwrap, pero que no fallaría en caso de ser None, sino que devolvería el valor definido.

En la documentación de Option vienen todos los métodos y es recomendable echarle un vistazo porque hay muchos que son muy interesantes.

Result, Gestión de errores

En rust no existen excepciones, como en python y otros lenguajes, para gestionar los errores, ni tampoco se hace como C, que realmente no tiene gestión de errores alguna, sino que se usa el valor de retorno para indicar errores, devolviendo números negativos, por ejemplo, o 0, o algo similar.

En rust la gestión de errores se hace usando el tipo de retorno, pero no devolviendo un valor determinado, sino devolviendo un tipo Result, que te obliga a comprobar si ha funcionado bien antes de poder usar el valor devuelto, evitando así problemas de uso de variables no inicializadas y demás.

El tipo Result es muy similar al Option, que hemos visto anteriormente, la única diferencia es que el Result se define con dos tipos genéricos, el de la respuesta y el del error, para poder especificar el tipo de error:

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

Es un simple enum, por lo tanto un Result puede ser o un Ok(T) o un Err(E)

struct User<'a> {
    username: &'a str,
    email: Option<&'a str>,
}

fn transform_email(u: &User) -> Result<String, String> {
    match u.email {
        Some(email) => {
            let s = email.split('@').nth(0).unwrap();
            Ok(String::from(s) + "@newdomain.com")
        },
        None => Err(String::from("email no definido"))
    }
}

fn main() {
    let mut u = User{ username: "danigm", email: None };

    let ret = transform_email(&u);
    match ret {
        Ok(email) => println!("Este es el nuevo email: {}", email),
        Err(error) => println!("Error: {}", error)
    }

    u.email = Some("danigm@wadobo.com");
    if let Ok(email) = transform_email(&u) {
        println!("Nuevo email: {}", email);
    }
}

ejecutar

En este ejemplo se define la función transform_email, que devuelve un Result con el tipo String para el Ok y también para el Err. En este caso he usado este tipo para los dos posibles valores, pero normalmente el tipo de error implementa el trait Error.

En la función lo único que hacemos es mirar si está definido, haciendo un match al email y en caso de estar definido devolvemos Ok(...) con la cadena transformada, y en el caso de error devolvemos Err(...) con un mensaje de error.

Luego a la hora de usarlo, al ser el valor devuelto de tipo Result y no de tipo String, nos obliga a comprobar si la llamada ha sido correcta antes de poder usar el valor de retorno real. Por lo tanto hay que hacer un pattern matching y actuar en consecuencia si es un error o si es un valor correcto.

Al igual que el Option, el tipo Result tiene métodos similares definidos, como el is_ok, is_err, unwrap, unwrap_or, etc.

Propagación de errores

Esta forma de gestionar errores es muy simple, pero al obligarte a comprobar el tipo de respuesta de cada llamada que devuelve un Result, hace que se introduzca mucho código de comprobación de errores en todas las funciones, y normalmente lo que queremos es que si va bien continuamos y si va mal, devolvemos un error, osea, propagamos el error hacia arriba, como se haría en python con un raise, como funcionan las excepciones.

Para eso existe la macro "try!" en Rust que simplifica el código, y recientemente también han añadido el nuevo operador ? que hace lo mismo que la macro try!, pero a nivel de compilador, por lo que es recomendable usar este último. Sin embargo voy a mostrar código con las dos posibilidades, porque supongo que aún habrá mucho código rust por ahí que usa la macro try!.

use std::slice::Iter;

struct User<'a> {
    username: &'a str,
    email: Option<&'a str>,
}

fn transform_email(u: &User) -> Result<String, String> {
    match u.email {
        Some(email) => {
            let s = email.split('@').nth(0).unwrap();
            Ok(String::from(s) + "@newdomain.com")
        },
        None => Err(String::from("email no definido"))
    }
}

fn transform_all_emails(users: Iter<&User>) -> Result<(), String> {
    for u in users {
        let new = transform_email(u)?;
        println!("{} new email: {}", u.username, new);
    }

    Ok(())
}

fn main() {
    let mut u = User{ username: "danigm", email: None };
    let u2 = User{ username: "user2", email: Some("user2@wadobo.com") };

    if let Err(e) = transform_all_emails(vec![&u, &u2].iter()) {
        println!("Error: {}", e);
    }

    u.email = Some("danigm@wadobo.com");
    if let Err(e) = transform_all_emails(vec![&u, &u2].iter()) {
        println!("Error: {}", e);
    }
}

ejecutar

En este ejemplo, además de lo anterior, he definido otra función que transforma todos los emails de una lista de usuarios y devuelve un Result con la lista vacía si todo ha ido bien, o con un mensaje de error si algo ha fallado. Dentro de esta función no hay un return Err por ninguna parte, sin embargo se hace en la línea:

    let new = transform_email(u)?;

El operador ? lo que hace es básicamente un match de la respuesta, si es Ok(t), devuelve t, si es un Err(e), hace un return Err(e), por lo que el error se propaga hacia arriba, y el código que hay detrás puede suponer que el valor de new es un String.

Como he comentado antes, el operador ? es relativamente nuevo, el mismo código usando la macro try! sería:

    let new = try!(transform_email(u));

El operador ? sólo se puede usar dentro de funciones de devuelvan un Result y que tengan como tipo de error el mismo o compatible con las funciones que lo usan, ya que se va a hacer un return directamente del error devuelto y por tanto esos tipos tienen que coincidir.

Propagación de errores con Option

El operador ? sólo vale para el tipo Result, sin embargo, el tipo Option es muy similar y es muy típico que en una función o método sólo se pueda continuar si el Option tiene valor, es decir, si no es None, por lo tanto, puede ser interesante poder propagar los errores de forma sencilla sin tener que hacer el match y el return.

Para eso se puede utilizar el método ok_or del tipo Option, que lo que hace es devolverte un Result con el valor si es Some(t) y en caso de ser None te devolverá Err(e), siendo e la variable que se pasa como único argumento al método.

...

fn print_all_emails(users: Iter<&User>) -> Result<(), String> {
    for u in users {
        let em = u.email.ok_or(String::from(u.username) + " sin email")?;
        println!("{}", em);
    }

    Ok(())
}

...
    if let Err(e) = print_all_emails(vec![&u, &u2].iter()) {
        println!("Error: {}", e);
    }
...

Comments !