Rust

Rust

Tutorial Rust: Manejo de errores Panic

Aprende a usar panic en Rust para gestionar errores irrecuperables, unwinding, aserciones y cuándo aplicar panic de forma segura y eficiente.

Aprende Rust y certifícate

panic! y unwinding

En Rust, cuando nos enfrentamos a situaciones irrecuperables donde el programa no puede continuar de manera segura, necesitamos un mecanismo para detener la ejecución de forma controlada. La macro panic! es la herramienta que Rust proporciona para estos casos.

Un panic ocurre cuando el programa encuentra una condición que no puede manejar y debe terminar inmediatamente. A diferencia de otros lenguajes donde los errores pueden pasar desapercibidos, Rust hace que estos problemas sean explícitos y visibles.

¿Qué es un panic?

La macro panic! detiene la ejecución del programa cuando se encuentra con una situación irrecuperable. Veamos un ejemplo básico:

fn main() {
    println!("Antes del panic");
    panic!("¡Algo salió muy mal!");
    println!("Este código nunca se ejecutará");
}

Al ejecutar este programa, verás una salida similar a:

Antes del panic
thread 'main' panicked at '¡Algo salió muy mal!', src/main.rs:3:5

El mensaje muestra:

  • El hilo donde ocurrió el panic (generalmente 'main')
  • El mensaje que proporcionamos
  • La ubicación exacta en el código (archivo y línea)

Panics implícitos

No siempre necesitamos llamar a panic! directamente. Rust genera panics automáticamente en varias situaciones:

  • Acceso fuera de límites en arrays:
fn main() {
    let numeros = [1, 2, 3];
    let valor = numeros[99]; // ¡PANIC! Índice fuera de límites
}
  • División por cero en enteros:
fn main() {
    let resultado = 10 / 0; // ¡PANIC! División por cero
}
  • Desreferenciación de punteros nulos (aunque esto es raro en Rust seguro):
fn main() {
    let referencia: Option<&str> = None;
    println!("{}", referencia.unwrap()); // ¡PANIC! Llamada a unwrap() en None
}

El proceso de unwinding

Cuando ocurre un panic, Rust inicia un proceso llamado unwinding (desenrollado). Este proceso:

  1. Recorre la pila de llamadas en sentido inverso
  2. Ejecuta los destructores de todas las variables en cada marco de pila
  3. Libera los recursos correctamente (memoria, archivos, etc.)
  4. Termina el hilo actual (o el programa completo si es el hilo principal)

Este comportamiento es crucial porque garantiza que los recursos se liberen adecuadamente, incluso cuando el programa termina abruptamente.

struct RecursoImportante {
    id: u32,
}

impl RecursoImportante {
    fn new(id: u32) -> Self {
        println!("Recurso {} creado", id);
        RecursoImportante { id }
    }
}

impl Drop for RecursoImportante {
    fn drop(&mut self) {
        println!("Limpiando recurso {}", self.id);
    }
}

fn main() {
    let _recurso1 = RecursoImportante::new(1);
    
    {
        let _recurso2 = RecursoImportante::new(2);
        panic!("¡Oh no!");
        // _recurso2 se limpiará aquí debido al unwinding
    }
    
    // Este código nunca se ejecuta
    println!("Fin del programa");
    // _recurso1 se limpiará al final
}

La salida mostrará:

Recurso 1 creado
Recurso 2 creado
thread 'main' panicked at '¡Oh no!', src/main.rs:22:9
Limpiando recurso 2
Limpiando recurso 1

Observa cómo los recursos se limpian en orden inverso a su creación, incluso durante un panic.

Configurando el comportamiento: unwinding vs abort

Por defecto, Rust utiliza unwinding durante un panic, pero este proceso tiene un costo en tamaño de binario y rendimiento. En sistemas embebidos o aplicaciones donde el tamaño es crítico, podemos configurar Rust para que simplemente aborte el programa sin unwinding.

Esto se configura en el archivo Cargo.toml:

[profile.release]
panic = "abort"

Las opciones disponibles son:

  • panic = "unwind": Comportamiento predeterminado, realiza unwinding
  • panic = "abort": Termina inmediatamente el programa sin unwinding

Capturando un panic

En ocasiones, podemos querer contener un panic para evitar que termine todo el programa. La función std::panic::catch_unwind permite esto:

use std::panic;

fn operacion_riesgosa() {
    panic!("Operación fallida");
}

fn main() {
    println!("Iniciando programa");
    
    let resultado = panic::catch_unwind(|| {
        println!("Dentro de la operación riesgosa");
        operacion_riesgosa();
        println!("Esto nunca se imprimirá");
    });
    
    match resultado {
        Ok(_) => println!("La operación fue exitosa"),
        Err(_) => println!("La operación causó un panic, pero lo capturamos"),
    }
    
    println!("El programa continúa ejecutándose");
}

Es importante destacar que catch_unwind no captura todos los panics. Si Rust está configurado para abortar en panic o si el panic ocurre en código externo que no soporta unwinding, catch_unwind no funcionará.

Backtrace de panic

Para obtener información detallada sobre dónde ocurrió un panic, podemos habilitar la variable de entorno RUST_BACKTRACE:

RUST_BACKTRACE=1 cargo run

Esto mostrará la pila completa de llamadas en el momento del panic, lo que facilita enormemente la depuración:

thread 'main' panicked at '¡Algo salió muy mal!', src/main.rs:3:5
stack backtrace:
   0: rust_begin_unwind
   1: core::panicking::panic_fmt
   2: main::main
   3: core::ops::function::FnOnce::call_once
   ...

El backtrace es una herramienta de depuración invaluable que muestra exactamente qué funciones estaban activas cuando ocurrió el panic.

Personalizando el hook de panic

Si necesitamos personalizar cómo se manejan los panics globalmente, podemos usar std::panic::set_hook:

use std::panic;

fn main() {
    panic::set_hook(Box::new(|info| {
        if let Some(ubicacion) = info.location() {
            println!("Panic en {} línea {}", ubicacion.file(), ubicacion.line());
        } else {
            println!("Panic en ubicación desconocida");
        }
        
        if let Some(mensaje) = info.payload().downcast_ref::<&str>() {
            println!("Mensaje: {}", mensaje);
        }
        
        // Podríamos registrar el error en un archivo o enviar una notificación
    }));
    
    panic!("Error personalizado");
}

Esta funcionalidad es especialmente útil en aplicaciones grandes donde queremos registrar los panics de manera consistente o enviar notificaciones cuando ocurren.

Aserciones

Las aserciones son herramientas de verificación que nos permiten comprobar condiciones que consideramos verdaderas durante la ejecución de nuestro programa. En Rust, las aserciones son implementadas mediante macros que provocan un panic cuando la condición evaluada resulta falsa.

Rust proporciona principalmente dos macros de aserción: assert! y debug_assert!, cada una con sus variantes para mensajes personalizados.

La macro assert!

La macro assert! es la forma más básica de aserción en Rust. Toma una expresión booleana y causa un panic si dicha expresión evalúa a false:

fn main() {
    let x = 5;
    assert!(x > 0);  // No ocurre nada porque la condición es verdadera
    
    let y = -5;
    assert!(y > 0);  // Causará un panic porque y no es mayor que 0
}

Al ejecutar este código, verás un mensaje de error similar a:

thread 'main' panicked at 'assertion failed: y > 0', src/main.rs:6:5

Aserciones con mensajes personalizados

Para hacer que los mensajes de error sean más descriptivos, podemos usar la variante assert! con un mensaje personalizado:

fn main() {
    let edad = 15;
    assert!(
        edad >= 18,
        "Acceso denegado: se requiere ser mayor de edad (edad actual: {})",
        edad
    );
}

El mensaje de error resultante será:

thread 'main' panicked at 'Acceso denegado: se requiere ser mayor de edad (edad actual: 15)', src/main.rs:4:5

Este formato sigue la misma sintaxis que la macro format!, lo que nos permite crear mensajes informativos que incluyan valores relevantes.

La macro assert_eq! y assert_ne!

Rust también proporciona macros especializadas para comparar valores:

  • assert_eq! - Verifica que dos expresiones sean iguales
  • assert_ne! - Verifica que dos expresiones sean diferentes
fn main() {
    let resultado = suma(2, 3);
    assert_eq!(resultado, 5, "La suma de 2 + 3 debería ser 5");
    
    let valor_actual = "hola";
    let valor_esperado = "mundo";
    assert_ne!(valor_actual, valor_esperado);
}

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

La ventaja de usar estas macros en lugar de assert!(a == b) es que muestran los valores reales en el mensaje de error:

thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `4`,
 right: `5`: La suma de 2 + 3 debería ser 5', src/main.rs:3:5

Esto facilita enormemente la depuración al mostrar exactamente qué valores causaron el fallo.

Aserciones solo en modo debug

La macro debug_assert! (y sus variantes debug_assert_eq! y debug_assert_ne!) funcionan exactamente igual que sus contrapartes regulares, con una diferencia crucial: solo se evalúan en compilaciones de depuración.

fn calcular_raiz_cuadrada(numero: f64) -> f64 {
    debug_assert!(
        numero >= 0.0,
        "Se intentó calcular la raíz cuadrada de un número negativo: {}",
        numero
    );
    
    // En una aplicación real, manejaríamos esto de otra forma
    numero.sqrt()
}

fn main() {
    let resultado = calcular_raiz_cuadrada(-1.0);
    println!("Resultado: {}", resultado);  // NaN en modo release
}

Cuando compilamos en modo debug (el predeterminado con cargo build o cargo run), la aserción se activará y causará un panic. Sin embargo, cuando compilamos en modo release (cargo build --release), la aserción se elimina completamente del código, sin ningún impacto en el rendimiento.

Casos de uso para aserciones

Las aserciones son especialmente útiles en los siguientes escenarios:

  • Verificación de invariantes internos que siempre deberían ser verdaderos:
fn dividir_array(valores: &[i32], divisor: i32) -> Vec<i32> {
    assert!(divisor != 0, "El divisor no puede ser cero");
    
    valores.iter().map(|&x| x / divisor).collect()
}
  • Validación de precondiciones en funciones:
fn procesar_buffer(buffer: &[u8], inicio: usize, longitud: usize) {
    assert!(inicio + longitud <= buffer.len(), 
            "Rango inválido: inicio={}, longitud={}, tamaño del buffer={}",
            inicio, longitud, buffer.len());
    
    // Procesar el buffer...
}
  • Verificación de resultados en pruebas unitarias:
#[test]
fn test_ordenamiento() {
    let mut numeros = vec![3, 1, 4, 1, 5, 9];
    numeros.sort();
    assert_eq!(numeros, vec![1, 1, 3, 4, 5, 9]);
}
  • Comprobaciones de rendimiento que solo se ejecutan durante el desarrollo:
fn algoritmo_optimizado(datos: &[i32]) -> i32 {
    let tiempo_inicio = std::time::Instant::now();
    let resultado = calcular_resultado(datos);
    let duracion = tiempo_inicio.elapsed();
    
    debug_assert!(
        duracion.as_millis() < 100,
        "El algoritmo tardó demasiado: {:?}",
        duracion
    );
    
    resultado
}

fn calcular_resultado(datos: &[i32]) -> i32 {
    // Implementación del algoritmo...
    datos.iter().sum()
}

Diferencias entre assert! y expect/unwrap

Es importante distinguir entre las aserciones y los métodos unwrap() o expect() que se utilizan con tipos como Option y Result:

// Aserción - verifica una condición booleana
assert!(valor > 0);

// unwrap/expect - extraen un valor de Option/Result o causan panic
let valor = option_valor.expect("Mensaje de error si es None");

La diferencia principal es que las aserciones están diseñadas para verificar condiciones que nunca deberían fallar en un programa correcto, mientras que unwrap/expect se utilizan para manejar casos donde un valor podría estar ausente o ser un error.

Aserciones personalizadas

Podemos crear nuestras propias funciones de aserción para casos específicos:

fn assert_ordenado(slice: &[i32]) {
    for i in 1..slice.len() {
        assert!(
            slice[i-1] <= slice[i],
            "Array no ordenado en posiciones {} y {}: {} > {}",
            i-1, i, slice[i-1], slice[i]
        );
    }
}

fn main() {
    let numeros = vec![1, 2, 5, 4, 6];
    assert_ordenado(&numeros);  // Causará panic en 5 > 4
}

Estas funciones personalizadas pueden mejorar significativamente la legibilidad del código y proporcionar mensajes de error más específicos para nuestro dominio.

Consideraciones de rendimiento

Las aserciones regulares (assert!) tienen un pequeño impacto en el rendimiento incluso en modo release, ya que la condición debe evaluarse. Si el rendimiento es crítico, considera usar debug_assert! para verificaciones que solo son necesarias durante el desarrollo.

fn operacion_critica(valores: &[f64]) -> f64 {
    // Esta aserción siempre se evalúa, incluso en release
    assert!(!valores.is_empty(), "El array no puede estar vacío");
    
    // Esta aserción solo se evalúa en modo debug
    debug_assert!(
        valores.len() < 10000,
        "Array demasiado grande, puede causar problemas de rendimiento"
    );
    
    // Resto del código...
    valores.iter().sum()
}

En general, las aserciones son herramientas poderosas para detectar errores temprano en el ciclo de desarrollo, mejorando la robustez y mantenibilidad del código Rust.

Cuándo usar panic

Decidir cuándo utilizar el mecanismo de panic en Rust es una cuestión fundamental para escribir código robusto y mantenible. El panic no es simplemente una forma de manejar errores, sino una herramienta específica para situaciones particulares dentro del ecosistema de Rust.

El principio básico que debemos recordar es que un panic debe reservarse para situaciones irrecuperables donde continuar la ejecución podría ser más perjudicial que detener el programa. Veamos las situaciones donde el uso de panic es apropiado y dónde debemos evitarlo.

Situaciones apropiadas para usar panic

  • Violaciones de invariantes fundamentales que nunca deberían ocurrir en un programa correcto:
fn raiz_cuadrada_positiva(numero: f64) -> f64 {
    if numero < 0.0 {
        panic!("Se intentó calcular la raíz cuadrada de un número negativo");
    }
    numero.sqrt()
}
  • Condiciones imposibles según la lógica del programa:
enum Semaforo {
    Rojo,
    Amarillo,
    Verde,
}

fn tiempo_espera(estado: Semaforo) -> u32 {
    match estado {
        Semaforo::Rojo => 30,
        Semaforo::Amarillo => 5,
        Semaforo::Verde => 0,
        // No necesitamos un caso por defecto porque hemos cubierto todas las variantes
    }
}
  • Inicialización fallida de componentes críticos del sistema:
fn iniciar_sistema() {
    let config = match cargar_configuracion() {
        Ok(cfg) => cfg,
        Err(e) => panic!("Error fatal al cargar la configuración: {}", e),
    };
    
    // Continuar con la inicialización...
}
  • Errores de programación que indican bugs en el código:
fn procesar_datos(datos: &[i32]) -> i32 {
    if datos.is_empty() {
        panic!("La función procesar_datos recibió un array vacío");
    }
    
    // Procesar los datos...
    datos.iter().sum()
}
  • Pruebas unitarias donde queremos verificar que ciertas condiciones se cumplan:
#[test]
fn test_suma() {
    let resultado = 2 + 2;
    assert_eq!(resultado, 4, "La suma básica no funciona correctamente");
}
  • Prototipos y código en desarrollo donde aún no hemos implementado un manejo de errores adecuado:
fn caracteristica_en_desarrollo(entrada: &str) -> String {
    // Implementación temporal
    if entrada.len() > 10 {
        panic!("Todavía no soportamos entradas tan largas");
    }
    
    entrada.to_uppercase()
}

Situaciones donde evitar panic

  • Errores recuperables que pueden manejarse de forma más elegante:
// En lugar de esto:
fn leer_archivo(ruta: &str) -> String {
    let contenido = std::fs::read_to_string(ruta)
        .unwrap_or_else(|_| panic!("No se pudo leer el archivo: {}", ruta));
    contenido
}

// Prefiere esto (aunque veremos Result más adelante):
fn leer_archivo(ruta: &str) -> Result<String, std::io::Error> {
    std::fs::read_to_string(ruta)
}
  • Validación de entrada de usuario donde los errores son esperables:
fn procesar_entrada_usuario(entrada: &str) -> Result<i32, String> {
    match entrada.parse::<i32>() {
        Ok(numero) => Ok(numero),
        Err(_) => Err(format!("'{}' no es un número válido", entrada)),
    }
}
  • Operaciones que pueden fallar por razones externas al programa:
fn conectar_a_servidor(url: &str) -> Result<Conexion, ErrorConexion> {
    // Implementación que maneja errores de red sin panic
    // ...
}
  • Bibliotecas públicas que serán utilizadas por otros desarrolladores:
pub fn calcular_estadisticas(datos: &[f64]) -> Option<Estadisticas> {
    if datos.is_empty() {
        return None; // Mejor que panic para una biblioteca
    }
    
    // Calcular estadísticas...
    Some(Estadisticas { /* ... */ })
}

Directrices prácticas

Para decidir si usar panic o un mecanismo de manejo de errores más sofisticado, considera estas preguntas:

  • ¿Es posible recuperarse de este error en tiempo de ejecución?
  • ¿El error indica un bug en el programa?
  • ¿Quién consumirá este código (tú, otros desarrolladores, usuarios finales)?
  • ¿Cuál es el impacto de detener el programa en este punto?

Patrones comunes en código Rust

  • Fail fast durante el desarrollo: Usar unwrap() o expect() durante el prototipado para identificar problemas rápidamente:
// Durante el desarrollo
let config = cargar_configuracion().expect("Archivo de configuración corrupto");

// En producción, esto se reemplazaría con manejo de errores adecuado
  • Validación temprana: Verificar precondiciones al inicio de las funciones:
fn procesar_transaccion(monto: f64, cuenta: &Cuenta) {
    if monto <= 0.0 {
        panic!("El monto de la transacción debe ser positivo");
    }
    
    if cuenta.esta_bloqueada() {
        panic!("No se pueden procesar transacciones en cuentas bloqueadas");
    }
    
    // Proceder con la transacción...
}
  • Conversión controlada de errores recuperables a irrecuperables:
fn debe_funcionar_o_es_bug(operacion: impl FnOnce() -> Result<T, E>) -> T {
    match operacion() {
        Ok(valor) => valor,
        Err(e) => {
            panic!("Error inesperado que indica un bug en el programa: {:?}", e);
        }
    }
}

Alternativas a panic

Aunque veremos estos mecanismos en detalle más adelante, es importante mencionar que Rust proporciona alternativas más sofisticadas para el manejo de errores recuperables:

  • Result<T, E>: Para operaciones que pueden fallar de manera esperada
  • Option: Para valores que pueden estar ausentes
  • Propagación de errores con el operador ?

Estos mecanismos permiten manejar errores de forma más elegante sin detener abruptamente la ejecución del programa.

Consideraciones de diseño

Al diseñar APIs en Rust, considera estas directrices:

  • Funciones internas pueden usar panic para condiciones que nunca deberían ocurrir:
fn calcular_promedio_interno(valores: &[f64]) -> f64 {
    // Esta función es llamada solo después de verificar que valores no está vacío
    assert!(!valores.is_empty(), "Array vacío en calcular_promedio_interno");
    valores.iter().sum::<f64>() / valores.len() as f64
}
  • APIs públicas deberían preferir Result/Option para dar control al llamador:
pub fn calcular_promedio(valores: &[f64]) -> Option<f64> {
    if valores.is_empty() {
        return None;
    }
    
    Some(calcular_promedio_interno(valores))
}
  • Documentar claramente las condiciones que pueden causar panic:
/// Calcula la raíz cuadrada de un número.
///
/// # Panics
///
/// Esta función causa panic si el número es negativo.
pub fn raiz_cuadrada(x: f64) -> f64 {
    if x < 0.0 {
        panic!("raiz_cuadrada recibió un valor negativo: {}", x);
    }
    x.sqrt()
}

Conclusión práctica

El mecanismo de panic en Rust es una herramienta poderosa cuando se usa correctamente. La regla general es:

  • Usa panic para errores de programación y situaciones verdaderamente irrecuperables
  • Usa Result/Option para errores operacionales y situaciones donde la recuperación es posible

Siguiendo estas directrices, crearás código Rust que sea tanto robusto como expresivo, aprovechando al máximo el sistema de tipos y el manejo de errores del lenguaje.

Aprende Rust online

Otras lecciones de Rust

Accede a todas las lecciones de Rust y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a Rust y certifícate

Ejercicios de programación de Rust

Evalúa tus conocimientos de esta lección Manejo de errores Panic con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.

En esta lección

Objetivos de aprendizaje de esta lección

  • Comprender qué es un panic y cómo Rust maneja errores irrecuperables mediante unwinding.
  • Aprender a usar las macros de aserción como assert!, assert_eq!, y debug_assert! para validar condiciones en tiempo de ejecución.
  • Conocer las situaciones adecuadas para utilizar panic y cuándo es preferible evitarlo en favor de mecanismos recuperables.
  • Entender cómo capturar panics con catch_unwind y personalizar el comportamiento global con set_hook.
  • Diferenciar entre errores recuperables y no recuperables y aplicar buenas prácticas en el diseño de APIs y manejo de errores.