Refactorización para mejorar la modularidad y el manejo de errores

RustRustBeginner
Practicar Ahora

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

💡 Este tutorial está traducido por IA desde la versión en inglés. Para ver la versión original, puedes hacer clic aquí

Introducción

Bienvenido a Refactoring para Mejorar la Modularidad y el Manejo de Errores. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, refactorizaremos el programa para mejorar la modularidad y el manejo de errores al separar tareas, agrupar variables de configuración, proporcionar mensajes de error significativos y consolidar el código de manejo de errores.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/ErrorHandlingandDebuggingGroup(["Error Handling and Debugging"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/DataTypesGroup -.-> rust/string_type("String Type") rust/DataTypesGroup -.-> rust/tuple_type("Tuple Type") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/ErrorHandlingandDebuggingGroup -.-> rust/panic_usage("panic! Usage") rust/AdvancedTopicsGroup -.-> rust/traits("Traits") rust/AdvancedTopicsGroup -.-> rust/operator_overloading("Traits for Operator Overloading") subgraph Lab Skills rust/variable_declarations -.-> lab-100420{{"Refactorización para mejorar la modularidad y el manejo de errores"}} rust/string_type -.-> lab-100420{{"Refactorización para mejorar la modularidad y el manejo de errores"}} rust/tuple_type -.-> lab-100420{{"Refactorización para mejorar la modularidad y el manejo de errores"}} rust/function_syntax -.-> lab-100420{{"Refactorización para mejorar la modularidad y el manejo de errores"}} rust/expressions_statements -.-> lab-100420{{"Refactorización para mejorar la modularidad y el manejo de errores"}} rust/method_syntax -.-> lab-100420{{"Refactorización para mejorar la modularidad y el manejo de errores"}} rust/panic_usage -.-> lab-100420{{"Refactorización para mejorar la modularidad y el manejo de errores"}} rust/traits -.-> lab-100420{{"Refactorización para mejorar la modularidad y el manejo de errores"}} rust/operator_overloading -.-> lab-100420{{"Refactorización para mejorar la modularidad y el manejo de errores"}} end

Refactoring para Mejorar la Modularidad y el Manejo de Errores

Para mejorar nuestro programa, solucionaremos cuatro problemas relacionados con la estructura del programa y cómo maneja errores potenciales. Primero, nuestra función main ahora realiza dos tareas: analiza los argumentos y lee archivos. A medida que nuestro programa crece, el número de tareas separadas que la función main maneja aumentará. A medida que una función adquiere responsabilidades, se vuelve más difícil de razonar, más difícil de probar y más difícil de cambiar sin romper una de sus partes. Es mejor separar la funcionalidad para que cada función sea responsable de una tarea.

Este problema también está relacionado con el segundo problema: aunque query y file_path son variables de configuración para nuestro programa, variables como contents se utilizan para realizar la lógica del programa. A medida que main se vuelva más larga, necesitaremos traer más variables al ámbito; entre más variables tengamos en el ámbito, más difícil será mantener un seguimiento del propósito de cada una. Es mejor agrupar las variables de configuración en una estructura para que su propósito quede claro.

El tercer problema es que hemos utilizado expect para imprimir un mensaje de error cuando falla la lectura del archivo, pero el mensaje de error solo imprime Should have been able to read the file. La lectura de un archivo puede fallar de varias maneras: por ejemplo, el archivo podría faltar, o es posible que no tengamos permiso para abrirlos. En este momento, independientemente de la situación, imprimiríamos el mismo mensaje de error para todo, lo que no daría ninguna información al usuario.

En cuarto lugar, usamos expect repetidamente para manejar diferentes errores, y si el usuario ejecuta nuestro programa sin especificar suficientes argumentos, obtendrá un error de índice fuera de los límites de Rust que no explica claramente el problema. Lo mejor sería que todo el código de manejo de errores estuviera en un solo lugar para que los futuros mantenedores tuvieran solo un lugar donde consultar el código si la lógica de manejo de errores necesitara cambiar. Tener todo el código de manejo de errores en un solo lugar también garantizará que estemos imprimiendo mensajes que serán significativos para nuestros usuarios finales.

Vamos a abordar estos cuatro problemas refactorizando nuestro proyecto.

Separación de Preocupaciones para Proyectos Binarios

El problema organizacional de asignar la responsabilidad de múltiples tareas a la función main es común a muchos proyectos binarios. Como resultado, la comunidad de Rust ha desarrollado pautas para dividir las preocupaciones separadas de un programa binario cuando main comienza a crecer. Este proceso tiene los siguientes pasos:

  • Divida su programa en un archivo main.rs y un archivo lib.rs y mueva la lógica de su programa a lib.rs.
  • Mientras su lógica de análisis de línea de comandos sea pequeña, puede permanecer en main.rs.
  • Cuando la lógica de análisis de línea de comandos comienza a volverse complicada, extáyala de main.rs y muevala a lib.rs.

Las responsabilidades que permanecen en la función main después de este proceso deben limitarse a lo siguiente:

  • Llamar a la lógica de análisis de línea de comandos con los valores de argumento
  • Configurar cualquier otra configuración
  • Llamar a una función run en lib.rs
  • Manejar el error si run devuelve un error

Este patrón se trata de separar preocupaciones: main.rs se encarga de ejecutar el programa y lib.rs se encarga de toda la lógica de la tarea en cuestión. Debido a que no se puede probar directamente la función main, esta estructura le permite probar toda la lógica de su programa al moverla a funciones en lib.rs. El código que queda en main.rs será lo suficientemente pequeño como para verificar su corrección leyéndolo. Vamos a rehacer nuestro programa siguiendo este proceso.

Extracción del Analizador de Argumentos

Extraeremos la funcionalidad para analizar los argumentos en una función que main llamará para prepararnos para mover la lógica de análisis de línea de comandos a src/lib.rs. La Lista 12-5 muestra el nuevo comienzo de main que llama a una nueva función parse_config, que definiremos en src/main.rs por el momento.

Nombre de archivo: src/main.rs

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, file_path) = parse_config(&args);

    --snip--
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}

Lista 12-5: Extracción de una función parse_config de main

Todavía estamos recopilando los argumentos de línea de comandos en un vector, pero en lugar de asignar el valor del argumento en el índice 1 a la variable query y el valor del argumento en el índice 2 a la variable file_path dentro de la función main, pasamos todo el vector a la función parse_config. La función parse_config luego contiene la lógica que determina a qué variable va cada argumento y devuelve los valores a main. Todavía creamos las variables query y file_path en main, pero main ya no tiene la responsabilidad de determinar cómo se corresponden los argumentos de línea de comandos y las variables.

Este rehacer puede parecer un exceso para nuestro pequeño programa, pero estamos refactorizando en pasos pequeños e incrementales. Después de hacer este cambio, ejecute el programa nuevamente para verificar que el análisis de argumentos todavía funcione. Es bueno revisar tu progreso con frecuencia, para ayudar a identificar la causa de los problemas cuando ocurran.

Agrupación de Valores de Configuración

Podemos dar otro pequeño paso para mejorar aún más la función parse_config. En este momento, estamos devolviendo una tupla, pero luego la rompemos inmediatamente en partes individuales nuevamente. Esto es una señal de que quizás aún no tenemos la abstracción adecuada.

Otro indicador de que hay margen para mejorar es la parte config de parse_config, lo que implica que los dos valores que devolvemos están relacionados y son parte de un solo valor de configuración. Actualmente, no estamos transmitiendo este significado en la estructura de los datos más allá de agrupar los dos valores en una tupla; en cambio, pondremos los dos valores en una estructura y daremos a cada campo de la estructura un nombre significativo. Hacer esto hará que sea más fácil para futuros mantenedores de este código entender cómo se relacionan los diferentes valores y cuál es su propósito.

La Lista 12-6 muestra las mejoras en la función parse_config.

Nombre de archivo: src/main.rs

fn main() {
    let args: Vec<String> = env::args().collect();

  1 let config = parse_config(&args);

    println!("Buscando {}", 2 config.query);
    println!("En el archivo {}", 3 config.file_path);

    let contents = fs::read_to_string(4 config.file_path)
     .expect("Debería haber sido posible leer el archivo");

    --snip--
}

5 struct Config {
    query: String,
    file_path: String,
}

6 fn parse_config(args: &[String]) -> Config {
  7 let query = args[1].clone();
  8 let file_path = args[2].clone();

    Config { query, file_path }
}

Lista 12-6: Refactorización de parse_config para devolver una instancia de una estructura Config

Hemos agregado una estructura llamada Config definida para tener campos llamados query y file_path [5]. La firma de parse_config ahora indica que devuelve un valor Config [6]. En el cuerpo de parse_config, donde antes devolvíamos rebanadas de cadena que referenciaban valores String en args, ahora definimos Config para contener valores String propios. La variable args en main es el propietario de los valores de argumento y solo está permitiendo que la función parse_config los preste, lo que significa que violaríamos las reglas de préstamo de Rust si Config intentara tomar posesión de los valores en args.

Hay varias maneras en las que podríamos manejar los datos String; la forma más fácil, aunque algo ineficiente, es llamar al método clone en los valores [7] [8]. Esto hará una copia completa de los datos para que la instancia de Config los tenga, lo que toma más tiempo y memoria que almacenar una referencia a los datos de cadena. Sin embargo, clonar los datos también hace que nuestro código sea muy sencillo porque no tenemos que manejar los períodos de vida de las referencias; en esta circunstancia, renunciar un poco de rendimiento para ganar simplicidad es un trato rentable.

Los Compromisos de Usar clone

Hay una tendencia entre muchos Rustaceans a evitar usar clone para solucionar problemas de posesión debido a su costo en tiempo de ejecución. En el Capítulo 13, aprenderá a usar métodos más eficientes en este tipo de situaciones. Pero por ahora, está bien copiar algunas cadenas para seguir avanzando porque solo harás estas copias una vez y tu ruta de archivo y cadena de consulta son muy pequeñas. Es mejor tener un programa funcional que sea un poco ineficiente que tratar de hiperoptimizar el código en tu primera iteración. A medida que te vuelvas más experimentado con Rust, será más fácil comenzar con la solución más eficiente, pero por ahora, es perfectamente aceptable llamar a clone.

Hemos actualizado main para que coloque la instancia de Config devuelta por parse_config en una variable llamada config [1], y actualizamos el código que anteriormente usaba las variables separadas query y file_path para que ahora use los campos de la estructura Config en su lugar [2] [3] [4].

Ahora nuestro código transmite más claramente que query y file_path están relacionados y que su propósito es configurar cómo funcionará el programa. Cualquier código que use estos valores sabe que los encontrará en la instancia config en los campos nombrados con su propósito.

Creación de un Constructor para Config

Hasta ahora, hemos extraído la lógica responsable de analizar los argumentos de línea de comandos de main y la hemos colocado en la función parse_config. Hacer esto nos ayudó a ver que los valores query y file_path estaban relacionados, y esa relación debería transmitirse en nuestro código. Luego agregamos una estructura Config para nombrar el propósito relacionado de query y file_path y poder devolver los nombres de los valores como nombres de campos de estructura desde la función parse_config.

Entonces, ahora que el propósito de la función parse_config es crear una instancia de Config, podemos cambiar parse_config de una función simple a una función llamada new que está asociada con la estructura Config. Hacer este cambio hará que el código sea más idiómático. Podemos crear instancias de tipos en la biblioteca estándar, como String, llamando a String::new. Del mismo modo, al cambiar parse_config en una función new asociada con Config, podremos crear instancias de Config llamando a Config::new. La Lista 12-7 muestra los cambios que necesitamos hacer.

Nombre de archivo: src/main.rs

fn main() {
    let args: Vec<String> = env::args().collect();

  1 let config = Config::new(&args);

    --snip--
}

--snip--

2 impl Config {
  3 fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}

Lista 12-7: Cambio de parse_config a Config::new

Hemos actualizado main donde estábamos llamando a parse_config para llamar en su lugar a Config::new [1]. Hemos cambiado el nombre de parse_config a new [3] y lo hemos movido dentro de un bloque impl [2], lo que asocia la función new con Config. Intenta compilar este código nuevamente para asegurarte de que funcione.

Corrigiendo el Manejo de Errores

Ahora trabajaremos en corregir nuestro manejo de errores. Recuerda que intentar acceder a los valores en el vector args en el índice 1 o índice 2 hará que el programa se detenga con un error si el vector contiene menos de tres elementos. Intenta ejecutar el programa sin ningún argumento; se verá así:

$ cargo run
   Compilando minigrep v0.1.0 (file:///projects/minigrep)
    Terminada la compilación en modo desarrollo [no optimizada + información de depuración] en 0.0s
     Ejecutando `target/debug/minigrep`
hilo'main' se detuvo con un error en 'índice fuera de los límites: la longitud es 1 pero el índice es 1', src/main.rs:27:21
nota: ejecuta con la variable de entorno `RUST_BACKTRACE=1` para mostrar una traza de pila

La línea índice fuera de los límites: la longitud es 1 pero el índice es 1 es un mensaje de error destinado a los programadores. No ayudará a nuestros usuarios finales a entender qué deben hacer en su lugar. Vamos a corregir eso ahora.

Mejora del Mensaje de Error

En la Lista 12-8, agregamos una comprobación en la función new que verificará que la rebanada sea lo suficientemente larga antes de acceder a los índices 1 e índice 2. Si la rebanada no es lo suficientemente larga, el programa se detiene con un error y muestra un mensaje de error mejor.

Nombre de archivo: src/main.rs

--snip--
fn new(args: &[String]) -> Config {
    if args.len() < 3 {
        panic!("no hay suficientes argumentos");
    }
    --snip--

Lista 12-8: Adición de una comprobación para el número de argumentos

Este código es similar a la función Guess::new que escribimos en la Lista 9-13, donde llamamos a panic! cuando el argumento value estaba fuera del rango de valores válidos. En lugar de comprobar un rango de valores aquí, estamos comprobando que la longitud de args sea al menos 3 y el resto de la función puede operar bajo la suposición de que esta condición se ha cumplido. Si args tiene menos de tres elementos, esta condición será true, y llamamos a la macro panic! para terminar el programa inmediatamente.

Con estas pocas líneas adicionales de código en new, ejecutemos el programa sin ningún argumento nuevamente para ver cómo se ve el error ahora:

$ cargo run
   Compilando minigrep v0.1.0 (file:///projects/minigrep)
    Terminada la compilación en modo desarrollo [no optimizada + información de depuración] en 0.0s
     Ejecutando `target/debug/minigrep`
hilo'main' se detuvo con un error en 'no hay suficientes argumentos',
src/main.rs:26:13
nota: ejecuta con la variable de entorno `RUST_BACKTRACE=1` para mostrar una traza de pila

Esta salida es mejor: ahora tenemos un mensaje de error razonable. Sin embargo, también tenemos información extraña que no queremos dar a nuestros usuarios. Quizás la técnica que usamos en la Lista 9-13 no es la mejor para usar aquí: una llamada a panic! es más adecuada para un problema de programación que para un problema de uso, como se discutió en el Capítulo 9. En lugar de eso, usaremos la otra técnica que aprendiste en el Capítulo 9: devolver un Result que indique éxito o un error.

Devolviendo un Result en lugar de llamar a panic!

En lugar de eso, podemos devolver un valor Result que contendrá una instancia de Config en el caso de éxito y describirá el problema en el caso de error. También vamos a cambiar el nombre de la función de new a build porque muchos programadores esperan que las funciones new nunca fallen. Cuando Config::build se comunica con main, podemos usar el tipo Result para señalar que hubo un problema. Luego podemos cambiar main para convertir una variante Err en un error más práctico para nuestros usuarios sin el texto circundante sobre thread'main' y RUST_BACKTRACE que causa una llamada a panic!.

La Lista 12-9 muestra los cambios que necesitamos hacer al valor de retorno de la función que ahora estamos llamando Config::build y al cuerpo de la función necesario para devolver un Result. Tenga en cuenta que esto no se compilará hasta que actualicemos main también, lo que haremos en la siguiente lista.

Nombre de archivo: src/main.rs

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("no hay suficientes argumentos");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

Lista 12-9: Devolviendo un Result desde Config::build

Nuestra función build devuelve un Result con una instancia de Config en el caso de éxito y un &'static str en el caso de error. Nuestros valores de error siempre serán literales de cadena que tienen la vida útil 'static.

Hemos hecho dos cambios en el cuerpo de la función: en lugar de llamar a panic! cuando el usuario no pasa suficientes argumentos, ahora devolvemos un valor Err, y hemos envolto el valor de retorno de Config en un Ok. Estos cambios hacen que la función se ajuste a su nueva firma de tipo.

Devolver un valor Err desde Config::build permite que la función main maneje el valor Result devuelto por la función build y salga del proceso de manera más limpia en el caso de error.

Llamada a Config::build y Manejo de Errores

Para manejar el caso de error y mostrar un mensaje amigable para el usuario, necesitamos actualizar main para manejar el Result que está siendo devuelto por Config::build, como se muestra en la Lista 12-10. También tomaremos la responsabilidad de salir de la herramienta de línea de comandos con un código de error no nulo alejándonos de panic! y en su lugar lo implementaremos a mano. Un estado de salida no nulo es una convención para señalar al proceso que llamó a nuestro programa que el programa salió con un estado de error.

Nombre de archivo: src/main.rs

1 use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

  2 let config = Config::build(&args).3 unwrap_or_else(|4 err| {
      5 println!("Problema al analizar los argumentos: {err}");
      6 process::exit(1);
    });

    --snip--

Lista 12-10: Salir con un código de error si la construcción de un Config falla

En esta lista, hemos usado un método que no hemos cubierto en detalle todavía: unwrap_or_else, que está definido en Result<T, E> por la biblioteca estándar [2]. Usar unwrap_or_else nos permite definir un manejo de errores personalizado, no basado en panic!. Si el Result es un valor Ok, el comportamiento de este método es similar a unwrap: devuelve el valor interno que está envolviendo Ok. Sin embargo, si el valor es un valor Err, este método llama al código en la clausura, que es una función anónima que definimos y pasamos como argumento a unwrap_or_else [3]. Cubriremos las clausuras en más detalle en el Capítulo 13. Por ahora, solo necesitas saber que unwrap_or_else pasará el valor interno de Err, que en este caso es la cadena estática "no hay suficientes argumentos" que agregamos en la Lista 12-9, a nuestra clausura en el argumento err que aparece entre los tubos verticales [4]. El código en la clausura puede entonces usar el valor err cuando se ejecuta.

Hemos agregado una nueva línea use para traer process de la biblioteca estándar al alcance [1]. El código en la clausura que se ejecutará en el caso de error solo tiene dos líneas: imprimimos el valor err [5] y luego llamamos a process::exit [6]. La función process::exit detendrá el programa inmediatamente y devolverá el número que se pasó como código de estado de salida. Esto es similar al manejo basado en panic! que usamos en la Lista 12-8, pero ya no obtenemos toda la salida extra. Intentemoslo:

$ cargo run
   Compilando minigrep v0.1.0 (file:///projects/minigrep)
    Terminada la compilación en modo desarrollo [no optimizada + información de depuración] en 0.48s
     Ejecutando `target/debug/minigrep`
Problema al analizar los argumentos: no hay suficientes argumentos

¡Excelente! Esta salida es mucho más amigable para nuestros usuarios.

Extracción de la Lógica de main

Ahora que hemos terminado de refactorizar el análisis de la configuración, pasemos a la lógica del programa. Como dijimos en "Separation of Concerns for Binary Projects" (Separación de Preocupaciones para Proyectos Binarios), extraeremos una función llamada run que contendrá toda la lógica que actualmente está en la función main y que no está involucrada en la configuración o el manejo de errores. Cuando hayamos terminado, main será concisa y fácil de verificar por inspección, y podremos escribir pruebas para toda la otra lógica.

La Lista 12-11 muestra la función run extraída. Por ahora, solo estamos realizando la pequeña mejora incremental de extraer la función. Todavía estamos definiendo la función en src/main.rs.

Nombre de archivo: src/main.rs

fn main() {
    --snip--

    println!("Buscando {}", config.query);
    println!("En el archivo {}", config.file_path);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.file_path)
     .expect("Debería haber sido posible leer el archivo");

    println!("Con el texto:\n{contents}");
}

--snip--

Lista 12-11: Extracción de una función run que contiene el resto de la lógica del programa

La función run ahora contiene toda la lógica restante de main, comenzando desde la lectura del archivo. La función run toma la instancia de Config como argumento.

Devolviendo Errores desde la Función run

Con el resto de la lógica del programa separada en la función run, podemos mejorar el manejo de errores, como lo hicimos con Config::build en la Lista 12-9. En lugar de permitir que el programa se detenga con un error llamando a expect, la función run devolverá un Result<T, E> cuando algo salga mal. Esto nos permitirá consolidar aún más la lógica alrededor del manejo de errores en main de una manera amigable para el usuario. La Lista 12-12 muestra los cambios que necesitamos hacer a la firma y el cuerpo de run.

Nombre de archivo: src/main.rs

1 use std::error::Error;

--snip--

2 fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)3?;

    println!("Con el texto:\n{contents}");

  4 Ok(())
}

Lista 12-12: Cambiando la función run para devolver Result

Hemos hecho tres cambios significativos aquí. Primero, cambiamos el tipo de retorno de la función run a Result<(), Box<dyn Error>> [2]. Esta función anteriormente devolvía el tipo unitario, (), y lo mantenemos como el valor devuelto en el caso Ok.

Para el tipo de error, usamos el objeto de tramo Box<dyn Error> (y hemos traído std::error::Error al alcance con una declaración use en la parte superior [1]). Cubriremos los objetos de tramo en el Capítulo 17. Por ahora, solo debes saber que Box<dyn Error> significa que la función devolverá un tipo que implemente el tramo Error, pero no tenemos que especificar qué tipo particular será el valor de retorno. Esto nos da flexibilidad para devolver valores de error que pueden ser de diferentes tipos en diferentes casos de error. La palabra clave dyn es abreviatura de dinámico.

Segundo, hemos eliminado la llamada a expect a favor del operador ? [3], como hablamos en el Capítulo 9. En lugar de detenerse con un error con panic!, ? devolverá el valor de error de la función actual para que el llamador lo maneje.

Tercero, la función run ahora devuelve un valor Ok en el caso de éxito [4]. Hemos declarado el tipo de éxito de la función run como () en la firma, lo que significa que necesitamos envolver el valor del tipo unitario en el valor Ok. Esta sintaxis Ok(()) puede parecer un poco extraña al principio, pero usar () de esta manera es la forma idiómática de indicar que estamos llamando a run solo por sus efectos secundarios; no devuelve un valor que necesitemos.

Cuando ejecutes este código, se compilará pero mostrará una advertencia:

advertencia: `Result` no utilizado que debe ser utilizado
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^^
   |
   = nota: `#[warn(unused_must_use)]` activado por defecto
   = nota: este `Result` puede ser una variante `Err`, que debe
ser manejada

Rust nos dice que nuestro código ignoró el valor Result y el valor Result puede indicar que se produjo un error. Pero no estamos comprobando si hubo o no un error, y el compilador nos recuerda que probablemente queríamos tener algún código de manejo de errores aquí. Vamos a corregir ese problema ahora.

Manejo de Errores Devueltos por run en main

Verificaremos los errores y los manejaremos usando una técnica similar a la que usamos con Config::build en la Lista 12-10, pero con una pequeña diferencia:

Nombre de archivo: src/main.rs

fn main() {
    --snip--

    println!("Buscando {}", config.query);
    println!("En el archivo {}", config.file_path);

    if let Err(e) = run(config) {
        println!("Error de la aplicación: {e}");
        process::exit(1);
    }
}

Usamos if let en lugar de unwrap_or_else para verificar si run devuelve un valor Err y para llamar a process::exit(1) si es el caso. La función run no devuelve un valor que queramos desenvolver de la misma manera que Config::build devuelve la instancia de Config. Dado que run devuelve () en el caso de éxito, solo nos importa detectar un error, por lo que no necesitamos unwrap_or_else para devolver el valor desenvolverido, que solo sería ().

Los cuerpos de las funciones if let y unwrap_or_else son los mismos en ambos casos: imprimimos el error y salimos.

Dividir el Código en un Caja de Librería

Hasta ahora, nuestro proyecto minigrep se ve muy bien. Ahora dividiremos el archivo src/main.rs y pondremos un poco de código en el archivo src/lib.rs. De esta manera, podemos probar el código y tener un archivo src/main.rs con menos responsabilidades.

Movamos todo el código que no está en la función main de src/main.rs a src/lib.rs:

  • La definición de la función run
  • Las declaraciones use relevantes
  • La definición de Config
  • La definición de la función Config::build

El contenido de src/lib.rs debería tener las firmas mostradas en la Lista 12-13 (hemos omitido los cuerpos de las funciones por brevedad). Tenga en cuenta que esto no se compilará hasta que modifiquemos src/main.rs en la Lista 12-14.

Nombre de archivo: src/lib.rs

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

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(
        args: &[String],
    ) -> Result<Config, &'static str> {
        --snip--
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    --snip--
}

Lista 12-13: Moviendo Config y run a src/lib.rs

Hemos usado ampliamente la palabra clave pub: en Config, en sus campos y su método build, y en la función run. Ahora tenemos una caja de librería que tiene una API pública que podemos probar.

Ahora necesitamos traer el código que movimos a src/lib.rs al alcance de la caja binaria en src/main.rs, como se muestra en la Lista 12-14.

Nombre de archivo: src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    --snip--
    if let Err(e) = minigrep::run(config) {
        --snip--
    }
}

Lista 12-14: Usando la caja de librería minigrep en src/main.rs

Agregamos una línea use minigrep::Config para traer el tipo Config de la caja de librería al alcance de la caja binaria, y le agregamos el nombre de nuestra caja como prefijo a la función run. Ahora todas las funcionalidades deberían estar conectadas y deberían funcionar. Ejecute el programa con cargo run y asegúrese de que todo funcione correctamente.

¡Uy! Eso fue mucho trabajo, pero nos hemos preparado para el éxito en el futuro. Ahora es mucho más fácil manejar errores y hemos hecho el código más modular. Casi todo nuestro trabajo se hará en src/lib.rs a partir de ahora.

Vamos a aprovechar esta nueva modularidad haciendo algo que habría sido difícil con el código antiguo pero es fácil con el nuevo código: ¡escribiremos algunas pruebas!

Resumen

¡Felicidades! Has completado el laboratorio de Refactorización para Mejorar la Modularidad y el Manejo de Errores. Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.