wandres.dev
GENÉRICOS · código para cualquier tipo

Structs y enums genéricos: contenedores para cualquier tipo

Un `struct Pila<T>` guarda cualquier tipo sin duplicar código, y `Option<T>` o `Result<T, E>` no son magia del compilador, sino enums genéricos ordinarios. El parámetro de tipo en las definiciones de datos y en sus bloques `impl`.

⏱ 17 min

Los genéricos no se detienen en las funciones. Un struct o un enum también pueden parametrizarse sobre un tipo, y eso es lo que convierte una estructura de datos en un contenedor universal: una Pila que guarda i32, o String, o tus propios tipos, con una única definición. Lo revelador es que dos de los tipos que ya usas a diario —Option y Result— no son construcciones especiales del lenguaje: son enums genéricos corrientes, escritos en la biblioteca estándar con exactamente las herramientas que vas a ver aquí.

🎯 Al terminar esta lección sabrás
  • Definir un struct con un parámetro de tipo y almacenar en él valores de tipo T.
  • Escribir un bloque impl<T> cuyos métodos operan sobre ese T.
  • Reconocer Option<T> y Result<T, E> como enums genéricos de la std, no como magia.
  • Usar varios parámetros de tipo independientes en una misma definición.

Un struct parametrizado: struct Pila

Para que un struct sea genérico, declaras el parámetro tras su nombre y lo usas en los tipos de sus campos:

struct Pila<T> {
    elementos: Vec<T>,
}

El <T> tras Pila declara el parámetro; el campo elementos: Vec<T> lo usa. Cada instancia fija T a un tipo concreto y no lo mezcla: una Pila<i32> guarda enteros y solo enteros, una Pila<String> guarda cadenas. Son tipos distintos, con disposiciones de memoria distintas, aunque compartan la misma definición fuente.

let mut enteros: Pila<i32> = Pila { elementos: Vec::new() };
enteros.elementos.push(7);

let mut textos: Pila<String> = Pila { elementos: Vec::new() };
textos.elementos.push(String::from("hola"));

El bloque impl genérico

Los métodos viven en un bloque impl, y como operan sobre un Pila<T> genérico, el bloque también debe ser genérico. Fíjate en que T aparece dos veces y cada una significa algo distinto:

impl<T> Pila<T> {
    fn nueva() -> Self {
        Pila { elementos: Vec::new() }
    }

    fn apila(&mut self, x: T) {
        self.elementos.push(x);
    }

    fn desapila(&mut self) -> Option<T> {
        self.elementos.pop()
    }

    fn cima(&self) -> Option<&T> {
        self.elementos.last()
    }

    fn largo(&self) -> usize {
        self.elementos.len()
    }
}

El impl<T> declara el parámetro para el bloque; el Pila<T> dice sobre qué tipo implementamos. Si olvidas el primero, el compilador cree que T es un tipo concreto que no existe y falla con E0412. Repara además en los tipos de retorno: desapila devuelve Option<T> porque la pila puede estar vacía, y cima devuelve Option<&T> —una referencia prestada al elemento superior, sin sacarlo—. El T genérico se propaga con naturalidad por toda la API.

let mut p = Pila::nueva();
p.apila(1);
p.apila(2);
assert_eq!(p.cima(), Some(&2));
assert_eq!(p.desapila(), Some(2));
assert_eq!(p.largo(), 1);

Enums genéricos: Option y Result por dentro

Un enum también admite parámetros de tipo, y sus variantes pueden transportarlos. Los dos ejemplos que ya conoces son, literalmente, así de simples:

enum Option<T> {
    None,
    Some(T),
}

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Esto merece detenerse. Option<T> no es una palabra clave ni un caso especial cableado en el compilador: es un enum genérico con dos variantes, una vacía (None) y otra que lleva un T (Some). Result<T, E> tiene dos parámetros independientes: el tipo del éxito y el tipo del error. Cuando escribes Result<u16, String>, estás instanciando T = u16 y E = String. Todo lo que aprendiste sobre ellos en el nivel 7 —el match, los combinadores, el operador ?— opera sobre enums genéricos ordinarios.

Definir el tuyo es igual de sencillo. Este enum genérico modela cero, uno o dos valores del mismo tipo:

enum Varios<T> {
    Ninguno,
    Uno(T),
    Dos(T, T),
}

let a: Varios<i32> = Varios::Dos(3, 7);
let b: Varios<String> = Varios::Uno(String::from("solo"));

Las tres variantes comparten el parámetro T: una Varios<i32> solo contiene enteros, sea cual sea la variante. Es el mismo mecanismo de Option, aplicado a tu dominio.

ℹ️
Genérico no es lo mismo que dinámico

Que Pila<T> acepte cualquier tipo no significa que una sola pila mezcle tipos. T se fija al construir la instancia y queda inmutable: una Pila<i32> jamás aceptará un String. La flexibilidad está en la definición (vale para todos), no en cada valor (cada uno es de un solo tipo). Es polimorfismo en compilación, no contenedores heterogéneos como las listas de Python.

Varios parámetros de tipo

Nada te limita a un solo parámetro. Un struct puede relacionar dos tipos independientes:

struct Par<A, B> {
    izquierda: A,
    derecha: B,
}

impl<A, B> Par<A, B> {
    fn nuevo(izquierda: A, derecha: B) -> Self {
        Par { izquierda, derecha }
    }

    fn intercambia(self) -> Par<B, A> {
        Par { izquierda: self.derecha, derecha: self.izquierda }
    }
}

A y B se eligen por separado en cada uso: Par<i32, String>, Par<bool, f64>. Y intercambia demuestra algo elegante: puede devolver un Par<B, A>, un tipo distinto del receptor, transformando la relación entre los parámetros. Solo debes declarar en impl<A, B> todos los parámetros que el bloque vaya a mencionar.

flowchart TB
def[Definicion generica struct Pila de T] --> i[Instancia Pila de i32]
def --> s[Instancia Pila de String]
def --> m[Instancia Pila de MiTipo]
i --> nota[Cada instancia fija T y no lo mezcla]
s --> nota
m --> nota
style def fill:#cba6f7,color:#11111b
style nota fill:#89b4fa,color:#11111b
La seguridad de Rust vive en la biblioteca, no en el compilador

Aquí se esconde una de las decisiones de diseño más profundas de Rust. En muchos lenguajes, la ausencia de null seguro y el manejo de errores son características del compilador: Optional en Java, las excepciones, tipos anulables con sintaxis especial. En Rust no. Option<T> y Result<T, E> son enums genéricos que tú mismo podrías haber escrito con las herramientas de este nivel: un enum, un par de parámetros de tipo, unas variantes. El lenguaje no cablea la seguridad frente a null; te da tres piezas —enums, genéricos y pattern matching— y la seguridad emerge de combinarlas en la biblioteca. Esto tiene una consecuencia liberadora: no hay una casta de tipos mágicos, privilegiados, inalcanzables para ti. Vec<T>, HashMap<K, V>, Box<T>, Rc<T> son todos, sin excepción, structs y enums genéricos construidos con lo mismo que tienes tú. Cuando entiendes struct Pila<T>, entiendes la maquinaria de la que están hechas todas las abstracciones de Rust. El poder no está reservado al compilador; está en tus manos, y la elegancia del lenguaje consiste precisamente en necesitar tan pocas primitivas para construir tanto. Un genérico más un enum más match es, literalmente, el cimiento sobre el que descansa la fama de seguridad de Rust.

📝
Lo esencial de los contenedores genéricos

Declaras el parámetro tras el nombre del struct o enum y lo usas en campos o variantes; el bloque impl<T> le da métodos. Cada instancia fija T y no lo mezcla. Option<T>, Result<T, E> y todas las colecciones de la std son exactamente esto: enums y structs genéricos ordinarios, sin ninguna magia del compilador.

⚔️ Construye tus propios contenedores
  1. Completa la Pila<T> con un método esta_vacia(&self) -> bool y pruébala construyendo una Pila<String>.
  2. Escribe enum Arbol<T> con variantes Hoja(T) y Nodo que contenga dos subárboles en un Box; razona por qué el Box es necesario.
  3. Define struct Envoltura<T> { valor: T } con un método mapa que reciba una función y devuelva una Envoltura de otro tipo.
  4. Escribe la definición completa de Option<T> de memoria y compárala con la de la std; comprueba que no hay ninguna palabra clave especial.
  5. Instancia Par<A, B> con dos tipos distintos, llama a intercambia y verifica en el tipo del resultado que los parámetros se han invertido.