Aprende Rust

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

Traits: comportamiento compartido

  • 5 min de lectura
  • 4 ejercicios
  1. Un trait describe un comportamiento. trait Resumen es un contrato: quien lo cumpla debe tener resumir.
  2. impl Resumen for Noticia e impl Resumen for Post: cada tipo cumple el contrato a su manera.
  3. notificar(item: &impl Resumen) acepta cualquier tipo que implemente Resumen: los dos pasan.
  4. Un tipo sin impl Resumen, como i32, no pasa: el compilador lo rechaza antes de ejecutar nada.
1/4

En la lección anterior escribiste T: PartialOrd para decir «cualquier tipo que se pueda comparar». PartialOrd es un trait: un conjunto de métodos que describe un comportamiento. Cualquier tipo puede implementarlo y, desde ese momento, se puede usar allí donde se pida ese comportamiento.

Si vienes de otros lenguajes, los traits se parecen a las interfaces de Java, TypeScript o Go. Son la pieza que une todo en Rust: genéricos, Display, Clone, iteradores, closures…

Definir un trait

Con trait declaras un nombre y las firmas de sus métodos, sin cuerpo:

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

Cualquier tipo que implemente Resumen tendrá que aportar su propio método resumir.

Implementar un trait

Con impl Trait for Tipo escribes la implementación para un tipo concreto:

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

struct Noticia {
    titular: String,
    autor: String,
}

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

impl Resumen for Noticia {
    fn resumir(&self) -> String {
        format!("{}, por {}", self.titular, self.autor)
    }
}

impl Resumen for Post {
    fn resumir(&self) -> String {
        format!("@{}: {}", self.usuario, self.texto)
    }
}

fn main() {
    let noticia = Noticia {
        titular: String::from("Rust 2024 ya está aquí"),
        autor: String::from("Ferris"),
    };
    let post = Post {
        usuario: String::from("midudev"),
        texto: String::from("Aprendiendo traits"),
    };
    println!("{}", noticia.resumir());
    println!("{}", post.resumir());
}

Cada tipo resume a su manera, pero los dos comparten el mismo método. Hay una regla: para implementar un trait en un tipo, el trait o el tipo tienen que ser tuyos. Puedes implementar Display para tu Noticia, pero no para Vec<T>, porque ni el trait ni el tipo son de tu crate.

Métodos por defecto

Un trait puede dar una implementación por defecto. Los tipos la reciben gratis y pueden sobrescribirla si quieren. Además, un método por defecto puede llamar a otros métodos del trait, incluso a los que no tienen cuerpo:

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

    fn resumir(&self) -> String {
        format!("(Leer más de {}...)", self.autor())
    }
}

struct Post {
    usuario: String,
}

struct Noticia {
    titular: String,
    autor: String,
}

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

impl Resumen for Noticia {
    fn autor(&self) -> String {
        self.autor.clone()
    }

    fn resumir(&self) -> String {
        format!("{}, por {}", self.titular, self.autor())
    }
}

fn main() {
    let post = Post { usuario: String::from("ferris") };
    let noticia = Noticia {
        titular: String::from("Rust 2024 ya está aquí"),
        autor: String::from("Ferris"),
    };
    println!("{}", post.resumir()); // usa el método por defecto
    println!("{}", noticia.resumir()); // usa el suyo propio
}

Traits como parámetros

Lo potente de los traits es escribir funciones que acepten cualquier tipo con un comportamiento. Para ello, usa impl Trait como tipo del parámetro:

fn notificar(item: &impl Resumen) {
    println!("¡Novedad! {}", item.resumir());
}

Es una forma abreviada de escribir una función genérica con un trait bound, como en la lección anterior. Esta versión es equivalente:

fn notificar<T: Resumen>(item: &T) {
    println!("¡Novedad! {}", item.resumir());
}

La versión genérica es útil cuando quieres obligar a que dos parámetros sean del mismo tipo: fn comparar<T: Resumen>(a: &T, b: &T). Con a: &impl Resumen, b: &impl Resumen podrían ser tipos distintos.

Varios traits: + y where

Si el tipo tiene que cumplir varios traits, únelos con +. Esta función necesita comparar (PartialOrd) e imprimir (Display):

use std::fmt::Display;

fn mostrar_mayor<T: Display + PartialOrd>(a: T, b: T) {
    if a >= b {
        println!("El mayor es {a}");
    } else {
        println!("El mayor es {b}");
    }
}

fn main() {
    mostrar_mayor(7, 3);
    mostrar_mayor("pera", "uva");
}

Cuando hay muchos bounds, la firma se vuelve difícil de leer. Con where los mueves debajo y la firma queda limpia:

fn mostrar_mayor<T>(a: T, b: T)
where
    T: Display + PartialOrd,
{
    // ...
}

Devolver impl Trait

También puedes usar impl Trait como tipo de retorno: «devuelvo algo que implementa este trait», sin decir qué tipo exacto:

fn crear_resumen() -> impl Resumen {
    Post { usuario: String::from("ferris") }
}

Ojo: solo funciona si la función devuelve un único tipo concreto. Si según una condición devolviera un Post o una Noticia, no compilaría. Lo verás mucho con closures e iteradores, cuyos tipos son tan largos que es imposible escribirlos.

Display: imprime tus propios tipos

println!("{}", x) funciona con cualquier tipo que implemente el trait std::fmt::Display. Ya lo usaste con tus errores; ahora sabes qué significa ese impl ... for:

use std::fmt;

struct Punto {
    x: i32,
    y: i32,
}

impl fmt::Display for Punto {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Punto { x: 3, y: -2 };
    println!("El punto es {p}");
    let texto = p.to_string();
    println!("Como String: {texto}");
}

De regalo, todo tipo que implementa Display tiene el método to_string().

derive: implementaciones automáticas

Muchos traits tienen una implementación obvia: comparar campo a campo, clonar campo a campo… Para esos, #[derive(...)] la escribe por ti:

#[derive(Debug, Clone, PartialEq)]
struct Color {
    r: u8,
    g: u8,
    b: u8,
}

fn main() {
    let rojo = Color { r: 255, g: 0, b: 0 };
    let copia = rojo.clone();
    println!("{rojo:?}");
    println!("¿Iguales? {}", rojo == copia);
}
TraitQué te da
DebugImprimir con {:?}
CloneDuplicar con .clone()
CopyCopia implícita al asignar, como los enteros (requiere Clone)
PartialEqComparar con == y !=
PartialOrdComparar con <, >, <= y >=
DefaultUn valor por defecto con Default::default()

Display no se puede derivar: cómo se muestra un tipo al usuario lo decides tú.

Trait objects: mezclar tipos con dyn

Un Vec<T> solo guarda elementos de un único tipo. ¿Y si quieres una lista de formas con círculos y rectángulos? Con genéricos no puedes, porque T se fija a un tipo concreto. La solución son los trait objects: Box<dyn Forma> significa «una caja con cualquier valor que implemente Forma».

trait Forma {
    fn area(&self) -> f64;
    fn nombre(&self) -> String;
}

struct Circulo {
    radio: f64,
}

struct Rectangulo {
    ancho: f64,
    alto: f64,
}

impl Forma for Circulo {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radio * self.radio
    }
    fn nombre(&self) -> String {
        String::from("Círculo")
    }
}

impl Forma for Rectangulo {
    fn area(&self) -> f64 {
        self.ancho * self.alto
    }
    fn nombre(&self) -> String {
        String::from("Rectángulo")
    }
}

fn main() {
    let formas: Vec<Box<dyn Forma>> = vec![
        Box::new(Circulo { radio: 1.0 }),
        Box::new(Rectangulo { ancho: 2.0, alto: 3.0 }),
        Box::new(Circulo { radio: 0.5 }),
    ];

    for forma in &formas {
        println!("{}: {:.2}", forma.nombre(), forma.area());
    }
}
  • dyn Forma es «algún tipo que implementa Forma». Cuál exactamente se descubre en ejecución.
  • Box guarda el valor en el heap. Hace falta porque un círculo y un rectángulo ocupan tamaños distintos y un Vec necesita elementos del mismo tamaño. Lo verás a fondo en la lección de smart pointers.

Genéricos o dyn: Con genéricos (impl Trait, <T: Trait>) el compilador genera una versión por tipo y sabe qué método llamar: es lo más rápido. Con dyn Trait la decisión se toma en ejecución, con un coste pequeño, a cambio de poder mezclar tipos. Usa genéricos por defecto y dyn cuando necesites mezclar.

Hora de practicar

Ejercicios

0/4 resueltos

Reto 1 de 4

Implementa el trait Saludar para Persona y para Robot. La persona dice Hola, soy NOMBRE y el robot BIP BOP. UNIDAD ID ACTIVADA.
Salida esperada
Hola, soy Ana
BIP BOP. UNIDAD 42 ACTIVADA

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