Aprende Rust

Lección 17 de 26 Genéricos, traits y módulos

Tipos genéricos

  • 4 min de lectura
  • 3 ejercicios
  1. mayor_i32 y mayor_char hacen lo mismo: solo cambia el tipo. Si hay un bug, ¡toca arreglarlo dos veces!
  2. Con un genérico <T> la escribes una sola vez. T: PartialOrd exige que el tipo se pueda comparar con >.
  3. La misma función sirve para números, caracteres, f64 o &str. Ya los usabas: Vec<T>, Option<T>...
  4. Al compilar, Rust crea una versión para cada tipo que usas (monomorfización). En ejecución, coste cero.
1/4

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 los Punto, sea cual sea T.
  • impl Punto<f64> define métodos solo para Punto<f64>. q.distancia_al_origen() no compilaría: no tiene sentido calcular la raíz cuadrada de unos char.

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óndeEjemplo
Funciónfn mayor<T: PartialOrd>(lista: &[T]) -> &T
Structstruct Punto<T> { x: T, y: T }
Enumenum Option<T> { Some(T), None }
Métodosimpl<T> Punto<T> { ... }
Métodos para un tipo concretoimpl Punto<f64> { ... }

Hora de practicar

Ejercicios

0/3 resueltos

Reto 1 de 3

Completa la función genérica 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.
Salida esperada
25
a
0.5

Al ejecutar, unos tests ocultos comprueban tus funciones.

src/main.rs
$ cargo run Listo

Pulsa Ejecutar para compilar y ver aquí la salida de tu programa.

¿Algo no cuadra en este reto? Avísanos