En esta página

Generics: abstracciones de costo cero

14 min lectura TextoCap. 4 — Abstracciones

Generics: escribir código reutilizable

Los generics (tipos genéricos) permiten escribir funciones, structs y enums que funcionan con múltiples tipos sin duplicar código. En Rust, los generics son una característica central del lenguaje y se usan en toda la biblioteca estándar: Vec<T>, Option<T>, Result<T, E>, HashMap<K, V>.

Funciones genéricas

El problema sin generics: necesitas una función por tipo.

// Sin generics: código duplicado
fn maximo_i32(lista: &[i32]) -> i32 {
    let mut max = lista[0];
    for &n in lista {
        if n > max { max = n; }
    }
    max
}

fn maximo_f64(lista: &[f64]) -> f64 {
    let mut max = lista[0];
    for &n in lista {
        if n > max { max = n; }
    }
    max
}

Con generics, una sola función maneja cualquier tipo comparable:

// T es el parámetro de tipo genérico
// PartialOrd: T debe poder compararse con > y <
// Copy: T debe poder copiarse (para asignar max = lista[0])
fn maximo<T: PartialOrd + Copy>(lista: &[T]) -> T {
    let mut max = lista[0];
    for &item in lista.iter() {
        if item > max {
            max = item;
        }
    }
    max
}

fn main() {
    let enteros = vec![34, 50, 25, 100, 65];
    println!("Máximo entero: {}", maximo(&enteros));
    
    let decimales = [2.5, 1.7, 9.3, 4.1, 7.8];
    println!("Máximo decimal: {}", maximo(&decimales));
    
    let caracteres = ['y', 'm', 'a', 'q'];
    println!("Máximo char: {}", maximo(&caracteres));
}

Structs genéricos

#[derive(Debug, Clone)]
struct Pila<T> {
    elementos: Vec<T>,
    capacidad_maxima: usize,
}

impl<T> Pila<T> {
    fn nueva(capacidad: usize) -> Self {
        Pila {
            elementos: Vec::with_capacity(capacidad),
            capacidad_maxima: capacidad,
        }
    }
    
    fn empujar(&mut self, valor: T) -> Result<(), &'static str> {
        if self.elementos.len() >= self.capacidad_maxima {
            return Err("Pila llena");
        }
        self.elementos.push(valor);
        Ok(())
    }
    
    fn sacar(&mut self) -> Option<T> {
        self.elementos.pop()
    }
    
    fn cima(&self) -> Option<&T> {
        self.elementos.last()
    }
    
    fn esta_vacia(&self) -> bool {
        self.elementos.is_empty()
    }
    
    fn len(&self) -> usize {
        self.elementos.len()
    }
}

// Implementación específica para tipos que implementan Display
impl<T: std::fmt::Display> Pila<T> {
    fn mostrar(&self) {
        print!("[Cima] ");
        for el in self.elementos.iter().rev() {
            print!("{el} ");
        }
        println!("[Base]");
    }
}

fn main() {
    let mut pila_nums: Pila<i32> = Pila::nueva(5);
    pila_nums.empujar(1).unwrap();
    pila_nums.empujar(2).unwrap();
    pila_nums.empujar(3).unwrap();
    pila_nums.mostrar();
    
    println!("Sacar: {:?}", pila_nums.sacar());
    println!("Cima: {:?}", pila_nums.cima());
    
    let mut pila_textos: Pila<&str> = Pila::nueva(3);
    pila_textos.empujar("hola").unwrap();
    pila_textos.empujar("mundo").unwrap();
    pila_textos.mostrar();
}

Enums genéricos

Ya conoces los enums genéricos más importantes de la std:

// Así están definidos en la biblioteca estándar:
// enum Option<T> { Some(T), None }
// enum Result<T, E> { Ok(T), Err(E) }

// Tú también puedes crear enums genéricos:
#[derive(Debug)]
enum Arbol<T> {
    Hoja(T),
    Nodo {
        valor: T,
        izquierdo: Box<Arbol<T>>,
        derecho: Box<Arbol<T>>,
    },
}

impl<T: std::fmt::Display + PartialOrd> Arbol<T> {
    fn contiene(&self, objetivo: &T) -> bool {
        match self {
            Arbol::Hoja(v) => v == objetivo,
            Arbol::Nodo { valor, izquierdo, derecho } => {
                valor == objetivo
                || izquierdo.contiene(objetivo)
                || derecho.contiene(objetivo)
            }
        }
    }
}

fn main() {
    let arbol = Arbol::Nodo {
        valor: 10,
        izquierdo: Box::new(Arbol::Nodo {
            valor: 5,
            izquierdo: Box::new(Arbol::Hoja(3)),
            derecho: Box::new(Arbol::Hoja(7)),
        }),
        derecho: Box::new(Arbol::Hoja(15)),
    };
    
    println!("¿Contiene 7? {}", arbol.contiene(&7));
    println!("¿Contiene 6? {}", arbol.contiene(&6));
}

Bounds múltiples con where

Cuando los bounds se vuelven complejos, la cláusula where mejora la legibilidad:

use std::fmt::{Debug, Display};
use std::ops::Add;

// Sin where (difícil de leer):
fn procesar<T: Display + Debug + Clone + PartialOrd>(valor: T) -> T { valor }

// Con where (más claro):
fn serializar<T>(coleccion: &[T]) -> String
where
    T: Display + Debug + Clone,
{
    coleccion.iter()
        .map(|item| format!("{item}"))
        .collect::<Vec<_>>()
        .join(", ")
}

// Múltiples parámetros genéricos con bounds distintos
fn mezclar<A, B, C>(a: A, b: B) -> C
where
    A: Into<C>,
    B: Into<C>,
    C: Add<Output = C>,
{
    a.into() + b.into()
}

fn main() {
    let numeros = vec![1, 2, 3, 4, 5];
    println!("{}", serializar(&numeros));
    
    let palabras = ["hola", "mundo", "rust"];
    println!("{}", serializar(&palabras));
    
    // mezclar i32 + i32 → i64
    // (simplificado — en la práctica From/Into tienen restricciones)
    let resultado: i64 = mezclar(10_i32, 20_i32);
    println!("Resultado: {resultado}");
}

Monomorphization: por qué los generics son gratis

Cuando el compilador compila código genérico, genera una versión especializada para cada tipo concreto que uses. Este proceso se llama monomorphization:

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

fn main() {
    let n = identidad(42_i32);       // El compilador genera identidad_i32
    let s = identidad("hola");       // El compilador genera identidad_str
    let f = identidad(3.14_f64);     // El compilador genera identidad_f64
    
    println!("{n} {s} {f}");
}

El binario resultante contiene código optimizado específico para cada tipo — exactamente como si hubieras escrito tres funciones separadas. No hay overhead de vtable, no hay boxing, no hay costos en tiempo de ejecución.

Esto contrasta con lenguajes como Java/C# donde los generics usan boxing y pueden tener overhead, o con Go donde los generics también usan monomorphization pero en una versión más reciente del lenguaje.

Turbofish: especificar tipos explícitamente

A veces el compilador no puede inferir el tipo genérico. Usa la sintaxis "turbofish" ::<T>:

fn main() {
    // Sin turbofish — el compilador necesita contexto
    let numeros: Vec<i32> = Vec::new();
    
    // Con turbofish en el método
    let v = Vec::<i32>::new();
    
    // En llamadas a funciones genéricas
    let n = "42".parse::<i32>().unwrap();
    let f = "3.14".parse::<f64>().unwrap();
    
    // En collect
    let cuadrados = (1..=5).map(|n| n * n).collect::<Vec<_>>();
    println!("{:?}", cuadrados);
    
    // En from
    let s = String::from("hola");
    let v2 = Vec::<u8>::from(s.as_bytes());
    println!("{:?}", v2);
}

Generics en bloques impl: implementaciones condicionales

Puedes implementar métodos solo para ciertos tipos genéricos:

use std::fmt::Display;

struct Envuelto<T> {
    valor: T,
}

impl<T> Envuelto<T> {
    fn nuevo(valor: T) -> Self {
        Envuelto { valor }
    }
    
    fn obtener(&self) -> &T {
        &self.valor
    }
}

// Solo disponible cuando T implementa Display
impl<T: Display> Envuelto<T> {
    fn mostrar(&self) {
        println!("Envuelto: {}", self.valor);
    }
}

// Solo disponible cuando T implementa Clone
impl<T: Clone> Envuelto<T> {
    fn duplicar(&self) -> (T, T) {
        (self.valor.clone(), self.valor.clone())
    }
}

fn main() {
    let w = Envuelto::nuevo(42);
    w.mostrar();                           // disponible: i32 impl Display
    let (a, b) = w.duplicar();            // disponible: i32 impl Clone
    println!("Duplicado: {a}, {b}");
    
    let w2 = Envuelto::nuevo(vec![1, 2, 3]);
    // w2.mostrar(); // Vec<i32> implementa Debug pero no Display por defecto
    let (v1, v2) = w2.duplicar();         // disponible: Vec impl Clone
    println!("{:?} {:?}", v1, v2);
}

Con un sólido entendimiento de generics y traits, estás listo para la lección de manejo de errores — donde Result<T, E> y el operador ? brillan con toda su potencia.

Monomorphization: cero overhead
Los generics en Rust son cero costo en tiempo de ejecución. El compilador genera una versión especializada de la función para cada tipo concreto que uses — esto se llama monomorphization. maximo::<i32> y maximo::<f64> son funciones completamente distintas en el binario, cada una optimizada para su tipo.
Turbofish ::<T> para desambiguar
Cuando el compilador no puede inferir el tipo genérico, usa la sintaxis turbofish: let v = "42".parse::<i32>().unwrap(); o vec![].into_iter().collect::<Vec<i32>>(). Las llaves angulares van después del nombre de la función/método.
rust
// Función genérica: T debe implementar PartialOrd y Copy
fn maximo<T: PartialOrd + Copy>(lista: &[T]) -> T {
    let mut max = lista[0];
    for &item in lista.iter() {
        if item > max {
            max = item;
        }
    }
    max
}

// Struct genérico
#[derive(Debug)]
struct Par<T, U> {
    primero: T,
    segundo: U,
}

impl<T: std::fmt::Display, U: std::fmt::Display> Par<T, U> {
    fn nuevo(primero: T, segundo: U) -> Self {
        Par { primero, segundo }
    }

    fn mostrar(&self) {
        println!("({}, {})", self.primero, self.segundo);
    }
}

// Implementación solo para tipos que son iguales
impl<T: std::fmt::Display + PartialOrd> Par<T, T> {
    fn mayor(&self) -> &T {
        if self.primero > self.segundo { &self.primero } else { &self.segundo }
    }
}

fn main() {
    let enteros = vec![34, 50, 25, 100, 65];
    println!("Máximo: {}", maximo(&enteros));

    let decimales = [2.5, 1.7, 9.3, 4.1];
    println!("Máximo: {}", maximo(&decimales));

    let p1 = Par::nuevo("hola", 42);
    p1.mostrar();

    let p2 = Par::nuevo(10_i32, 20_i32);
    println!("Mayor: {}", p2.mayor());
}