Rusty Full Stack
El blog para los amantes de Rust, Ionic y Vuejs
El blog para los amantes de Rust, Ionic y Vuejs
En esta publicación intentaremos aprender como evitar duplicar código en Rust, pero la primera pregunta es ¿por qué podríamos llegar a duplicar código y por qué es "malo"?
Vamos a comenzar comentando que duplicar código no necesariamente puede ser malo, sin embargo, no es lo más óptimo porque nuestro código puede comenzar a ser difícil de mantener o de entender ya que los motivos del por qué existe código duplicado es aveces complicado de explicar. En casi todos los lenguajes de programación, existen estrategias o herramientas para evitar duplicar código, por ejemplo, crear funciones, clases, métodos de actividades repetitivas nos ayuda a no duplicar código.
Aunque duplicar código no es un problema únicamente de Rust, sino que nos puede pasar en cualquier lenguaje de programación, existe un detalle importante a tomar en cuenta cuando programamos con Rust y son los tipos de datos, esto no es exclusivo de Rust, sino que también cualquier otro lenguaje estrictamente "tipeado" como C, C++, etc.
¿A qué se debe que los tipos de datos nos pueden llevar a duplicar código? lo más sencillo es explicar esto con un ejemplo, imagina que necesitas crear un programa en el cual se deba sumar todos los números de una lista. Como buenos programadores seguramente querrás crear una función para sumar los números 🤓
En el caso de Rust, a lo mejor la función sea más o menos igual a la siguiente:
// Ejemplo
fn sumar(lista: &[i32]) -> i32 {
let mut resultado: i32 = 0;
for elemento in lista {
resultado += elemento;
}
resultado
}
La función anterior es únicamente para ayudar con el ejemplo, lo mejor sería hacerlo con iterators, puedes aprender sobre ellos en esta publicación.
Iteradores en Rust Con Ejemplos
La función anterior ya cumple con el objetivo que nos planteamos, pero un detalle a notar es que la función únicamente trabajaría con el tipo de datos i32, o enteros, pero ¿qué pasaría si queremos hacer lo mismo, pero el tipo de datos sería números decimales o enteros? la solución duplicando código se vería de esta forma:
// Ejemplo duplicando código
fn sumar_i32(lista: &[i32]) -> i32 {
let mut resultado: i32 = 0;
for elemento in lista {
resultado += elemento;
}
resultado
}
fn sumar_f32(lista: &[f32]) -> f32 {
let mut resultado: f32 = 0.0;
for elemento in lista {
resultado += elemento;
}
resultado
}
Al observar ambas funciones, podemos ver que tanto el loop for y la forma de devolver los datos, son prácticamente lo mismo. Con Rust tenemos una manera de poder evitar esa duplicidad utilizando generics.
Los Generics o Generics Data Types son una herramienta para declarar en Rust que vamos a utilizar un dato genérico o que el dato que vamos a procesar puede ser de "cualquier" tipo, al declarar tipos genéricos en Rust, podemos evitar duplicar código porque ya no tendríamos que preocuparnos, por ejemplo, si procesamos un i32 o un f32, para Rust, sería un número simplemente.
Hagamos un nuevo ejemplo un poco más completo, vamos a hacer un programa en el cual vamos a procesar una lista de números, de la lista, vamos a devolver un vector con todos los elementos que sean mayores a un determinado número, por ejemplo:
si tenemos una lista con estos números:
[10, 12, 5, 6, 1]
y queremos los valores mayores a 9, nuestro programa regresara un vector con estos elementos [10, 12]
Comencemos creando nuestro proyecto, en tu terminal en una carpeta de gusto ejecuta el siguiente comando
cargo new ejemplo_generics_data
cd ejemplo_generics_data
Recuerda que puedes ver el ejemplo completo en nuestro repositorio en github dando click a este enlace.
Vamos a crear una función la cual recibirá como parámetro la referencia a la lista y otro parámetro con el número a evaluar, luego vamos a modificar nuestro archivo src/main.rs para agregar el siguiente código y poder utilizar nuestra función
// src/main.rs
fn numeros_mayores_a(lista: &[i32], numero: i32) -> Vec<&i32> {
let mut resultado: Vec<&i32> = vec![];
for elemento in lista {
if *elemento > numero {
resultado.push(elemento);
}
}
resultado
}
fn main() {
let lista = [10, 12, 5, 6, 8];
let numero = 9;
let resultado = numeros_mayores_a(&lista, numero);
println!("Resultados obtenidos");
for elemento in resultado {
println!("{}", elemento);
}
}
Si observamos la función llamada numeros_mayores_a, vamos a observar que recibe la referencia de la lista de números, es importante observar también que cuando evaluamos los elementos en esta línea:
if *elemento > numero
utilizamos el caracter * debido a que la lista es en realidad la referencia en memoria de los valores, con el * accedemos directamente al valor, o sea, al número, finalmente creamos un vector con la referencia de los números que nos interesan.
Si ahora corremos nuestro programa vamos a observar el resultado siguiente:
cargo run
El cual nos muestra justamente los resultados que esperamos, si ahora modificamos nuestra función main en src/main.rs para agregar otra lista de números y otro valor a evaluar, por ejemplo 6, vamos a ver que el programa sigue funcionando:
// src/main.rs
fn numeros_mayores_a(lista: &[i32], numero: i32) -> Vec<&i32> {
let mut resultado: Vec<&i32> = vec![];
for elemento in lista {
if *elemento > numero {
resultado.push(elemento);
}
}
resultado
}
fn main() {
let lista = [10, 12, 5, 6, 8];
let numero = 9;
let resultado = numeros_mayores_a(&lista, numero);
println!("Resultados obtenidos para la primera lista");
for elemento in resultado {
println!("{}", elemento);
}
let lista2 = [11, 21, 3, 15, 1];
let numero2 = 6;
let resultado2 = numeros_mayores_a(&lista2, numero2);
println!("Resultados obtenidos para la segunda lista");
for elemento in resultado2 {
println!("{}", elemento);
}
}
Y ahora logra mostrarnos los valores para ambas listas:
Hasta el momento nuestra función ha sido bastante útil pero porque al observar las listas, te podrás dar cuenta que todos son para tipos de datos i32, pero imagina que ahora quisieras hacer lo mismo para números decimales o f32, entonces nuestra función no nos serviría, como hemos visto antes, una forma de solventar eso es crear dos funciones, una para los i32 y otra para f32, más o menos de esta forma:
fn numeros_mayores_a_i32(lista: &[i32], numero: i32) -> Vec<&i32> {
let mut resultado: Vec<&i32> = vec![];
for elemento in lista {
if *elemento > numero {
resultado.push(elemento);
}
}
resultado
}
fn numeros_mayores_a_f32(lista: &[f32], numero: f32) -> Vec<&f32> {
let mut resultado: Vec<&f32> = vec![];
for elemento in lista {
if *elemento > numero {
resultado.push(elemento);
}
}
resultado
}
Como podemos ver, ambas funciones son exactamente iguales, lo único que cambia son los tipos de datos, aunque esta solución, en tiempo de compilación, es la más óptima, a nivel de código no lo es tanto por la duplicación de instrucciones. Ahora veamos como poder resolver el problema utilizando Generics o Generics Types con Rust.
Los tipos genéricos en Rust, pueden declararse o "llamarse" como nosotros lo deseemos, y su nombre suele representarse en PascalCase, pero es bastante frecuente encontrárseles representados por un único caracter, normalmente "T".
Para declarar un tipo genérico en Rust, es importante que la "firma" o declaración de nuestras funciones o estructuras, también sepan que estamos declarando ese tipo genérico, es en esencia hacer algo como:
fn mi_funcion<MiTipoGenerico>(mi_parametro: MiTipoGenerico)
como puedes ver en el ejemplo anterior, luego del nombre de la función, estamos colocando <MiTipoGenerico> es decir, le hacemos saber a la función que vamos a utilizar un tipo que puede ser cualquiera (recuerda que puedes llamar al tipo como quieras) luego ese tipo genérico ya lo podemos utilizar en nuestra función, como por ejemplo para indicar que un parámetro será de ese tipo o incluso si queremos retornar ese tipo genérico. Retomando nuestro ejemplo, nuestra función se vería más o menos así (el código siguiente tiene un error que arreglaremos en un momento).
fn numeros_mayores_a<T>(lista: &[T], numero: T) -> Vec<&T> {
let mut resultado: Vec<&T> = vec![];
for elemento in lista {
if *elemento > numero {
resultado.push(elemento);
}
}
resultado
}
Como puedes ver, es prácticamente la misma función, pero estamos declarando que utilizaremos un tipo genérico el cual hemos llamado simplemente "T", pero recuerda que puedas llamarlo como quieras, puedes probar reemplazar "T" por "MiTipo" y verás que siempre funcionará.
Es importante mencionar que ahora ese "T" puede ser cualquier dato como un i32 o un f32, incluso un caracter!
Recuerda que la función tiene un pequeño error que resolveremos en un momento, pero ahora vamos a agregarlo a nuestro programa, modificamos src/main.rs para que se vea igual al siguiente código:
// src/main.rs
fn numeros_mayores_a<T>(lista: &[T], numero: T) -> Vec<&T> {
let mut resultado: Vec<&T> = vec![];
for elemento in lista {
if *elemento > numero {
resultado.push(elemento);
}
}
resultado
}
fn main() {
let lista = [10, 12, 5, 6, 8];
let numero = 9;
let resultado = numeros_mayores_a(&lista, numero);
println!("Resultados obtenidos para la primera lista");
for elemento in resultado {
println!("{}", elemento);
}
let lista2 = [11, 21, 3, 15, 1];
let numero2 = 6;
let resultado2 = numeros_mayores_a(&lista2, numero2);
println!("Resultados obtenidos para la segunda lista");
for elemento in resultado2 {
println!("{}", elemento);
}
}
Si ahora intentamos correr nuestro programa vamos a notar un error de compilación.
El error dice "binary operation > cannot be applied to type 'T'" esto se debe que aunque estamos declarando un tipo genérico, el operador > solamente puede aplicarse a datos en los cuales se puede determinar si un valor es más grande que otro desde tiempo de compilación, por suerte, el compilador también nos da la solución que es simplemente agregar lo siguiente en la declaración del tipo genérico:
str::cmp::PartialOrd
ahora corregimos nuestro programa:
// src/main.rs
fn numeros_mayores_a<T: std::cmp::PartialOrd>(lista: &[T], numero: T) -> Vec<&T> {
let mut resultado: Vec<&T> = vec![];
for elemento in lista {
if *elemento > numero {
resultado.push(elemento);
}
}
resultado
}
fn main() {
let lista = [10, 12, 5, 6, 8];
let numero = 9;
let resultado = numeros_mayores_a(&lista, numero);
println!("Resultados obtenidos para la primera lista");
for elemento in resultado {
println!("{}", elemento);
}
let lista2 = [11, 21, 3, 15, 1];
let numero2 = 6;
let resultado2 = numeros_mayores_a(&lista2, numero2);
println!("Resultados obtenidos para la segunda lista");
for elemento in resultado2 {
println!("{}", elemento);
}
}
Si volvemos a ejecutar nuestro programa con:
cargo run
veremos que siempre funciona tal cual había estado funcionando con las listas de tipo i32.
Ahora intentemos re-utilizar la misma función pero para una lista de f32, agreguemos ese nueva lista en nuestra función main en src/main.rs
// src/main.rs
fn numeros_mayores_a<T: std::cmp::PartialOrd>(lista: &[T], numero: T) -> Vec<&T> {
let mut resultado: Vec<&T> = vec![];
for elemento in lista {
if *elemento > numero {
resultado.push(elemento);
}
}
resultado
}
fn main() {
let lista = [10, 12, 5, 6, 8];
let numero = 9;
let resultado = numeros_mayores_a(&lista, numero);
println!("Resultados obtenidos para la primera lista");
for elemento in resultado {
println!("{}", elemento);
}
let lista2 = [11, 21, 3, 15, 1];
let numero2 = 6;
let resultado2 = numeros_mayores_a(&lista2, numero2);
println!("Resultados obtenidos para la segunda lista");
for elemento in resultado2 {
println!("{}", elemento);
}
let lista_f32 = [1.5, 6.76, 5.25, 8.90, 10.55];
let numero_f32 = 5.5;
let resultado_f32 = numeros_mayores_a(&lista_f32, numero_f32);
println!("Resultados obtenidos para la lista f32 utilizando generics");
for elemento in resultado_f32 {
println!("{}", elemento);
}
}
como puedes ver hemos agregado una nueva lista con los siguientes valores:
[1.5, 6.76, 5.25, 8.90, 10.55]
Queremos los números mayores a 5.5, al correr nuestro programa veremos que funciona tanto para i32 como para f32 con la misma función utilizando generics 😎
Puedes ver el ejemplo completo en nuestro repositorio en github dando click a este enlace.
Uno de los grandes beneficios de los lenguajes con declaración estricta de tipos es el rendimiento, eso se debe a que nuestro programa, en tiempo de compilación, es capaz de determinar los tipos de datos a utilizar y de esta forma se vuelven más rápidos que cuando no lo saben, pero entonces ¿qué pasa con el rendimiento de nuestros programas si utilizamos tipos genéricos, se vuelven más lentos? 🤔
La buena noticia es que los tipos genéricos no tienen ningún impacto en el rendimiento de nuestro programa, es decir que si nuestro programa ya es rápido, lo seguirá siendo 🤩 🥳
El motivo que no impacte el rendimiento, se debe que al momento de compilar nuestro programa, Rust crea una versión de nuestra función con tipos genéricos para cada tipo esperado, por ejemplo para nuestro caso anterior, el programa compilado tendría este código (pero en binario 😬):
fn numeros_mayores_a_i32(lista: &[i32], numero: i32) -> Vec<&i32> {
let mut resultado: Vec<&i32> = vec![];
for elemento in lista {
if *elemento > numero {
resultado.push(elemento);
}
}
resultado
}
fn numeros_mayores_a_f32(lista: &[f32], numero: f32) -> Vec<&f32> {
let mut resultado: Vec<&f32> = vec![];
for elemento in lista {
if *elemento > numero {
resultado.push(elemento);
}
}
resultado
}
por lo que nuestro rendimiento no es impactado en tiempo de ejecución.
Para el ejemplo anterior hemos empezado a utilizar datos genéricos pero en funciones y únicamente hemos utilizado un tipo de dato genérico, pero ¿qué pasaría si queremos utilizar tipos genéricos en una estructura (o también en traits) y no únicamente declarar un tipo de dato genérico?
Para probar que también podemos utilizar generic types en estructuras con Rust, creemos un nuevo ejemplo. Esta vez vamos a crear un programa en el cual crearemos una estructura que representará un record en una base de datos, vamos a asumir que el record tendrá tres atributos:
También vamos a crear otra estructura la cual contendrá un vector de los "records" y vamos a crearle un método el cual reciba como parámetro el id que queremos buscar y nos deberá devolver el campo "valor" del record que puede ser un String o número.
Comencemos creando nuestro proyecto con los siguientes comandos:
cargo new generics_en_estructuras
cd generics_en_estructuras
Para ver el código completo de este ejemplo, puedes dar click a este enlace.
Según el enunciado anterior, tanto el id del record como su valor pueden tener tipos diferentes, ¿te imaginas hacer una estructura por cada combinación de tipos de datos y luego te imaginas estar evaluando si un determinado campo es de un tipo de dato para poder controlar que hacer 😵?
Este es un ejemplo perfecto para utilizar generics types, comencemos definiendo la estructura de los records, al igual que con las funciones, es necesario que la "firma" o declaración de la estructura sepa que vamos a utilizar tipos de datos genéricos y podemos llamarle como queramos, yo en este caso utilizaré "K" para el id y utilizaré "T" para el valor, pero tu puedes llamarle como creas conveniente. La estructura se miraría de esta forma en src/main.rs
// src/main.rs
struct Record<K, T> {
id: K,
valor: T,
peso: i32,
}
En la declaración de la estructura hemos colocado <K, T> para indicar que vamos a utilizar dos tipos de datos que son genéricos y que no necesariamente tendrán el mismo tipo de dato.
Luego para el campo id, hemos declarado que utilizaremos el tipo de dato genérico "K" que puede ser cualquier tipo de dato y para el campo "valor" que utilizará un tipo de dato genérico "T" que puede ser también cualquier tipo de dato y que incluso puede ser diferente de "K" (id). Sin embargo para el peso sabemos que siempre será i32.
Ahora vamos a crear la estructura que contendrá un vector de "records" esta nueva estructura contendrá un vector que utiliza datos genéricos porque sabemos que serán de tipo "Record" (Rust permite utilizar estructuras definidas con datos genéricos! 😃) Por lo que debemos hacerle saber a la nueva estructura
// src/main.rs
struct Record<K, T> {
id: K,
valor: T,
peso: i32,
}
struct Records<K, T> {
datos: Vec<Record<K, T>>,
}
Ahora a la estructura "Records" vamos a crearle un método que nos permita pasarle como parámetro un id que puede ser de cualquier tipo o nos deberá devolver el campo "peso" de ese record.
Al igual que con la declaración de la estructura, también es importante declarar los tipos genéricos que estaremos utilizando en la implementación de los métodos. Al igual que en el ejemplo del proyecto anterior donde utilizamos un ">" y debíamos colocar el PartialOrd, en este caso necesitaremos agregarle a "K" un PartialEq. También regresaremos un -1 si no encuentra el id que buscamos.
Por motivos de ejemplo, utilizaremos un ciclo "for", pero recuerda que existen los iteradores que ayudan a hacer la vida más fácil 😉
Iteradores en Rust Con Ejemplos
Después del impl
colocaremos la definición de los generics types y del PartialEq
// src/main.rs
struct Record<K, T> {
id: K,
valor: T,
peso: i32,
}
struct Records<K, T> {
datos: Vec<Record<K, T>>,
}
impl<K: std::cmp::PartialEq, T> Records<K, T> {
fn obtener_peso_por_id(&self, id: K) -> i32 {
for record in &self.datos {
if record.id == id {
return record.peso;
}
}
// Si no encuentra nada, entonces regresamos un -1
-1
}
}
Al ver el ejemplo anterior, podrás observar que los tipos genéricos, se pueden utilizar en conjunto con los tipos fijos como i32 y que también pueden utilizarse en estructuras, ahora agreguemos una función main y declaremos algunos "records" con id's y valores diferentes y veamos si es posible obtener su peso sin que el compilador de Rust se queje de nosotros 😅
// src/main.rs
struct Record<K, T> {
id: K,
valor: T,
peso: i32,
}
struct Records<K, T> {
datos: Vec<Record<K, T>>,
}
impl<K: std::cmp::PartialEq, T> Records<K, T> {
fn obtener_peso_por_id(&self, id: K) -> i32 {
for record in &self.datos {
if record.id == id {
return record.peso;
}
}
// Si no encuentra nada, entonces regresamos un -1
-1
}
}
fn main() {
// Record con id i32 y valor String
let record1 = Record {
id: 1,
valor: String::from("Este es un valor String del valor 1"),
peso: 5,
};
println!("Valor Record 1: {}", record1.valor);
// Record con id String y valor numerico
let _record2 = Record {
id: String::from("id1"),
valor: 1000,
peso: 1,
};
}
Si corremos ahora nuestro programa veremos que termina con algunos warnings porque no estamos utilizando todos los métodos, pero termina sin errores, a pesar que los records que hemos definido, contienen tipos diferentes
cargo run
Ahora vamos a aplicar nuestro método para obtener el peso, aquí un detalle importante, para poder ejecutar bien nuestro programa, el array de records que vayamos a evaluar deben tener todos la misma estructura, es decir tanto el id como el valor deben coincidir en el array.
Esto es un poco contradictorio porque buscamos una forma super genérica de utilizar nuestro método y esto es posible hacerlo pero requerirá definir un tipo de dato custom y cambiar un poco nuestro programa, por motivos de alcance de esta publicación no lo vamos a realizar porque lo único que queremos es corroborar que podemos tener datos genéricos y en una base de datos estructurada, sería muy difícil encontrarnos con ese escenario donde los campos tengan diferentes tipos de datos incluso si se tratase de una tabla No-Sql por lo que lo más probable es que nuestra estructura Record se utilice para diferentes tablas.
Si quieres que creemos una publicación donde hagamos super genérica la búsqueda sin importar el tipo, déjalo en la caja de comentarios o en el formulario de contacto 😉
Por el momento con utilizar datos con una misma estructura nos bastará.
// src/main.rs
struct Record<K, T> {
id: K,
valor: T,
peso: i32,
}
struct Records<K, T> {
datos: Vec<Record<K, T>>,
}
impl<K: std::cmp::PartialEq, T> Records<K, T> {
fn obtener_peso_por_id(&self, id: K) -> i32 {
for record in &self.datos {
if record.id == id {
return record.peso;
}
}
// Si no encuentra nada, entonces regresamos un -1
-1
}
}
fn main() {
// Record con id i32 y valor String
let record1 = Record {
id: 1,
valor: String::from("Este es un valor String del valor 1"),
peso: 5,
};
println!("Valor Record 1: {}", record1.valor);
// Record con id String y valor numerico
let _record2 = Record {
id: String::from("id1"),
valor: 1000,
peso: 1,
};
// Record con id y valor como string
let record3 = Record {
id: String::from("id2"),
valor: String::from("valor como string nuevamente"),
peso: 7,
};
// Records simulando ser de Una misma tabla en una base de datos
// // Record con id y valor como string
let record4 = Record {
id: String::from("id3"),
valor: String::from("valor como string nuevamente"),
peso: 2,
};
let records = Records {
datos: vec![record3, record4],
};
let resultado_busqueda = records.obtener_peso_por_id("id2".to_string());
println!("peso del resultado busqueda: {}", resultado_busqueda);
}
Como podemos ver, record3 y record4 simulan ser de una misma tabla, si tenemos otros records donde por ejemplo tanto el id como el valor son de tipo f32 igual podríamos aplicar el método de búsqueda a esos records. Ahora si corremos nuestro programa vamos a observar que encuentra el peso utilizando generic types
cargo run
Felicidades, hemos aprendido a utilizar generic types para evitar duplicar código en Rust! 🥳
Para ver el código completo del ejemplo, puedes dar click a este enlace.
Si esta publicación te ha sido de utilidad o te ha sido interesante, compártela con tus amigos y en tus redes sociales, no te olvides de seguirme en twitter!
Si tienes alguna duda o algún tema sobre Rust que te gustaría creara una publicación déjalo en la caja de comentarios al final de este post o utilizando el formulario de contacto.
println!("hasta la próxima");