Manejo de Errores, Genéricos, Traits y Pruebas en Rust

Este documento es una guía sobre el manejo de errores, genéricos, traits y pruebas en el lenguaje de programación Rust.

Manejo de Errores

Rust distingue dos tipos de errores:

  1. Recuperables: Errores que, tras una intervención, permiten continuar la ejecución del programa (ej. archivo no encontrado).
  2. No recuperables: Errores que deben forzar la terminación del programa (ej. acceso fuera de límites de un array).

Para errores recuperables, Rust utiliza el tipo Result<T, E>. Para errores no recuperables, se emplea la macro panic!.

Errores No Recuperables y panic!

La ejecución de panic! puede ocurrir por dos motivos:

  1. Invocación explícita de la macro panic!.
  2. Ocurrencia de un error grave en el programa (ej. acceso inválido a un array).

Por defecto, Rust muestra un mensaje de error, realiza el desenrollado (unwinding) de la pila, libera recursos y termina el programa. La variable de entorno RUST_BACKTRACE puede ser utilizada para obtener información detallada de la pila de llamadas, facilitando la depuración.

Desenvolvimiento vs. Abortar:

El comportamiento por defecto ante un panic es el desenrollado, donde Rust retrocede en la pila de llamadas liberando memoria y recursos. Una alternativa es la opción abort, que termina el programa inmediatamente sin liberar recursos; el sistema operativo se encarga de la limpieza. Para habilitar esta opción, se añade panic = 'abort' en la sección [profile.release] del archivo Cargo.toml.

Ejemplo de llamada explícita a panic!:


fn main() {
    panic!("¡Colisión y quema!");
}

Al ejecutar este código, se obtendrá una salida similar a:


thread 'main' panicked at '¡Colisión y quema!', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Rastreo de Pila (Backtrace)

A diferencia de C, donde el aceso fuera de límites de un array es un comportamiento indefinido (potencialmente resultando en desbordamiento de búfer), en Rust provoca un error que detiene la ejecución.

Para obtener el rastreo de pila, se utiliza la varible de entorno RUST_BACKTRACE:

  • RUST_BACKTRACE=1: Muestra información básica.
  • RUST_BACKTRACE=full: Muestra información completa.

El rastreo de pila lista las funciones llamadas. Las líneas anteriores indican las funciones que llamaron a la línea actual, y las líneas posteriores indican las funciones llamadas por la línea actual.

Errores Recuperables y Result

El tipo Result<T, E>

Result<T, E> es un enum definido como:


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

Ok(T) envuelve un valor de tipo T en caso de éxito, y Err(E) envuelve un valor de tipo E en caso de error.

Ejemplo abriendo un archivo:


use std::fs::File;

fn main() {
    let result_archivo = File::open("saludo.txt");

    let archivo = match result_archivo {
        Ok(f) => f,
        Err(error) => panic!("Problema al abrir el archivo: {:?}", error),
    };
}

  • Si la apertura es exitosa, Ok contendrá el descriptor del archivo.
  • Si falla, Err contendrá la información del error del sistema operativo.

Manejo Detallado de Errores

Para diferenciar entre distintos tipos de errores, se pueden anidar sentencias match:


use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let result_archivo = File::open("saludo.txt");

    let archivo = match result_archivo {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("saludo.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problema al crear el archivo: {:?}", e),
            },
            other_error => {
                panic!("Problema al abrir el archivo: {:?}", other_error);
            }
        },
    };
}

ErrorKind es un enum que requiere ser importado explícitamente.

unwrap() y expect()

Estos métodos ofrecen una forma más concisa de manejar errores en Result, recurriendo a panic! si el valor es Err.


use std::fs::File;

fn main() {
    let archivo = File::open("saludo.txt").unwrap();
}

Si el archivo no existe, esto producirá un panic con el error del sistema.


use std::fs::File;

fn main() {
    let archivo = File::open("saludo.txt")
        .expect("¡Fallo al leer el archivo!");
}

expect() es similar a unwrap() pero permite un mensaje de error personalizado, siendo preferido en producción por su claridad.

Propagación de Errores y el Operador ?

Propagación de Errores

En lugar de manejar un error dentro de una función, es común propagarlo a la función que la llama. Esto permite un control más centralizado del flujo de errores.


use std::fs::File;
use std::io::{self, Read};

fn leer_nombre_usuario() -> Result<String, io::Error> {
    let result_archivo = File::open("nombre_usuario.txt");

    let mut archivo_usuario = match result_archivo {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut nombre = String::new();

    match archivo_usuario.read_to_string(&mut nombre) {
        Ok(_) => Ok(nombre),
        Err(e) => Err(e),
    }
}

El Operador ?

El operador ? simplifica la propagación de errores, actuando de manera similar a un match que retorna Err tempranamente.


use std::fs::File;
use std::io::{self, Read};

fn leer_nombre_usuario() -> Result<String, io::Error> {
    let mut archivo_usuario = File::open("nombre_usuario.txt")?;
    let mut nombre = String::new();
    archivo_usuario.read_to_string(&mut nombre)?;
    Ok(nombre)
}

La versión encadenada es aún más concisa:


use std::fs::File;
use std::io::{self, Read};

fn leer_nombre_usuario() -> Result<String, io::Error> {
    let mut nombre = String::new();
    File::open("nombre_usuario.txt")?.read_to_string(&mut nombre)?;
    Ok(nombre)
}

El operador ? también maneja la conversión de tipos de error usando el trait From.

Condiciones para Usar ?

El operador ? solo puede ser usado en funciones cuyo tipo de retorno sea compatible con el valor devuelto por la operación.

Por ejemplo, en la función main, que retorna ():


use std::fs::File;

fn main() {
    let archivo = File::open("saludo.txt")?; // Error: main no puede retornar Result
}

Para solucionar esto, la función main puede retornar un tipo Result:


use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let archivo = File::open("saludo.txt")?;
    Ok(())
}

Si main retorna Ok(()), el código de salida es 0. Si retorna Err, es un valor distinto de cero.

Genéricos, Traits y Lifetimes

Genéricos

Los genéricos permiten escribir código que opera sobre tipos abstractos, reduciendo la duplicación.

Definición de Genéricos

Se utilizan corchetes angulares <> para definir parámetros de tipo genéricos, comúnmente representados por T.

Genéricos en Funciones


fn encontrar_mayor<T: PartialOrd>(lista: &[T]) -> &T {
    let mut mayor = &lista[0];
    for item in lista {
        if item > mayor {
            mayor = item;
        }
    }
    mayor
}

Se añade la restricción PartialOrd para asegurar que los tipos puedan ser comparados.

Genéricos en Estructuras


struct Punto<T> {
    x: T,
    y: T,
}

struct PuntoDos<T, U> {
    x: T,
    y: U,
}

Punto<T> usa un solo tipo para ambos campos, mientras que PuntoDos<T, U> permite tipos diferentes.

Genéricos en Enums

Los enums Option<T> y Result<T, E> son ejemplos comunes de uso de genéricos.

Genéricos en Métodos


impl<T> Punto<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Punto<f32> {
    fn distancia_origen(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

El primer bloque implementa un método genérico para cualquier tipo T. El segundo implementa un método específico solo para Punto<f32>.

Traits

Los traits definen funcionalidades compartidas entre tipos.

Definición de Traits


pub trait Resumen {
    fn resumir(&self) -> String;
}

Implementación de Traits


pub struct Tweet {
    pub autor: String,
    pub contenido: String,
    pub longitud: u32,
}

impl Resumen for Tweet {
    fn resumir(&self) -> String {
        format!("{} - {}", self.autor, self.contenido)
    }
}

La implementación de traits está sujeta a la regla del "huérfano" (orphan rule), que limita la implementación de traits en tipos si ni el trait ni el tipo son de nuestro crate actual.

Pruebas

Las pruebas en Rust verifican que el código funcione como se espera.

Funciones de Prueba

Las funciones de prueba se marcan con el atributo #[test].


#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}

Macro assert!

Evalúa una expresión booleana. Si es true, la prueba continúa; si es false, llama a panic!.

Macros assert_eq! y assert_ne!

Comprueban la igualdad o desigualdad entre dos valores. El orden de los argumentos no importa.


pub fn sumar_dos(a: i32) -> i32 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn suma_correcta() {
        assert_eq!(sumar_dos(2), 4);
    }
}

Mensajes de Error Personalizados

Se pueden añaddir mensajes personalizados a las macros de aserción.


#[test]
fn saludo_contiene_nombre() {
    let resultado = saludar("Ana");
    assert!(
        resultado.contains("Ana"),
        "El saludo no contiene el nombre, valor: `{}`",
        resultado
    );
}

#[should_panic]

Este atributo indica que la función de prueba debe fallar (hacer panic). Si no hace panic, la prueba falla.


#[derive(Debug)]
struct Adivinanza {
    valor: i32,
}

impl Adivinanza {
    fn new(valor: i32) -> Adivinanza {
        if valor < 1 || valor > 100 {
            panic!("El valor debe estar entre 1 y 100, se recibió: {}.", valor);
        }
        Adivinanza { valor }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn valor_mayor_a_100() {
        Adivinanza::new(200);
    }
}

Pruebas con Result<T, E>

Se pueden escribir pruebas que retornen Result.


#[cfg(test)]
mod tests {
    #[test]
    fn funciona_con_result() -> Result<(), String> {
        if 2 + 2 == 4 {
            Ok(())
        } else {
            Err(String::from("2 + 2 no es 4"))
        }
    }
}

Las pruebas que retornan Result no pueden usar #[should_panic].

Controlando la Ejecución de Pruebas

Paralelo vs. Secuencial

Por defecto, las pruebas se ejecutan en paralelo. Para ejecutarlas secuencialmente, se usa cargo test -- --test-threads=1.

Mostrar Salida

Para ver la salida de las pruebas que pasan, se usa cargo test -- --show-output.

Ejecutar Pruebas por Nombre

Se pueden ejecutar pruebas específicas nombrando la función o usando patrones.

  • cargo test nombre_prueba: Ejecuta una prueba específica.
  • cargo test patron: Ejecuta pruebas cuyo nombre coincida con el patrón.

Ignorar Pruebas

El atributo #[ignore] omite una prueba.


#[test]
#[ignore]
fn prueba_ignorada() { /* ... */ }

Organización del Código de Pruebas

Pruebas Unitarias

Se organizan en módulos tests dentro de cada archivo, marcados con #[cfg(test)]. Permiten probar funciones privadas.


pub fn sumar_dos(a: i32) -> i32 {
    sumador_interno(a, 2)
}

fn sumador_interno(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn interno() {
        assert_eq!(4, sumador_interno(2, 2));
    }
}

Pruebas de Integración

Se ubican en el directorio tests y tratan el código como una librería externa, probando solo las funciones públicas.

Estructura de directorios:


├── src
│   └── lib.rs
└── tests
    ├── integration_test.rs
    └── common
        └── mod.rs

Las pruebas de integración solo aplican a crates de librería (lib crates).

Etiquetas: Rust Manejo de errores Genéricos Traits Pruebas Unitarias

Publicado el 7-30 06:52