Los genéricos son parámetros de tipo para reutilizar código sin fijarlo a un tipo concreto.
Seguramente te has dado cuenta de que ya has estado usando genéricos todo el tiempo.
Option<T>Result<T, E>Vec<T>HashMap<K, V>
Esas letras mayúsculas (T, E, K, V) son Genéricos. Son marcadores de posición (placeholders) que dicen: “Aquí irá un tipo de dato, pero aún no sé cuál es. Me lo dirás cuando uses el código”.
En C++ se llaman Templates. En Java y C#, Generics. La idea es la misma: evitar la duplicación de código.
El problema de la duplicación
Imagina que queremos una función que imprima un valor y lo devuelva (una función identidad).
Si queremos que funcione para números y strings, sin genéricos tendríamos que hacer esto:
fn identidad_i32(x: i32) -> i32 {
println!("El valor es: {}", x);
x
}
fn identidad_string(x: String) -> String {
println!("El valor es: {}", x);
x
}El cuerpo de la función es idéntico. Solo cambia el tipo. Si nos pasara muchas veces, se haria insostenible.
Genéricos en funciones
Para hacerlo genérico, definimos un tipo abstracto, que por convención solemos llamar T (de Type), entre corchetes angulares < > justo después del nombre de la función.
// Declaramos T Usamos T Devolvemos T
// | | |
// v v v
fn identidad<T>( x: T) -> T {
// println!("{}", x); // ⚠️ Ojo: Esto daría error (lo explico abajo)
x
}
fn main() {
let numero = identidad(5); // T es i32
let texto = identidad("Hola"); // T es &str
}¿Por qué println! podría fallar?
Si intentas imprimir x dentro de la función genérica, el compilador se quejará: T doesn’t implement Display.
Rust es muy seguro. Como T puede ser “cualquier cosa” (incluso un tipo que no se puede imprimir), Rust no te deja asumir nada sobre él. Para solucionar esto, necesitaremos Trait Bounds, que veremos en dos artículos.
Genéricos en structs
Los structs son uno de los sitios donde más los usarás. Supón que quieres representar un punto de coordenadas, pero a veces necesitas precisión entera (píxeles) y otras veces flotante (gráficos vectoriales).
struct Punto<T> {
x: T,
y: T,
}
fn main() {
// T se infiere como i32
let entero = Punto { x: 5, y: 10 };
// T se infiere como f64
let flotante = Punto { x: 1.0, y: 4.0 };
}Varios tipos genéricos
Fíjate que en el ejemplo anterior, x e y deben ser del mismo tipo T.
Si intentas hacer Punto { x: 5, y: 4.0 }, fallará.
Si necesitamos que sean tipos distintos, declaramos múltiples genéricos:
struct PuntoHibrido<T, U> {
x: T,
y: U,
}
fn main() {
let p = PuntoHibrido { x: 5, y: 4.0 }; // T=i32, U=f64
}Genéricos en métodos (impl)
Cuando implementamos métodos para un struct genérico, la sintaxis puede parecer un poco extraña al principio. Debemos declarar <T> justo después de impl para que Rust sepa que estamos hablando de un tipo genérico, no de un struct concreto llamado “T”.
struct Punto<T> {
x: T,
y: T,
}
// "Para cualquier tipo T, implementamos métodos en Punto<T>"
impl<T> Punto<T> {
fn x(&self) -> &T {
&self.x
}
}Bloques impl para tipos concretos
Rust nos permite hacer algo genial: implementar métodos solo para un tipo específico.
// Estos métodos existen para CUALQUIER Punto
impl<T> Punto<T> {
fn nuevo(x: T, y: T) -> Self { Self { x, y } }
}
// Estos métodos SOLO existen si T es f32
impl Punto<f32> {
fn distancia_al_origen(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
fn main() {
let p_int = Punto { x: 5, y: 10 };
// p_int.distancia_al_origen(); // ❌ Error: no existe para i32
let p_float = Punto { x: 3.0, y: 4.0 };
println!("{}", p_float.distancia_al_origen()); // ✅ 5.0
}Rendimiento y monomorfización
Esta es la pregunta del millón: ¿los genéricos añaden despacho dinámico por sí mismos? En el uso habitual de genéricos de Rust, no: el compilador genera versiones concretas mediante monomorfización.
Rust utiliza un proceso llamado Monomorfización (mono = uno, morph = forma).
En tiempo de compilación, Rust busca todos los lugares donde usas Punto<T>. Si ve que usas Punto<i32> y Punto<f64>, el compilador copia y pega internamente el código del struct dos veces, reemplazando T por el tipo real.
El resultado puede optimizarse como si hubiéramos escrito versiones específicas a mano. No hay una capa de despacho dinámico implícita, aunque generar más código puede aumentar el tamaño del binario y afectar a la caché.
- 🟢 Ventaja: Rendimiento máximo.
- 🟡 Desventaja: El binario compilado puede ser un poco más grande (porque el código se duplica para cada tipo distinto que uses).