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`.
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í.
- Definir un
structcon un parámetro de tipo y almacenar en él valores de tipoT. - Escribir un bloque
impl<T>cuyos métodos operan sobre eseT. - Reconocer
Option<T>yResult<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.
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
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.
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.
- Completa la
Pila<T>con un métodoesta_vacia(&self) -> booly pruébala construyendo unaPila<String>. - Escribe
enum Arbol<T>con variantesHoja(T)yNodoque contenga dos subárboles en unBox; razona por qué elBoxes necesario. - Define
struct Envoltura<T> { valor: T }con un métodomapaque reciba una función y devuelva unaEnvolturade otro tipo. - 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. - Instancia
Par<A, B>con dos tipos distintos, llama aintercambiay verifica en el tipo del resultado que los parámetros se han invertido.