Lección 17 de 26 Genéricos, traits y módulos
Tipos genéricos
- mayor_i32 y mayor_char hacen lo mismo: solo cambia el tipo. Si hay un bug, ¡toca arreglarlo dos veces!
- Con un genérico <T> la escribes una sola vez. T: PartialOrd exige que el tipo se pueda comparar con >.
- La misma función sirve para números, caracteres, f64 o &str. Ya los usabas: Vec<T>, Option<T>...
- Al compilar, Rust crea una versión para cada tipo que usas (monomorfización). En ejecución, coste cero.
Imagina una función que encuentra el número más grande de una lista. Funciona perfecta con i32… hasta que necesitas lo mismo con char, f64 o &str. ¿Copias la función cuatro veces cambiando solo el tipo? Los genéricos resuelven justo eso: escribes el código una vez y sirve para muchos tipos.
De hecho, ya los has usado: Vec<T>, Option<T>, Result<T, E> y HashMap<K, V> son genéricos. En esta lección aprenderás a crear los tuyos.
El problema: código duplicado
Estas dos funciones buscan el mayor elemento de una lista, una de números y otra de caracteres:
fn mayor_i32(lista: &[i32]) -> &i32 {
let mut mayor = &lista[0];
for elemento in lista {
if elemento > mayor {
mayor = elemento;
}
}
mayor
}
fn mayor_char(lista: &[char]) -> &char {
let mut mayor = &lista[0];
for elemento in lista {
if elemento > mayor {
mayor = elemento;
}
}
mayor
}
fn main() {
println!("{}", mayor_i32(&[34, 50, 25, 100, 65]));
println!("{}", mayor_char(&['y', 'm', 'a', 'q']));
}Los cuerpos son idénticos: solo cambia el tipo. Si mañana encuentras un bug, tendrás que arreglarlo en dos sitios.
Funciones genéricas
Para escribir una única versión, declara un parámetro de tipo entre < > justo después del nombre de la función. Por convención se llama T (de type) y lo usas donde antes ponías el tipo concreto:
fn mayor<T>(lista: &[T]) -> &T {
let mut mayor = &lista[0];
for elemento in lista {
if elemento > mayor {
mayor = elemento;
}
}
mayor
}
fn main() {
println!("{}", mayor(&[34, 50, 25, 100, 65]));
}Casi, pero no compila: binary operation > cannot be applied to type &T. Y el compilador tiene razón. T puede ser cualquier tipo, también uno que no se pueda comparar. Hay que restringirlo a los tipos ordenables, los que implementan PartialOrd:
fn mayor<T: PartialOrd>(lista: &[T]) -> &T {
let mut mayor = &lista[0];
for elemento in lista {
if elemento > mayor {
mayor = elemento;
}
}
mayor
}
fn main() {
println!("{}", mayor(&[34, 50, 25, 100, 65]));
println!("{}", mayor(&['y', 'm', 'a', 'q']));
println!("{}", mayor(&[1.5, 0.3, 2.7]));
println!("{}", mayor(&["pera", "manzana", "uva"]));
}T: PartialOrd se lee «cualquier tipo T que se pueda comparar con < y >». Es un trait bound: una condición que debe cumplir el tipo. En la próxima lección verás qué son los traits; por ahora quédate con que limitan qué tipos acepta tu función.
Ya lo conocías: La función
parse::<i32>()es genérica. Con::<i32>, la sintaxis turbofish, le dices qué tipo quieres en lugar de dejar que lo deduzca el compilador.
Structs genéricos
Los structs también pueden tener parámetros de tipo. Decláralos tras el nombre y úsalos en los campos:
struct Punto<T> {
x: T,
y: T,
}
fn main() {
let enteros = Punto { x: 5, y: 10 };
let decimales = Punto { x: 1.5, y: 4.0 };
println!("({}, {})", enteros.x, enteros.y);
println!("({}, {})", decimales.x, decimales.y);
}Con un solo T, x e y tienen que ser del mismo tipo. Punto { x: 5, y: 4.0 } no compila: el compilador fija T = i32 al ver el 5 y luego se encuentra un f64. Si quieres permitir tipos distintos, usa dos parámetros:
struct Punto<T, U> {
x: T,
y: U,
}
fn main() {
let p = Punto { x: 5, y: 4.0 };
let q = Punto { x: "norte", y: 'N' };
println!("({}, {}) y ({}, {})", p.x, p.y, q.x, q.y);
}Puedes declarar tantos parámetros como quieras, pero si necesitas muchos, seguramente convenga dividir el tipo en piezas más pequeñas.
Enums genéricos
Ya conoces los dos enums genéricos más importantes de Rust. Fíjate en cómo están definidos:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}Option<T> guarda un valor de cualquier tipo o ninguno. Result<T, E> usa dos parámetros: el tipo del éxito y el del error. Gracias a eso, una sola definición sirve para Option<i32>, Option<String> o Option<Punto<f64>>.
Métodos en tipos genéricos
Para añadir métodos a un tipo genérico, declara también el parámetro justo después de impl. Así le dices a Rust que T es un genérico y no un tipo llamado T:
struct Punto<T> {
x: T,
y: T,
}
impl<T> Punto<T> {
fn x(&self) -> &T {
&self.x
}
}
impl Punto<f64> {
fn distancia_al_origen(&self) -> f64 {
(self.x * self.x + self.y * self.y).sqrt()
}
}
fn main() {
let p = Punto { x: 3.0, y: 4.0 };
let q = Punto { x: 'a', y: 'b' };
println!("p.x = {}, distancia = {}", p.x(), p.distancia_al_origen());
println!("q.x = {}", q.x());
}impl<T> Punto<T>define métodos para todos losPunto, sea cual seaT.impl Punto<f64>define métodos solo paraPunto<f64>.q.distancia_al_origen()no compilaría: no tiene sentido calcular la raíz cuadrada de unoschar.
Monomorfización: genéricos sin coste
¿Hacen los genéricos más lento el programa? No. Al compilar, Rust aplica la monomorfización: mira con qué tipos concretos usas cada genérico y genera una versión especializada para cada uno.
Es como si hubieras escrito tú a mano mayor_i32, mayor_char y mayor_f64, pero sin duplicar código. En ejecución no queda ni rastro de T: el código genérico es exactamente igual de rápido que el específico. Lo mismo pasa con los tipos: para el compilador, Option<i32> y Option<f64> son dos enums distintos.
Abstracciones de coste cero: Es uno de los principios de Rust. Las herramientas para escribir código más limpio, como los genéricos, no deben costar nada en ejecución. El precio se paga al compilar: tarda algo más y el binario puede crecer un poco.
Resumen
| Dónde | Ejemplo |
|---|---|
| Función | fn mayor<T: PartialOrd>(lista: &[T]) -> &T |
| Struct | struct Punto<T> { x: T, y: T } |
| Enum | enum Option<T> { Some(T), None } |
| Métodos | impl<T> Punto<T> { ... } |
| Métodos para un tipo concreto | impl Punto<f64> { ... } |
Hora de practicar
Ejercicios
0/3 resueltos
Reto 1 de 3 Resuelto
menor, que devuelve una referencia al elemento más pequeño de la lista. Tiene que funcionar con números, caracteres y textos, así que añade el trait bound que necesita T.25 a 0.5
Al ejecutar, unos tests ocultos comprueban tus funciones.
Pulsa Ejecutar para compilar y ver aquí la salida de tu programa.