17  Manejo de errores

Dedicamos el Capítulo 12 a los tipos enumerados, estudiaremos ahora uno de los más importantes de la biblioteca estándar: Result<T, E>.

Durante la ejecución de un programa pueden producirse situaciones que impidan continuar una operación con normalidad. En muchos lenguajes de programación estas situaciones se gestionan mediante excepciones. Rust adopta un enfoque diferente, distingue entre errores recuperables y errores no recuperables.

Este enfoque obliga al programador a considerar explícitamente la posibilidad de que una operación falle. Como consecuencia, el código resulta más robusto y es menos probable que un error pase inadvertido.

17.1 Errores recuperables: Result<T, E>

Las operaciones que pueden fallar de forma recuperable devuelven un valor de tipo Result<T, E>, que representa explícitamente que la operación puede tener éxito o fracasar. De forma simplificada, la definición de Result es la siguiente:

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

El significado de sus dos parámetros genéricos es:

  • T representa el tipo del valor devuelto cuando la operación tiene éxito.
  • E representa el tipo que describe el error cuando la operación falla.

Por tanto, un valor de tipo Result<T, E> siempre estará en uno de estos dos estados:

  • Ok(valor), si la operación se ha realizado correctamente.
  • Err(error), si la operación ha fallado.

En los siguientes apartados veremos distintas formas de procesar un valor de tipo Result<T, E> y actuar de forma diferente según la operación haya tenido éxito o haya producido un error.

Por ejemplo, la función parse() convierte una cadena de caracteres en un número entero. Como la conversión puede fallar, devuelve un Result.

fn main() {
    let mut numero = "125".parse::<i32>();
    println!("{:?}", numero);   // ok

    numero = "abc".parse::<i32>();
    println!("{:?}", numero);  // Error (recuperable). 
}

Obsérvese que la función no devuelve directamente un entero, sino un Result<i32, ParseIntError>. En este caso el resultado es:

  • Ok(125), si la conversión se realiza correctamente.

  • Err(ParseIntError { kind: InvalidDigit }), si la cadena no representa un número entero válido.

De esta forma, el compilador obliga al programador a considerar explícitamente ambos casos: el éxito de la operación (Ok) y el posible error (Err).

Es importante observar que un Err no representa un fallo del programa ni una excepción. Simplemente indica que una determinada operación no pudo realizarse. El programa continúa ejecutándose con normalidad y es el propio código quien decide cómo actuar ante esa situación.

17.2 match con Result

En el Capítulo 13 vimos que la instrucción match permite distinguir entre las distintas variantes de un tipo enumerado. Como Result<T, E> es también un tipo enumerado, se procesa exactamente del mismo modo.

El siguiente ejemplo intenta convertir una cadena en un número entero. Si la conversión tiene éxito, muestra el valor obtenido. En caso contrario, informa del error.

use std::num::ParseIntError;

fn obtiene_numero(cadena: &str) -> Result<i32, ParseIntError> {
    let numero = match cadena.parse::<i32>() {
        Ok(valor) => valor,
        Err(error) => return Err(error),
    };
    Ok(numero)
}

fn main() {
    println!("{:?}", obtiene_numero("125"));
    println!("{:?}", obtiene_numero("hola"));
}

Al ejecutar el programa, la primera llamada a extrae_numero() produce un Ok, por lo que se ejecuta la primera rama del match. La segunda llamada produce un Err, ejecutándose la segunda rama.

Obsérvese que, al igual que ocurría con Option<T>, los patrones Ok(numero) y Err(error) no solo permiten distinguir la variante del enumerado, sino también extraer el valor asociado a cada una de ellas.

17.3 El operador ?

Al escribir una función, es frecuente llamar a otra función que devuelve un Result. En muchas ocasiones interesa hacer lo siguiente:

  • Si la operación tiene éxito, continuar la ejecución utilizando el valor obtenido.
  • Si la operación produce un error, devolver ese mismo error al llamador.

Veamos un ejemplo con esta estructura, utilizando match. La función obtiene_numero() intentará convertir una cadena en un valor de tipo i32 mediante el método parse(). Si la conversión tiene éxito, escribirá el número obtenido en pantalla. En caso contrario, devolverá al llamador el mismo error que proporciona parse(), de tipo ParseIntError.

Para ello debemos importar este tipo de error:

use std::num::ParseIntError;

Además, como la función puede tener éxito o producir un error, su valor de retorno será:

Result<i32, ParseIntError>

Con match, podríamos escribirlo así:

use std::num::ParseIntError;

fn obtiene_numero(cadena: &str) -> Result<i32, ParseIntError> {
    let numero = match cadena.parse::<i32>() {
        Ok(valor) => valor,
        Err(error) => return Err(error),
    };
    Ok(numero)
}

fn main() {
    println!("{:?}", obtiene_numero("125"));
    println!("{:?}", obtiene_numero("hola"));
}

Obsérvese especialmente la siguiente rama del match:

Err(error) => return Err(error),

Si parse() falla, la función no continúa ejecutándose. En su lugar, finaliza inmediatamente y devuelve ese mismo error al llamador.

Este patrón aparece con tanta frecuencia que Rust proporciona el operador ?, es azúcar sintáctico que ofrece el mismo comportamiento, pero permite expresarlo de forma mucho más concisa. El ejemplo anterior queda así.

use std::num::ParseIntError;

fn obtiene_numero(cadena: &str) -> Result<i32, ParseIntError> {
    let numero = cadena.parse::<i32>()?;
    Ok(numero)
}

fn main() {
    println!("{:?}", obtiene_numero("125"));
    println!("{:?}", obtiene_numero("hola"));
}

El comportamiento del operador ? es el siguiente:

  • Si el resultado es Ok(valor), extrae el valor contenido y la ejecución continúa normalmente.

  • Si el resultado es Err(error), la función finaliza inmediatamente devolviendo dicho error al llamador.

Por tanto, el operador ? solo puede utilizarse en funciones que devuelven un Result (o tipos compatibles). Por tanto, un intento de usar ? en funciones que devuelvan algún tipo diferente, provocará un error de compilación.

Esta es la forma idiomática de propagar errores en Rust y aparece con gran frecuencia en el código escrito por la biblioteca estándar y por la mayoría de los proyectos.

Para quien se acerca a Rust, este operador puede resultar sorprendente. En unas ocasiones produce un valor y en otras finaliza inmediatamente la función devolviendo un error. Cabría esperar un comportamiento más homogéneo: o que siempre produzca un valor o que siempre finalice devolviendo algo.

La aparente asimetría viene de que ? no es un operador de expresiones, su propósito no es producir un valor. Realmente es un mecanismo de propagación de errores.

  • Si todo va bien, produce el valor contenido en Ok.
  • Si algo va mal, abandona la función actual devolviendo el contenido de Err.

Rust no tiene excepciones, pero semánticamente este operador se parece al throw de otros lenguajes, no a un operador convencional.

Aunque aún no hemos estudiado el manejo de ficheros, el siguiente ejemplo resulta suficientemente intuitivo y permite apreciar la utilidad del operador ?. Sin él, escribiríamos como este:

let f = match File::open("datos.txt") {
    Ok(f) => f,
    Err(e) => return Err(e),
};

let s = match f.read_to_string() {
    Ok(s) => s,
    Err(e) => return Err(e),
};

La alternativa, con ?, es así:

let f = File::open("datos.txt")?;
let s = f.read_to_string()?;

La reducción de ruido es enorme. La filosofía de Rust es muy clara: el código correspondiente a la ejecución normal (el llamado happy path) debe verse limpio, mientras que la propagación de errores debe ser automática, pero seguir siendo visible. Precisamente eso es lo que consigue el operador ?.

Si bien, una misma función puede llamar a varias operaciones que devuelven tipos de error diferentes. En ese caso, no siempre es posible aplicar el operador ? a todas ellas directamente, ya que la función debe devolver un tipo de error compatible con cada una de esas operaciones. Una solución habitual consiste en convertir todos los errores a un mismo tipo, aunque esta técnica queda fuera del alcance de este curso.

17.4 panic! y errores no recuperables

Como vimos en la introducción del capítulo, un error no recuperable indica que el programa ha alcanzado un estado del que no puede continuar de forma segura. En estos casos Rust utiliza la macro panic!, que finaliza inmediatamente la ejecución del programa mostrando un mensaje de error.

La forma más sencilla de provocar un panic! consiste en llamar directamente a esta macro:

fn main() {
    panic!("Se ha producido un error irrecuperable.");
}

Además del mensaje indicado por el programador, Rust informa del archivo y de la línea en la que se produjo el error. Esta información resulta muy útil para localizar el origen del problema.

En la práctica, la mayoría de los panic! no son escritos directamente por el programador, sino que son generados por la biblioteca estándar cuando detecta una situación de la que no es posible recuperarse.

Un ejemplo es intentar acceder a una posición inexistente de un vector mediante el operador []. Este programa finaliza con un panic!, ya que el vector tiene elementos desde la posición 0 hasta la 2, pero no en la posición 3.

fn main() {
    let numeros = vec![10, 20, 30]; 
  
    println!("{}", numeros[3]);  // ¡Error! Índice fuera de rango.
}

En general, un panic! suele indicar un error de programación o una situación que el programa considera imposible durante una ejecución correcta. Cuando una operación puede fallar de forma habitual, Rust prefiere utilizar Result<T, E>, permitiendo que sea el propio programa quien decida cómo actuar.

17.5 unwrap() y expect() sobre Result

En la sección Sección 12.4.3 vimos que el método unwrap() permitía extraer el valor contenido en un Option<T>, provocando un panic! cuando el valor era None.

El tipo Result<T, E> dispone del mismo método:

  • Si el resultado es Ok(valor), unwrap() devuelve el valor contenido.
  • Si el resultado es Err(error), provoca un panic!.

Por ejemplo:

fn main() {
    let mut numero = "125".parse::<i32>().unwrap();
    println!("{}", numero);

    numero = "hola".parse::<i32>().unwrap();
    println!("{}", numero);
}

Este programa escribe el valor 125. Sin embargo, cuando la cadena no representa un número entero válido, el programa finaliza con un panic!.

El método expect() se comporta de forma similar a unwrap(). Pero, en caso de fallo, además del mensaje habitual del panic!, muestra un mensaje personalizado indicado por el programador. Un buen programa no solo detecta los errores, sino que también proporciona mensajes claros y útiles que ayuden a comprender su causa y faciliten su depuración.

fn main() {
    let numero = "hola"
        .parse::<i32>()
        .expect("La cadena debería contener un número entero");

    println!("{numero}");
}

Obsérvese que este ejemplo aprovecha la característica de la sintaxis de Rust que permite escribir una cadena de métodos en líneas distintas, tal y como describimos en Sección 11.6.

En general, cuando una operación puede fallar durante una ejecución normal del programa es preferible tratar el error mediante Result<T, E>. Los métodos unwrap() y expect() deberían reservarse para situaciones en las que el programador considera que el error no debería producirse. Si esa suposición resulta ser incorrecta, expect() suele ser preferible, ya que el mensaje proporcionado por el programador facilita comprender qué suposición ha fallado y localizar el origen del problema.

17.6 Resumen

En este capítulo hemos estudiado el mecanismo que utiliza Rust para gestionar los errores de forma segura y explícita.

Las ideas principales son las siguientes:

  • Los errores recuperables se representan mediante el tipo Result<T, E>.
  • Un valor Result puede contener un resultado correcto (Ok) o un error (Err), por lo que ambos casos deben tratarse explícitamente.
  • La expresión match permite procesar cada una de estas dos posibilidades de forma clara y segura.
  • El operador ? simplifica la propagación de errores en las funciones que también devuelven un Result.
  • Los métodos unwrap() y expect() extraen el valor contenido en un Result, pero provocan un panic! si el resultado es un error, por lo que solo deben utilizarse cuando esa situación no pueda producirse o durante el desarrollo.
  • panic! representa un error irrecuperable y provoca la finalización del programa.

El tratamiento explícito de los errores es una de las características que distinguen a Rust. Gracias a Result y al operador ?, es posible escribir programas robustos sin recurrir a excepciones y haciendo visibles en el propio código las situaciones que pueden fallar.