14 - Genéricos en Rust - escribiendo código flexible con pokémon

14 - Genéricos en Rust - escribiendo código flexible con pokémon

¡Bienvenidos de nuevo a Rustaceo.es! En nuestra aventura continua por el mundo de Rust y Pokémon, hoy exploraremos uno de los conceptos más poderosos y útiles en Rust: los genéricos. ¡Pero cuidado! Este es uno de los artículos mas avanzados del curso, y por ende, de los más largos. Afróntalo con paciencia y tómate tu tiempo para asimilar todos los conceptos.

¿Qué son los genéricos? #

Los genéricos son una forma de escribir código que no está limitado a un tipo específico de datos. En lugar de escribir funciones y estructuras para un tipo de dato en particular, los genéricos nos permiten definirlos para un conjunto de tipos que cumplen ciertos requisitos.

Beneficios de los Genéricos:

  • Reutilización de Código: Evitan la duplicación al permitir que el mismo código funcione con diferentes tipos.
  • Flexibilidad: Facilitan la adaptación del código a nuevos tipos de datos.
  • Seguridad de Tipo: Mantienen las verificaciones en tiempo de compilación, garantizando que el código sea seguro y correcto.

Sintaxis básica de los genéricos #

En Rust, los genéricos se denotan utilizando parámetros de tipo dentro de los símbolos < y >.

Ejemplo sencillo: una función genérica #

fn identidad<T>(x: T) -> T {
    x
}

let numero = identidad(42);
let texto = identidad("Hola, mundo!");
  • T es un parámetro de tipo genérico.
  • La función identidad acepta un valor de cualquier tipo T y lo devuelve sin cambios.

Usando genéricos con structs #

Podemos definir structs genéricas que puedan contener diferentes tipos de datos.

Ejemplo: coordenadas genéricas #

Imaginemos que queremos representar las coordenadas de un Pokémon en un mapa.

struct Coordenada<T> {
    x: T,
    y: T,
}

let punto_entero = Coordenada { x: 10, y: 20 };
let punto_flotante = Coordenada { x: 10.5, y: 20.5 };
  • Aquí, Coordenada<T> es una struct genérica que puede tener campos de cualquier tipo T.

Genéricos con enums #

También podemos utilizar genéricos en enums.

Ejemplo: respuesta de una operación #

enum Resultado<T, E> {
    Exito(T),
    Error(E),
}
  • Este enum genérico es similar a Result en la biblioteca estándar de Rust.
  • T representa el tipo de valor en caso de éxito.
  • E representa el tipo de error en caso de fallo.

Aplicando genéricos en el mundo pokémon #

Ahora, apliquemos estos conceptos en ejemplos relacionados con Pokémon.

Ejemplo 1: una bolsa que puede contener diferentes objetos #

Queremos crear una estructura Bolsa que pueda contener diferentes tipos de objetos: pociones, bayas, Poké Balls, etc.

struct Bolsa<T> {
    items: Vec<T>,
}

impl<T> Bolsa<T> {
    fn nueva() -> Self {
        Bolsa { items: Vec::new() }
    }

    fn agregar(&mut self, item: T) {
        self.items.push(item);
    }

    fn obtener(&self, indice: usize) -> Option<&T> {
        self.items.get(indice)
    }
}

Usando la Bolsa:

struct Pocion {
    nombre: String,
    curacion: u16,
}

struct PokeBall {
    nombre: String,
    tasa_captura: f32,
}

fn main() {
    let mut bolsa_pociones = Bolsa::<Pocion>::nueva();
    let mut bolsa_pokeballs = Bolsa::<PokeBall>::nueva();

    bolsa_pociones.agregar(Pocion {
        nombre: String::from("Super Poción"),
        curacion: 50,
    });

    bolsa_pokeballs.agregar(PokeBall {
        nombre: String::from("Ultra Ball"),
        tasa_captura: 0.8,
    });

    if let Some(pocion) = bolsa_pociones.obtener(0) {
        println!("Tienes una {} que cura {} puntos.", pocion.nombre, pocion.curacion);
    }

    if let Some(pokeball) = bolsa_pokeballs.obtener(0) {
        println!("Tienes una {} con tasa de captura {}.", pokeball.nombre, pokeball.tasa_captura);
    }
}

Ejemplo 2: comparando niveles de pokémon #

Queremos crear una función genérica que compare dos valores y devuelva el mayor.

fn mayor<T: PartialOrd>(a: T, b: T) -> T {
    if a > b {
        a
    } else {
        b
    }
}

fn main() {
    let nivel_item1 = 25;
    let nivel_item2 = 15;

    let nivel_mayor = mayor(nivel_item1, nivel_item2);
    println!("El nivel mayor es {}.", nivel_mayor);
}
  • Nota: El parámetro de tipo T está restringido por el trait PartialOrd para poder utilizar el operador >.

Restricciones con traits #

Los traits en Rust definen funcionalidades que un tipo debe implementar. Al usar genéricos, a menudo necesitamos especificar que los tipos genéricos implementen ciertos traits para garantizar que nuestro código funcione correctamente.

Sintaxis de restricción con traits #

fn funcion_generica<T: Trait>(parametro: T) {
    // Código que utiliza las funcionalidades definidas en Trait
}

Ejemplo: imprimir información de un pokémon #

Queremos crear una función genérica que pueda imprimir cualquier dato que implemente el trait Debug.

use std::fmt::Debug;

fn mostrar_info<T: Debug>(item: T) {
    println!("{:?}", item);
}

fn main() {
    let item1 = Pokémon {
        nombre: String::from("Item1"),
        tipo: String::from("Eléctrico"),
        nivel: 25,
    };

    mostrar_info(item1);
}

Para que esto funcione, debemos asegurarnos de que Pokémon implemente el trait Debug.

#[Derive(debug)]
struct Pokémon {
    nombre: String,
    tipo: String,
    nivel: u8,
}

Genéricos en implementaciones y traits #

Podemos utilizar genéricos en las implementaciones de structs y en la definición de traits.

Ejemplo: implementación genérica de un método #

Volvamos a nuestra struct Coordenada<T> y añadamos un método que calcule la distancia al origen.

impl<T> Coordenada<T>
where
    T: Copy + Into<f64>,
{
    fn distancia_al_origen(&self) -> f64 {
        let x = self.x.into();
        let y = self.y.into();
        (x.powi(2) + y.powi(2)).sqrt()
    }
}

fn main() {
    let punto = Coordenada { x: 3.0, y: 4.0 };
    let distancia = punto.distancia_al_origen();
    println!("La distancia al origen es {}.", distancia);
}
  • Utilizamos una cláusula where para especificar que T debe implementar Copy e Into<f64>.
  • Esto nos permite convertir x e y a f64 y realizar operaciones matemáticas.

Genéricos y traits asociados #

Los traits asociados permiten definir tipos y funciones dentro de traits que pueden ser implementados de manera específica para cada tipo.

Ejemplo: definiendo un trait para ataques #

trait Atacante {
    type Movimiento;

    fn atacar(&self, movimiento: Self::Movimiento);
}

Implementamos el trait para Pokémon:

impl Atacante for Pokémon {
    type Movimiento = Movimiento;

    fn atacar(&self, movimiento: Movimiento) {
        println!("{} usa {}.", self.nombre, movimiento.nombre);
    }
}
  • Aquí, Movimiento podría ser una struct o enum que represente un movimiento de ataque.

Monomorfización: ¿qué sucede en tiempo de compilación? #

En Rust, los genéricos son monomorfizados en tiempo de compilación. Esto significa que el compilador genera código específico para cada tipo utilizado con genéricos, sin incurrir en costos de rendimiento en tiempo de ejecución.

Ventajas:

  • Eficiencia: No hay penalización de rendimiento por usar genéricos.
  • Seguridad de Tipo: Los errores se detectan en tiempo de compilación.

Ejemplo avanzado: sistema de intercambio de pokémon #

Imaginemos que queremos crear un sistema que permita intercambiar Pokémon u objetos entre entrenadores, y queremos que sea genérico para diferentes tipos.

Definimos una estructura genérica para el intercambio #

struct Intercambio<T> {
    item1: T,
    item2: T,
}

impl<T> Intercambio<T> {
    fn nuevo(item1: T, item2: T) -> Self {
        Intercambio { item1, item2 }
    }

    fn ejecutar(self) -> (T, T) {
        (self.item2, self.item1)
    }
}

Usando el Intercambio:

fn main() {
    let item1 = Pokémon {
        nombre: String::from("Item1"),
        tipo: String::from("Eléctrico"),
        nivel: 25,
    };

    let item2 = Pokémon {
        nombre: String::from("Item2"),
        tipo: String::from("Fuego"),
        nivel: 15,
    };

    let intercambio = Intercambio::nuevo(item1, item2);
    let (nuevo_elemento1, nuevo_elemento2) = intercambio.ejecutar();

    println!("Ahora tienes a {} y {}.", nuevo_elemento1.nombre, nuevo_elemento2.nombre);
}

Este sistema funciona para cualquier tipo T, siempre y cuando sea el mismo para ambos items.

Limitaciones y consideraciones #

  • Uso Excesivo de Genéricos: Aunque los genéricos son poderosos, un uso excesivo puede complicar el código y hacerlo menos legible.
  • Restricciones Necesarias: Es importante aplicar restricciones adecuadas con traits para garantizar que los tipos genéricos cumplan con los requisitos necesarios.
  • Complejidad en Mensajes de Error: Los mensajes de error pueden volverse más complejos cuando se trabaja con genéricos, especialmente en código más avanzado.

Conclusión #

Los genéricos en Rust son una herramienta esencial para escribir código flexible, reutilizable y seguro. Al utilizarlos, podemos crear funciones y estructuras que trabajan con una variedad de tipos, reduciendo la duplicación y mejorando la eficiencia.

A través de ejemplos del mundo Pokémon, hemos visto cómo aplicar los genéricos en situaciones prácticas, desde bolsas que pueden contener diferentes objetos hasta sistemas de intercambio versátiles.

Al dominar los genéricos, estarás mejor preparado para enfrentar desafíos más complejos y escribir código Rust idiomático y robusto.


¿Te ha resultado útil este artículo? ¡Te animo a que experimentes incorporando genéricos en tus propios proyectos de Rust! Si tienes preguntas o quieres compartir tus experiencias, ¡deja un comentario abajo!

Próxima vez en Rustaceo: Exploraremos “Traits en Rust: Compartiendo Comportamiento entre Tipos”, continuando nuestra emocionante aventura en el mundo de Rust.

¡Hasta la próxima, entrenadores y entusiastas de Rust!