To Panic or Not to Panic

Beginner

This tutorial is from open-source community. Access the source code

Introducción

Bienvenido a To Panic or Not to Panic. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, la decisión de llamar a panic! o devolver un Result depende de la recuperabilidad de la situación de error y las opciones disponibles para el código llamador.

To panic or Not to panic

Entonces, ¿cómo decides cuándo deberías llamar a panic! y cuándo deberías devolver Result? Cuando el código se bloquea, no hay forma de recuperarse. Podrías llamar a panic! para cualquier situación de error, ya sea que haya una forma posible de recuperarse o no, pero entonces estás tomando la decisión de que una situación es irreparable en nombre del código llamador. Cuando eliges devolver un valor Result, le das opciones al código llamador. El código llamador podría elegir intentar recuperarse de una manera adecuada para su situación, o podría decidir que un valor Err en este caso es irreparable, por lo que puede llamar a panic! y convertir tu error recuperable en uno irreparable. Por lo tanto, devolver Result es una buena opción por defecto cuando estás definiendo una función que podría fallar.

En situaciones como ejemplos, código de prototipo y pruebas, es más adecuado escribir código que se bloquee en lugar de devolver un Result. Vamos a explorar por qué, luego discutiremos situaciones en las que el compilador no puede saber que la falla es imposible, pero tú como humano sí. El capítulo concluirá con algunas pautas generales sobre cómo decidir si se debe bloquear en el código de la biblioteca.

Ejemplos, código de prototipo y pruebas

Cuando estás escribiendo un ejemplo para ilustrar algún concepto, incluir también código de manejo de errores robusto puede hacer que el ejemplo sea menos claro. En los ejemplos, se entiende que una llamada a un método como unwrap que podría causar un bloqueo es un marcador temporal para la forma en que quieres que tu aplicación maneje los errores, lo cual puede variar según lo que haga el resto de tu código.

Del mismo modo, los métodos unwrap y expect son muy útiles durante el prototipado, antes de que estés listo para decidir cómo manejar los errores. Dejan marcas claras en tu código para cuando estés listo para hacer que tu programa sea más robusto.

Si una llamada a un método falla en una prueba, querrás que toda la prueba falle, incluso si ese método no es la funcionalidad que se está probando. Debido a que panic! es la forma en que una prueba se marca como fallida, llamar a unwrap o expect es exactamente lo que debería suceder.

Casos en los que tienes más información que el compilador

También sería adecuado llamar a unwrap o expect cuando tienes alguna otra lógica que asegure que el Result tendrá un valor Ok, pero la lógica no es algo que el compilador entienda. Todavía tendrás un valor Result que debes manejar: cualquier operación que estés llamando todavía tiene la posibilidad de fallar en general, aunque sea lógicamente imposible en tu situación particular. Si puedes asegurar mediante la inspección manual del código que nunca tendrás una variante Err, es perfectamente aceptable llamar a unwrap, y aún mejor documentar la razón por la que crees que nunca tendrás una variante Err en el texto de expect. Aquí hay un ejemplo:

use std::net::IpAddr;

let home: IpAddr = "127.0.0.1"
 .parse()
 .expect("La dirección IP codificada en el código debe ser válida");

Estamos creando una instancia de IpAddr al analizar una cadena codificada en el código. Podemos ver que 127.0.0.1 es una dirección IP válida, por lo que es aceptable usar expect aquí. Sin embargo, tener una cadena codificada y válida no cambia el tipo de retorno del método parse: todavía obtenemos un valor Result, y el compilador todavía nos hará manejar el Result como si la variante Err fuera una posibilidad porque el compilador no es lo suficientemente inteligente para ver que esta cadena siempre es una dirección IP válida. Si la cadena de la dirección IP provenía de un usuario en lugar de estar codificada en el programa y, por lo tanto, tuvo una posibilidad de fallar, definitivamente querríamos manejar el Result de una manera más robusta en lugar de eso. Mencionar la suposición de que esta dirección IP está codificada en el código nos hará cambiar expect a un código de manejo de errores mejor si, en el futuro, necesitamos obtener la dirección IP de alguna otra fuente en lugar de eso.

Pautas para el manejo de errores

Es recomendable que tu código se bloquee cuando es posible que termine en un estado incorrecto. En este contexto, un estado incorrecto es cuando se ha violado alguna suposición, garantía, contrato o invariante, como cuando se pasan valores inválidos, valores contradictorios o valores faltantes a tu código, más uno o más de los siguientes:

  • El estado incorrecto es algo inesperado, en contraste con algo que probablemente sucederá ocasionalmente, como un usuario ingresando datos en un formato incorrecto.
  • Tu código después de este punto debe confiar en no estar en este estado incorrecto, en lugar de comprobar el problema en cada paso.
  • No hay una buena forma de codificar esta información en los tipos que utilizas. Veremos un ejemplo de lo que queremos decir en "Codificar estados y comportamiento como tipos".

Si alguien llama a tu código y pasa valores que no tienen sentido, es mejor devolver un error si es posible para que el usuario de la biblioteca pueda decidir qué hacer en ese caso. Sin embargo, en casos donde continuar podría ser inseguro o dañino, la mejor opción podría ser llamar a panic! y alertar a la persona que utiliza tu biblioteca sobre el error en su código para que lo pueda corregir durante el desarrollo. Del mismo modo, panic! a menudo es apropiado si estás llamando a código externo que está fuera de tu control y devuelve un estado inválido que no puedes corregir.

Sin embargo, cuando se espera un error, es más apropiado devolver un Result que hacer una llamada a panic!. Ejemplos incluyen un analizador que recibe datos con formato incorrecto o una solicitud HTTP que devuelve un estado que indica que has alcanzado un límite de tasa. En estos casos, devolver un Result indica que el error es una posibilidad esperada que el código llamador debe decidir cómo manejar.

Cuando tu código realiza una operación que podría poner a un usuario en riesgo si se llama con valores inválidos, tu código debe verificar primero que los valores son válidos y bloquear si los valores no son válidos. Esto es principalmente por razones de seguridad: intentar operar con datos inválidos puede exponer tu código a vulnerabilidades. Esta es la principal razón por la que la biblioteca estándar llamará a panic! si intentas acceder a memoria fuera de los límites: intentar acceder a memoria que no pertenece a la estructura de datos actual es un problema de seguridad común. Las funciones a menudo tienen contratos: su comportamiento solo está garantizado si las entradas cumplen con determinados requisitos. Bloquear cuando se viola el contrato tiene sentido porque una violación del contrato siempre indica un error en el código del llamador, y no es un tipo de error que quieres que el código llamador tenga que manejar explícitamente. De hecho, no hay forma razonable para que el código llamador se recupere; los programadores llamantes deben corregir el código. Los contratos de una función, especialmente cuando una violación causará un bloqueo, deben explicarse en la documentación de la API de la función.

Sin embargo, tener muchas comprobaciones de errores en todas tus funciones sería verboso y molesto. Por suerte, puedes utilizar el sistema de tipos de Rust (y, por lo tanto, la comprobación de tipos realizada por el compilador) para hacer muchas de las comprobaciones por ti. Si tu función tiene un tipo particular como parámetro, puedes continuar con la lógica de tu código sabiendo que el compilador ya ha asegurado que tienes un valor válido. Por ejemplo, si tienes un tipo en lugar de una Option, tu programa espera tener algo en lugar de nada. Entonces, tu código no tiene que manejar dos casos para las variantes Some y None: solo tendrá un caso para tener definitivamente un valor. El código que intenta pasar nada a tu función ni siquiera se compilará, por lo que tu función no tiene que comprobar ese caso en tiempo de ejecución. Otro ejemplo es utilizar un tipo de entero sin signo como u32, que garantiza que el parámetro nunca es negativo.

Crear tipos personalizados para validación

Vamos a llevar la idea de utilizar el sistema de tipos de Rust para asegurarnos de tener un valor válido un paso más allá y ver cómo crear un tipo personalizado para validación. Recuerda el juego de adivinanzas del Capítulo 2 en el que nuestro código preguntaba al usuario que adivinara un número entre 1 y 100. Nunca validamos que la suposición del usuario estuviera entre esos números antes de comprobarla contra nuestro número secreto; solo validamos que la suposición era positiva. En este caso, las consecuencias no eran muy graves: nuestra salida de "Demasiado alto" o "Demasiado bajo" todavía sería correcta. Pero sería una mejora útil para guiar al usuario hacia suposiciones válidas y tener un comportamiento diferente cuando el usuario adivina un número fuera de rango en comparación con cuando el usuario escribe, por ejemplo, letras en lugar de números.

Una forma de hacer esto sería analizar la suposición como un i32 en lugar de solo un u32 para permitir números potencialmente negativos, y luego agregar una comprobación para que el número esté en el rango, como sigue:

Nombre de archivo: src/main.rs

loop {
    --snip--

    let guess: i32 = match guess.trim().parse() {
        Ok(num) => num,
        Err(_) => continue,
    };

    if guess < 1 || guess > 100 {
        println!("El número secreto estará entre 1 y 100.");
        continue;
    }

    match guess.cmp(&secret_number) {
        --snip--
}

La expresión if comprueba si nuestro valor está fuera de rango, le informa al usuario sobre el problema y llama a continue para iniciar la siguiente iteración del bucle y pedir otra suposición. Después de la expresión if, podemos continuar con las comparaciones entre guess y el número secreto sabiendo que guess está entre 1 y 100.

Sin embargo, esta no es una solución ideal: si fuera absolutamente crítico que el programa solo operara con valores entre 1 y 100, y tuviera muchas funciones con este requisito, tener una comprobación como esta en cada función sería tediosa (y podría afectar el rendimiento).

En lugar de eso, podemos crear un nuevo tipo y poner las validaciones en una función para crear una instancia del tipo en lugar de repetir las validaciones en todos lados. De esa manera, es seguro para las funciones utilizar el nuevo tipo en sus firmas y utilizar con confianza los valores que reciben. La Lista 9-13 muestra una forma de definir un tipo Guess que solo creará una instancia de Guess si la función new recibe un valor entre 1 y 100.

Nombre de archivo: src/lib.rs

1 pub struct Guess {
    value: i32,
}

impl Guess {
  2 pub fn new(value: i32) -> Guess {
      3 if value < 1 || value > 100 {
          4 panic!(
                "El valor de la suposición debe estar entre 1 y 100, se obtuvo {}.",
                value
            );
        }

      5 Guess { value }
    }

  6 pub fn value(&self) -> i32 {
        self.value
    }
}

Lista 9-13: Un tipo Guess que solo continuará con valores entre 1 y 100

Primero definimos un struct llamado Guess que tiene un campo llamado value que almacena un i32 [1]. Aquí es donde se almacenará el número.

Luego implementamos una función asociada llamada new en Guess que crea instancias de valores Guess [2]. La función new está definida para tener un parámetro llamado value de tipo i32 y para devolver un Guess. El código en el cuerpo de la función new prueba value para asegurarse de que esté entre 1 y 100 [3]. Si value no pasa esta prueba, hacemos una llamada a panic! [4], lo que alertará al programador que está escribiendo el código llamador de que tiene un error que necesita corregir, porque crear un Guess con un value fuera de este rango violaría el contrato en el que se basa Guess::new. Las condiciones en las que Guess::new podría causar un bloqueo deben discutirse en su documentación de API pública; cubriremos las convenciones de documentación que indican la posibilidad de un panic! en la documentación de API que crees en el Capítulo 14. Si value pasa la prueba, creamos un nuevo Guess con su campo value establecido en el parámetro value y devolvemos el Guess [5].

A continuación, implementamos un método llamado value que presta self, no tiene ningún otro parámetro y devuelve un i32 [6]. Este tipo de método a veces se llama getter porque su propósito es obtener algunos datos de sus campos y devolverlos. Este método público es necesario porque el campo value del struct Guess es privado. Es importante que el campo value sea privado para que el código que utiliza el struct Guess no se permita establecer value directamente: el código fuera del módulo debe utilizar la función Guess::new para crear una instancia de Guess, lo que garantiza que no hay forma de que un Guess tenga un value que no haya sido comprobado por las condiciones en la función Guess::new.

Una función que tiene un parámetro o devuelve solo números entre 1 y 100 podría entonces declarar en su firma que toma o devuelve un Guess en lugar de un i32 y no necesitaría hacer ninguna comprobación adicional en su cuerpo.

Resumen

¡Felicidades! Has completado el laboratorio "To Panic or Not to Panic". Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.