Para un Rustacean experimentado, salta a la vista que la siguiente función no devuelve un Result<>
, sino una tupla:
fn add_student() -> (Student, bool)
Este enfoque no solo no es idiomático, sino que también es engañoso para el lector del código: "¿Qué significa el valor bool?" podría preguntarse alguien. Luego, para reaccionar al resultado de esta función, se tiene que escribir algo tan complicado como lo siguiente:
// Agregar estudiante al curso
let (st, err) = add_student();
// Verificar si hay error. Si hay error, continuar el bucle
if !err {
continue;
}
Cinco líneas con comentarios para que el lector entienda el código. Los nombres de variables cortos son otra mala práctica.
Refactoricemos estos fragmentos primero. De:
fn add_student() -> (Student, bool) {
// ...
let mut st = Student {
name: "".to_string(),
age: 0,
};
// ...
if student_name.len() < 3 {
// ...
return (st, false);
}
// ...
(st, true)
}
A un enfoque más idiomático y legible:
fn add_student() -> Result<Student, &'static str> {
// ...
if student_name.len() < 3 {
// ...
return Err("Nombre del estudiante demasiado corto");
}
// ...
let age = age.parse().map_err(|_| "No se puede analizar la edad del estudiante")?;
Ok(Student {
name: student_name,
age
})
}
Soy consciente de que devolver cadenas estáticas como errores no es una práctica sostenible, pero es suficiente para este ejemplo. Si alguna vez se crea la segunda parte de este artículo sobre cómo mejorar el código con el uso de crates externos, demostraré las buenas prácticas reconocidas.
El método .map_err()
utilizado en el ejemplo permite la conversión del tipo de instancia contenido por el valor Err(e)
del enum en uno compatible con nuestra función.
El tipo declarado, en este caso, es &'static str
(el equivalente de Rust al tipo idiomático const char*
en C), por lo que nuestros textos entre comillas coinciden. El operador ?
es, de hecho, una de las mejores características de Rust: verifica la instancia de Result<>
antes de él; si el valor es Err(e)
, devuelve ese resultado; de lo contrario, continúa. En el pasado, existía el macro try!()
que puedes ver en código antiguo.
Como resultado, nuestra verificación de si tenemos el resultado esperado de la función evoluciona a lo siguiente:
let student = if let Ok(student) = add_student() {
student
} else {
continue;
}
student_db.push(student.clone());
Esta condición no es ideal, ya que efectivamente descarta cualquier error. Lo hacemos aquí bajo el supuesto de que se nos permite, pero considera manejar el enum Err(e)
caso por caso.
loop
El código original tiene dos problemas con loop
:
exit_var
muestra el "resultado" antes de salir.
Ambas prácticas son errores comunes de principiantes. Mejoremos el código y convirtamos:
let mut i: i8 = 1;
loop {
en
for i in 1usize.. {
basándonos en la suposición de que el tamaño típico de 64 bits de usize
hoy en día es suficiente para mucho tiempo.
Luego eliminamos:
i += 1;
de esa manera simplificamos el código y lo hicimos más legible.
Luego movemos la invocación de mostrar la lista de estudiantes recopilados fuera del bucle:
if exit_var == "q" {
println!("Saliendo...");
display_students_in_course(&student_db);
break;
}
se convierte en:
/// ...
if exit_var == "q" {
break;
}
}
println!("Saliendo...");
display_students_in_course(&student_db);
Este programa me recuerda a mí mismo en los años 80 cuando escribir tales programas en BASIC era bastante común. Pero BASIC era un lenguaje imperativo básico que carecía de muchas características que son comunes hoy en día, como las funciones. Pero como resultado, el pensamiento directo sobre el problema llevó a un código directo. El código en el que estamos trabajando aquí es precisamente eso, un ejemplo de pensamiento directo sobre la receta de cómo lograr el resultado esperado.
Esto funciona al principio, pero generalmente se vuelve insostenible bastante rápido. Como remedio, las universidades enseñan a los estudiantes programación orientada a objetos. A pesar de que la enseñan mal, sin entrar en detalles, vamos a utilizar algunos de sus principios para mejorar el código para el futuro.
En palabras más simples, encapsulación consiste en confinar elementos básicos para evitar accesos no deseados y ocultar detalles de implementación al usuario.
Rust, por naturaleza, no es orientado a objetos, ya que su modelo de tipos y rasgos está más cerca de los lenguajes funcionales que de los lenguajes orientados a objetos propiamente dichos. Es lo suficientemente bueno para encapsular cosas en nuestro programa simple.
En este ejemplo, demostraré cómo usar módulos para la refactorización, aunque puede que no sea necesario para un programa de este tamaño.
Comencemos con esta línea de código:
let mut student_db: Vec<Student> = Vec::new();
que crea un vector mutable vacío. Pero este tipo no dice mucho sobre lo que el usuario de esta base de datos primitiva puede hacer. En nuestro caso, no es mucho.
Vamos a crear el módulo src/db.rs
e incluir el siguiente código en él:
use super::Student;
pub struct StudentDatabase {
db: Vec<Student>
}
impl StudentDatabase {
pub fn new() -> Self {
Self {
db: vec![]
}
}
}
luego, al comienzo de src/main.rs
tenemos que agregar:
mod db;
para que este módulo sea tenido en cuenta durante la compilación.
Pero este código simple solo inicializa el vector interno. Sin embargo, este ejemplo simple es lo suficientemente bueno para comprender rápidamente cómo los tipos pueden ejercer el poder de los métodos. El idioma de Rust sobre el método new()
, a diferencia de otros lenguajes, se trata de ser el constructor más simple de una instancia en la pila.
¿Eh? Si te sientes confundido, tienes que aprender sobre el uso de la pila en programas escritos en lenguajes que no usan recolección de basura. Sin entrar en demasiados detalles (este tema merece un artículo completo), otros lenguajes tienden a usar new()
para la asignación de memoria en el montón (estoy pensando en C++ y Java).
Avanzando, necesitamos la capacidad de agregar estudiantes; hagámoslo simplemente liberando al usuario de la API de instanciar el tipo Student
(esta es una forma, no siempre ideal, pero no es el tema de esta lección). Nuevo código para agregar a impl StudentDatabase
:
pub fn add(&mut self, name: String, age: u8) {
self.db.push(Student {
name,
age
})
}
suponiendo que no pueda fallar de manera elegante, lo que está en línea con el método .push()
de std::Vec
: puede hacer pánico. Observa que Rust puede hacer coincidir los nombres de los argumentos de la función con los nombres de los campos del tipo que estamos utilizando.
Esta es una abreviatura de inicialización de campos que simplifica el código y mejora la legibilidad, y no requiere escribir name: name
.
Otro aspecto que vale la pena mencionar es el hecho de que el argumento name
es consumidor, lo que en la semántica de Rust significa que estamos moviendo la instancia de esa cadena al ámbito del
cuerpo de la función. Por lo tanto, la necesidad de .clone()
en el código original. Esa no es la forma más eficiente de trabajar con cadenas, pero está más allá del alcance de este artículo discutir otras opciones.
Para completar la refactorización en src/main.rs
, necesitamos agregar un método más. Normalmente comenzarías a refactorizar de inmediato dejando que tu editor muestre errores en el código, pero para evitar confusiones en esta etapa, preparamos todos los componentes que necesitamos de antemano. Aquí vamos:
pub fn display(&self) {
for student in self.db.as_slice() {
println!("Nombre: {}, Edad: {}", student.name, student.age);
}
}
En esta etapa, para cumplir con todos los requisitos del código existente, necesitamos conocer la longitud de la base de datos. En la siguiente etapa, mejoraremos la encapsulación para eliminar la necesidad de eso; sin embargo, es una función útil en la API pública de la base de datos.
Para verificar la longitud:
pub fn len(&self) -> usize {
self.db.len()
}
Ahora es el momento de aplicar nuestro nuevo código a src/main.rs
. Primero, reemplaza:
let mut student_db: Vec<Student> = Vec::new();
por
let mut student_db = db::StudentDatabase::new();
luego elimina:
display_students_in_course(&student_db);
del cuerpo de la condición de longitud máxima. Y la definición de esa función también debe eliminarse para evitar advertencias sobre código inactivo.
Luego, reemplaza la misma línea al final del cuerpo de main()
con:
student_db.display();
Después de eso, la adición original en el vector:
student_db.push(student.clone());
se reemplaza con:
student_db.add(student.name.clone(), student.age);
Esto muestra una deficiencia de nuestra decisión anterior sobre los argumentos de add()
. En lenguajes que admiten la sobrecarga, podríamos hacer esto de ambas maneras, pero en Rust necesitas nombres de métodos explícitos, por lo que lo dejaremos así por ahora. Centrémonos en la función add_student()
, que no agrega ningún estudiante, por lo que tiene un nombre incorrecto. Así que comenzamos renombrando:
// Función para agregar un nuevo estudiante a la base de datos
fn add_student() -> Result<Student, &'static str> {
en
fn input_student() -> Result<Student, &'static str> {
y la invocación también.
El código de esa función intenta hacer algunas cosas de manera redundante:
let student_name = &input[..input.len() - 1].trim();
// ...
let age = input.trim();
age.to_string().pop(); // Eliminar carácter de nueva línea
let age = age.parse().map_err(|_| "No se puede analizar la edad del estudiante")?;
por lo que esto requiere una corrección en Rust más simple y idiomático:
let student_name = input.trim();
// ...
let age = input.trim().parse().map_err(|_| "No se puede analizar la edad del estudiante")?;
Lo que se vuelve claramente visible en el resultado es el patrón repetido de:
let mut input = String::new();
let _ = stdin().read_line(&mut input);
donde se ignora el resultado de read_line()
. Tal ignorancia se considera una mala práctica.
Una solución rápida sería agregar la siguiente función y reemplazar el código repetido con ella:
fn prompt_input<T: FromStr>(prompt: &str) -> Result<T, &'static str> {
println!("{}: ", prompt);
let mut input = String::new();
let _ = stdin().read_line(&mut input);
input.trim().parse().map_err(|_| "No se puede analizar la entrada")
}
Así que terminamos con la función input_student()
tan compacta como:
fn input_student() -> Result<Student, &'static str> {
print!("#### Agregando Nuevo Estudiante ####\n");
let student_name: String = prompt_input("Ingrese el Nombre del Estudiante")?;
// Verificar que el nombre del estudiante tenga al menos 3 caracteres
if student_name.len() < 3 {
println!(
"El nombre del estudiante no puede tener menos de 3 caracteres. Registro no agregado.\n Por favor, inténtelo de nuevo"
);
return Err("Nombre del estudiante demasiado corto");
}
let age = prompt_input("Edad del Estudiante")?;
Ok(Student {
name: student_name.to_string(),
age,
})
}
Está lejos de ser excelente, pero es una mejora significativa, ¿no crees?
En esta sección, cubrimos los fundamentos de la encapsulación, que también ayuda a mantener el código DRY (Don’t Repeat Yourself - No Repitas Tu Mismo). Hacemos todo lo posible para aislar la función de uso de los detalles de implementación. El código final está lejos de ser perfecto (si es que alguna vez puede serlo), pero como actúa como una ilustración de los pasos tomados, ciertas partes deben dejarse para más adelante.
Una de las muy buenas prácticas que falta en los ejemplos originales son las pruebas unitarias. Las pruebas unitarias en nuestro código actúan como la primera barrera de calidad capaz de detectar muchos errores y fallos que pueden ser costosos de detectar y corregir si se escapan a la siguiente barrera, y aún más costosos si se escapan más adelante. Esto ocurre con bastante frecuencia en proyectos jóvenes, ya que al principio solo pueden permitirse pruebas manuales.
Como separamos partes importantes de la lógica central en módulos separados, tenemos un buen punto de partida para introducir pruebas unitarias. Algunas personas pueden quejarse en este punto diciendo que un buen código comienza con pruebas unitarias. Pero la realidad es que es una visión utópica, ya que la mayoría del código en la vida real comienza con algún borrador inicial de un prototipo. Así que dejemos esa discusión de lado.
Rust es un lenguaje muy amigable para los escritores de pruebas unitarias. Los mecanismos básicos están integrados. No son ideales en todos los aspectos, pero son lo suficientemente buenos para comenzar a probar sin mucho esfuerzo.
Agreguemos al final de src/db.rs
:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_to_database() {
let mut db_ut = StudentDatabase::new();
db_ut.add("Estudiante de Prueba".to_string(), 34);
assert_eq!(db_ut.len(), 1);
}
}
Esta es una prueba muy primitiva, pero un buen comienzo. Ejecutar cargo test
produciría:
Compiling student v0.1.0 (/home/teach/rust-student-mini-project)
Finished test [unoptimized + debuginfo] target(s) in 0.16s
Running unittests src/main.rs (target/debug/deps/student-f5f1fdf375ff16cf)
running 1 test
test db::tests::add_to_database ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
así que nuestra prueba pasa.
Pero la otra parte de nuestra API es mostrar el contenido de la base de datos. Entonces nos encontramos con el problema de la salida de esa función. Capturar la salida en la prueba es teóricamente posible, pero es complejo y está muy fuera del alcance de este artículo.
Centrémonos en una práctica llamada inyección de dependencias inversa que, en palabras simples, consiste en proporcionar interfaces para nuestra unidad bajo prueba para todo aquello de lo que pueda depender. En la práctica, esto se limita a aspectos que mejoran la capacidad de prueba y, como resultado, la reutilización de nuestro código.
Para lograr esto, necesitamos cambiar el método .display()
de StudentDatabase
a:
pub fn display_on(&self, output: &mut impl std::io::Write) -> io::Result<()> {
for student in self.db.as_slice() {
write!(output, "Nombre: {}, Edad: {}\n", student.name, student.age)?;
}
Ok(())
}
luego la invocación en src/main.rs
se convierte en:
student_db.display_on(&mut stdout()).expect("Fallo inesperado de salida");
Así que, en efecto, podemos crear la siguiente prueba a continuación:
#[test]
fn print_database() {
let mut db_ut = StudentDatabase::new();
db_ut.add("Estudiante de Prueba".to_string(), 34);
db_ut.add("Foo Bar".to_string(), 43);
let mut output: Vec<u8> = Vec::new();
db_ut.display_on(&mut output).expect("Fallo inesperado de salida");
let result = String::from_utf8_lossy(&output);
assert_eq!(result, "Nombre: Estudiante de Prueba, Edad: 34\nNombre: Foo Bar, Edad: 43\n");
}
Luego vemos que ambas pruebas pasan:
running 2 tests
test db::tests::add_to_database ... ok
test db::tests::print_database ... ok
En un par de etapas, este artículo demostró cómo pasar de un estado donde el código parece escrito por un novato adolescente al comienzo de su aprendizaje de programación, hasta un punto donde el código está cerca del nivel esperado de un ingeniero junior trabajando en su primer trabajo durante un par de meses. Y no escribo esto para ofender a nadie, solo para subrayar el papel de la experiencia en el diseño de una estructura básica de código. Escribir código spaghetti es fácil, planificar bien cómo debe estructurarse el código con la cantidad adecuada de separación de preocupaciones es difícil y, en mis 25 años de carrera, todavía aprendo cómo hacerlo mejor. Este aprendizaje nunca termina para los buenos programadores.
Incluso la versión refactorizada tiene fallas, soy consciente de eso. Simplemente pulirla ahora al nivel de plena satisfacción desdibujaría el sentido de este artículo y haría que los cambios fueran mucho más complejos de explicar. Dejo como ejercicio para los lectores encontrar lo que me perdí y cómo más pruebas unitarias pueden ayudar a prevenir la vergüenza.
Hice algunos de los cambios importantes que el código requería para ser mejorado y cometí cambios realizados en esta etapa para referencia. Ninguna de las soluciones demostradas son definitivas, pero la mayoría deberían estar cerca de lo que cualquier programador muy experimentado cambiaría en este código en primer lugar. Sin embargo, tienes derecho a tener tu opinión sobre el asunto. Te invito a desafiar mi enfoque escribiendo tu artículo sobre los cambios propuestos en la línea de base.
Aparte de eso, espero que lo hayas disfrutado. 🖐️😃
Jorge García
Fullstack developer