Traits y genéricos en Rust

Aprende Rust → Lección 8

Esta lección salda varias deudas. En la lección 4 quedó dicho que un enum es un conjunto cerrado y que para dejarlo abierto hacían falta los traits. En la lección 3 aparecieron From y TryFrom sin explicar qué eran. Y los iteradores de la lección 6 funcionan enteros sobre un trait. Un trait es un contrato: una lista de métodos que un tipo promete tener. Los genéricos son la otra mitad, escribir código una sola vez que sirva para cualquier tipo que firme ese contrato.

Un trait es un contrato, no un padre

La diferencia con la herencia es de forma, no de detalle. En una jerarquía, un tipo tiene un padre y recibe de él lo que recibe. Con traits no hay parentesco: hay tipos sueltos que firman los contratos que les convienen, tantos como quieran.

Herencia frente a traits HERENCIA Animal Perro Gato Un solo padre, y lo que heredas viene dado. TRAITS Articulo Tuit Informe trait Resumen el contrato Tipos sin parentesco, el mismo contrato. Un tipo no es «hijo de» nada: cumple los contratos que decide, y puede cumplir tantos como quiera.
Por eso en Rust no se pregunta «¿qué es este tipo?» sino «¿qué sabe hacer?».

Declarar un trait es listar las firmas; implementarlo es cumplirlas:

trait Resumen {
    fn autor(&self) -> String;
    fn contenido(&self) -> String;
}

struct Tuit {
    usuario: String,
    texto: String,
}

struct Articulo {
    titulo: String,
    firma: String,
    cuerpo: String,
}

impl Resumen for Tuit {
    fn autor(&self) -> String {
        format!("@{}", self.usuario)
    }
    fn contenido(&self) -> String {
        self.texto.clone()
    }
}

impl Resumen for Articulo {
    fn autor(&self) -> String {
        self.firma.clone()
    }
    fn contenido(&self) -> String {
        format!("{}: {}", self.titulo, self.cuerpo)
    }
}

Tuit y Articulo no tienen nada que ver entre sí y no comparten ni un campo. Lo único que comparten es que ambos saben decir quién los firma y qué dicen, que es justo lo que el contrato pide.

Métodos con cuerpo por defecto

Un trait puede traer implementaciones hechas, que cada tipo puede aceptar tal cual o reemplazar. Esto es lo que hace que un trait con muchos métodos siga siendo cómodo de implementar:

trait Resumen {
    fn autor(&self) -> String;
    fn contenido(&self) -> String;

    fn resumir(&self) -> String {
        let c = self.contenido();
        let corto: String = c.chars().take(30).collect();
        format!("{} — {corto}…", self.autor())
    }
}

struct Nota {
    texto: String,
}

impl Resumen for Nota {
    fn autor(&self) -> String {
        String::from("anónimo")
    }
    fn contenido(&self) -> String {
        self.texto.clone()
    }
}

fn main() {
    let n = Nota {
        texto: String::from("Los traits describen capacidades, no identidades"),
    };
    println!("{}", n.resumir());
}

Nota implementa dos métodos y obtiene el tercero gratis. Y fíjate en que el método por defecto llama a self.contenido() sin saber cómo está implementado: el contrato garantiza que existirá. Así funciona Iterator, el de la lección 6: tú escribes next() y recibes map, filter, sum y decenas más, todos construidos sobre esa única función.

Genéricos: escribirlo una vez

Un genérico es un hueco para un tipo que se rellena al usar la función. Pero Rust no acepta un hueco vacío: hay que decir qué debe saber hacer ese tipo, y eso se expresa con un límite.

fn mayor<T: PartialOrd>(lista: &[T]) -> &T {
    let mut may = &lista[0];
    for x in lista {
        if x > may {
            may = x;
        }
    }
    may
}

fn main() {
    let numeros = vec![34, 50, 25, 100, 65];
    let letras = vec!['y', 'm', 'a', 'q'];

    println!("mayor número: {}", mayor(&numeros));
    println!("mayor letra: {}", mayor(&letras));
}

El T: PartialOrd dice «cualquier tipo, siempre que se pueda comparar con mayor que». Sin ese límite el código no compilaría, porque dentro de la función se usa > y el compilador no puede saber si un tipo cualquiera lo admite.

Una diferencia importante con las plantillas de C++. Allí el error aparece al instanciar la plantilla, a veces con páginas de mensajes. En Rust el límite se comprueba al compilar la función genérica, aunque no la llame nadie todavía: si el cuerpo hace algo que el límite no garantiza, el error sale ahí mismo y además te dice cuál falta, con un consider restricting type parameter `T` with trait `PartialOrd`. El contrato se verifica en los dos lados.

Tres formas de escribir lo mismo

Los límites se pueden poner en tres sitios, y las tres son habituales. Cuál usar es cuestión de legibilidad:

use std::fmt::Display;

fn uno<T: Display>(x: T) {
    println!("{x}");
}

fn dos(x: impl Display) {
    println!("{x}");
}

fn tres<T>(x: T)
where
    T: Display,
{
    println!("{x}");
}

fn main() {
    uno(1);
    dos("dos");
    tres(3.0);
}

La primera es la clásica. La segunda, con impl Trait en el parámetro, es la más corta y se usa cuando el tipo solo aparece una vez. La tercera, con where, es la que se impone en cuanto hay dos o tres límites, porque saca el ruido de la línea de la firma y la deja legible.

Estático o dinámico: la decisión que importa

Hay dos maneras de usar un trait para aceptar varios tipos, y no son intercambiables. Entender la diferencia es lo que separa escribir Rust de pelearse con él.

Despacho estático y despacho dinámico DESPACHO ESTÁTICO fn ver<T: Resumen>(x: T) versión para Tuit versión para Informe El compilador escribe una copia por cada tipo usado. No queda nada por decidir en ejecución: cuesta lo mismo que escribirlo. DESPACHO DINÁMICO Vec<Box<dyn Resumen>> una sola copia tabla de métodos Una sola copia del código. En ejecución se consulta a qué tipo hay que llamar, y por eso sí se pueden mezclar tipos distintos. La regla práctica: genéricos por defecto, y dyn cuando necesites guardar tipos distintos en la misma colección.
Los genéricos se resuelven al compilar; dyn aplaza la decisión hasta la ejecución, a cambio de una indirección.

El motivo por el que hacen falta las dos se ve con un caso concreto. Una función genérica con un límite no sirve para guardar tipos distintos juntos, porque al compilar T se fija en uno solo. Para una colección mixta hay que usar dyn:

trait Figura {
    fn area(&self) -> f64;
    fn nombre(&self) -> &str;
}

struct Circulo { radio: f64 }
struct Cuadrado { lado: f64 }

impl Figura for Circulo {
    fn area(&self) -> f64 { std::f64::consts::PI * self.radio * self.radio }
    fn nombre(&self) -> &str { "círculo" }
}

impl Figura for Cuadrado {
    fn area(&self) -> f64 { self.lado * self.lado }
    fn nombre(&self) -> &str { "cuadrado" }
}

fn describir(f: &impl Figura) {
    println!("{} de área {:.2}", f.nombre(), f.area());
}

fn main() {
    describir(&Circulo { radio: 1.0 });
    describir(&Cuadrado { lado: 2.0 });

    let figuras: Vec<Box<dyn Figura>> = vec![
        Box::new(Circulo { radio: 1.0 }),
        Box::new(Cuadrado { lado: 2.0 }),
        Box::new(Circulo { radio: 0.5 }),
    ];

    let total: f64 = figuras.iter().map(|f| f.area()).sum();
    println!("{} figuras, área total {:.2}", figuras.len(), total);
}

El Vec<Box<dyn Figura>> es la respuesta a la deuda que dejó la lección 4. Un enum te habría obligado a enumerar de antemano todas las figuras posibles; con un trait, cualquiera puede escribir su propio tipo, implementar Figura y meterlo en ese vector sin tocar tu código. Esa es exactamente la diferencia entre un conjunto cerrado y uno abierto.

Por qué hace falta el Box. Un Circulo y un Cuadrado ocupan distinto número de bytes, y los elementos de un Vec tienen que medir todos lo mismo. Box resuelve el problema guardando el valor en el montón y dejando en el vector solo un puntero, que sí mide siempre igual. Cuando veas el error doesn’t have a size known at compile-time, casi siempre falta un Box o un &.

Los traits que ya venías usando

Buena parte de lo que parecía sintaxis del lenguaje en las lecciones anteriores eran traits de la biblioteca estándar:

Trait Qué habilita Dónde apareció
Display Imprimir con {} Lección 7, al dar mensaje a un error propio
Debug Imprimir con {:?} Lección 4, con derive
From, Into Convertir sin que falle Lección 3, entre tipos numéricos
TryFrom Convertir pudiendo fallar Lección 3, con Result
Iterator map, filter y todo lo demás Lección 6
PartialOrd, PartialEq Comparar con < y con == Lección 4, con derive
Default Tipo::default() Lección 4
Error Que un tipo sirva como error Lección 7

Implementar uno de ellos para un tipo propio le da acceso a toda la maquinaria que ya existe. From es el ejemplo más rentable, porque implementarlo regala into() y hace que el operador ? convierta errores por su cuenta:

struct Celsius(f64);
struct Fahrenheit(f64);

impl From<Celsius> for Fahrenheit {
    fn from(c: Celsius) -> Self {
        Fahrenheit(c.0 * 9.0 / 5.0 + 32.0)
    }
}

fn main() {
    let f = Fahrenheit::from(Celsius(100.0));
    println!("{:.1} °F", f.0);

    let otra: Fahrenheit = Celsius(0.0).into();
    println!("{:.1} °F", otra.0);
}

Solo se escribió From, y sin embargo into() funciona en la última línea. Es un regalo de la biblioteca estándar: existe una implementación general que dice que todo lo que se puede convertir con From se puede convertir también con into, en el otro sentido.

La regla del huérfano

Hay un límite que conviene conocer antes de chocar con él: solo puedes implementar un trait para un tipo si al menos uno de los dos es tuyo. Tu trait para un tipo ajeno, sí. Un trait ajeno para tu tipo, sí. Un trait ajeno para un tipo ajeno, no.

La razón es evitar que dos bibliotecas distintas den dos implementaciones del mismo trait para el mismo tipo, dejando al compilador sin forma de elegir. La solución habitual cuando necesitas hacerlo es envolver el tipo ajeno en un struct tupla propio, exactamente la técnica de la lección 4:

use std::fmt;

struct Lista(Vec<String>);

impl fmt::Display for Lista {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let l = Lista(vec![
        String::from("rojo"),
        String::from("verde"),
    ]);
    println!("{l}");
}

Display y Vec son los dos de la biblioteca estándar, así que impl Display for Vec<String> estaría prohibido. Envolviéndolo en Lista, que sí es un tipo propio, la implementación pasa a ser legítima.

En resumen

Lo que quieres hacer Qué usar
Describir una capacidad trait Nombre { … }
Que un tipo la cumpla impl Nombre for Tipo
Dar un método hecho a todos un cuerpo por defecto dentro del trait
Una función para cualquier tipo capaz fn f<T: Trait>(x: T)
Lo mismo, más corto fn f(x: impl Trait)
Varios límites sin ensuciar la firma una cláusula where
Máxima velocidad, un tipo por llamada genéricos: despacho estático
Guardar tipos distintos juntos Box<dyn Trait>
Un conjunto de casos cerrado un enum (lección 4)
Un conjunto que otros amplíen un trait
Implementar un trait ajeno en un tipo ajeno envolverlo en un struct tupla propio

La idea que conviene llevarse es que en Rust los tipos no se organizan en familias, se describen por lo que saben hacer. Un trait no dice qué es un tipo, sino qué se le puede pedir, y esa diferencia es la que permite que una función escrita hoy funcione con tipos que todavía no existen.

Para verlo explicado

Miniatura del vídeo «47.- Curso Rust. Explicación Práctica de Traits, Genéricos y Trait Bounds», del canal Jesús Conde

«47.- Curso Rust. Explicación Práctica de Traits, Genéricos y Trait Bounds», del canal Jesús Conde (11:20). Material de otro canal que recomendamos como complemento, no una producción de decodigo.com. El reproductor solo se carga al pulsar, así que YouTube no recibe nada tuyo hasta entonces. También puedes verlo en YouTube.