Ir al contenido

Trait objects (dyn) en Rust

En la página de traits escribiste funciones que aceptan “cualquier tipo que implemente Speak”. En la de genéricos viste cómo Rust crea una copia distinta de una función genérica para cada tipo. Las dos cosas funcionan muy bien cuando conoces el tipo al compilar. Pero ¿y si quieres una sola lista que guarde muchos tipos distintos?

Para eso existe el trait object (objeto de trait). Imagina a una profesora de música con un aula llena de estudiantes. Uno toca el piano, otra el violín, otro la batería. La profesora no necesita saber qué instrumento tiene cada uno: solo dice “¡a tocar!” y cada estudiante toca a su manera. Un trait object permite que tu código haga lo mismo: “No sé el tipo exacto, pero sé que puede hacer esto”.

Supón que tienes dos figuras y quieres guardarlas en un mismo Vec:

struct Circle { radius: f64 }
struct Square { side: f64 }
fn main() {
let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }];
}

Esto no compila:

error[E0308]: mismatched types
--> src/main.rs:5:47
|
5 | let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }];
| ^^^^^^^^^^^^^^^^^^^^ expected `Circle`, found `Square`

El compilador dice que los tipos no coinciden: esperaba un Circle y encontró un Square. Un Vec solo puede guardar un tipo. Todos sus elementos deben ocupar lo mismo en memoria, para que Rust pueda colocarlos uno detrás de otro. Circle y Square son tipos distintos, así que no pueden compartir un Vec normal.

Box<dyn Trait>: una lista de figuras distintas

Sección titulada «Box<dyn Trait>: una lista de figuras distintas»

La solución tiene dos partes. Primero, dales a las dos figuras un trait en común. Segundo, mete cada figura en un Box y di que el tipo es Box<dyn Shape>:

trait Shape {
fn name(&self) -> String;
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn name(&self) -> String { String::from("círculo") }
fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }
}
impl Shape for Square {
fn name(&self) -> String { String::from("cuadrado") }
fn area(&self) -> f64 { self.side * self.side }
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 2.0 }),
Box::new(Square { side: 3.0 }),
Box::new(Circle { radius: 1.0 }),
];
for shape in &shapes {
println!("El {} tiene un área de {:.2}", shape.name(), shape.area());
}
}

Salida:

El círculo tiene un área de 12.57
El cuadrado tiene un área de 9.00
El círculo tiene un área de 3.14
▶ Pruébalo tú mismo

Leamos Box<dyn Shape> pieza por pieza:

  • dyn Shape significa “algún tipo que implementa Shape; sabremos cuál cuando el programa se ejecute”. dyn viene de dynamic (dinámico).
  • Box guarda el valor en el heap (el montón de memoria) y se queda con un puntero hacia él (ya conociste Box en la página de punteros inteligentes). Todos los Box ocupan lo mismo, sin importar lo que lleven dentro. Por eso ahora el Vec está contento.

El valor dentro de la caja (junto con un puntero oculto que dice “este es un Circle”) es lo que llamamos trait object.

&dyn Trait: tomar prestado en lugar de usar una caja

Sección titulada «&dyn Trait: tomar prestado en lugar de usar una caja»

No siempre necesitas un Box. Si los valores ya viven en otro sitio, también sirve una referencia. &dyn Shape significa “una referencia prestada a algún tipo que implementa Shape”:

trait Shape {
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }
}
impl Shape for Square {
fn area(&self) -> f64 { self.side * self.side }
}
fn print_area(shape: &dyn Shape) {
println!("Área: {:.1}", shape.area());
}
fn main() {
let circle = Circle { radius: 1.0 };
let square = Square { side: 4.0 };
print_area(&circle);
print_area(&square);
let both: Vec<&dyn Shape> = vec![&circle, &square];
println!("Tenemos {} figuras", both.len());
}

Salida:

Área: 3.1
Área: 16.0
Tenemos 2 figuras
▶ Pruébalo tú mismo

Una regla sencilla: usa Box<dyn Trait> cuando la lista deba ser dueña de sus valores, y &dyn Trait cuando solo necesites tomarlos prestados un rato.

“Dispatch” solo significa “decidir a qué método llamar”. Rust puede hacerlo de dos maneras.

El dispatch estático ocurre con los genéricos y con impl Trait. El compilador conoce el tipo exacto, así que crea una copia especial de la función para cada tipo (como viste en la página de genéricos). La llamada va directa al método correcto. Es muy rápido.

El dispatch dinámico ocurre con dyn Trait. El compilador no conoce el tipo exacto. Por eso cada trait object lleva una pequeña tabla oculta (llamada vtable) que dice “para este valor, area está aquí”. Mientras el programa se ejecuta, Rust busca el método en esa tabla y luego lo llama. Esa búsqueda tiene un costo extra muy pequeño.

// Dispatch estático: Rust crea una copia de esta función por cada tipo
fn static_area(shape: &impl Shape) -> f64 {
shape.area()
}
// Dispatch dinámico: una sola función; el método se busca al ejecutar
fn dynamic_area(shape: &dyn Shape) -> f64 {
shape.area()
}

Las dos funciones dan el mismo resultado para la misma figura. La única diferencia es cómo encuentra Rust el método area correcto. Entonces, ¿cuál deberías elegir?

Usa genéricos / impl Trait cuando… Usa dyn Trait cuando…
Cada llamada trabaja con un tipo conocido Necesitas mezclar tipos distintos en una colección
Quieres el código más rápido posible Decides el tipo al ejecutar (por ejemplo, según lo que escribe el usuario)
No te importa que el programa sea un poco más grande Quieres menos código y compilaciones más rápidas

Un buen punto de partida si estás empezando: usa primero genéricos o impl Trait. Recurre a dyn cuando te encuentres con el problema de “tipos distintos en una sola lista”. No te preocupes demasiado por la diferencia de velocidad: en la mayoría de los programas nunca la notarás.

A veces una función tiene que decidir al ejecutarse qué tipo devolver. Podrías probar con -> impl Shape, pero eso solo funciona cuando la función devuelve siempre el mismo tipo. Si cada rama devuelve un tipo distinto, devuelve un Box<dyn Shape>:

trait Shape {
fn describe(&self) -> String;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn describe(&self) -> String { format!("un círculo de radio {}", self.radius) }
}
impl Shape for Square {
fn describe(&self) -> String { format!("un cuadrado de lado {}", self.side) }
}
fn make_shape(kind: &str, size: f64) -> Box<dyn Shape> {
if kind == "circle" {
Box::new(Circle { radius: size })
} else {
Box::new(Square { side: size })
}
}
fn main() {
let a = make_shape("circle", 1.5);
let b = make_shape("square", 2.0);
println!("Creé {}", a.describe());
println!("Creé {}", b.describe());
}

Salida:

Creé un círculo de radio 1.5
Creé un cuadrado de lado 2
▶ Pruébalo tú mismo

Es un patrón habitual en funciones “fábrica”, sistemas de plugins y código que depende de la configuración: en cualquier lugar donde el tipo exacto dependa de datos que solo ves cuando el programa se ejecuta.

Esto se parece más a una app real. Tu programa tiene que avisar a un usuario, pero cada usuario eligió canales distintos: email, SMS o ambos. Cada canal es su propio tipo, y todos comparten el trait Notifier:

trait Notifier {
fn send(&self, message: &str) -> String;
}
struct Email { address: String }
struct Sms { phone: String }
impl Notifier for Email {
fn send(&self, message: &str) -> String {
format!("Email a {}: {}", self.address, message)
}
}
impl Notifier for Sms {
fn send(&self, message: &str) -> String {
format!("SMS a {}: {}", self.phone, message)
}
}
fn notify_all(channels: &[Box<dyn Notifier>], message: &str) {
for channel in channels {
println!("{}", channel.send(message));
}
}
fn main() {
let channels: Vec<Box<dyn Notifier>> = vec![
Box::new(Email { address: String::from("[email protected]") }),
Box::new(Sms { phone: String::from("+1-555-0100") }),
];
notify_all(&channels, "¡Tu pedido ya fue enviado!");
}

Salida:

Email a [email protected]: ¡Tu pedido ya fue enviado!
SMS a +1-555-0100: ¡Tu pedido ya fue enviado!
▶ Pruébalo tú mismo

Lo mejor: si más adelante añades un notificador Push, escribes un struct nuevo y un impl Notifier for Push. La función notify_all no cambia en absoluto.

No todos los traits pueden convertirse en trait objects. Un trait tiene que ser dyn compatible (compatible con dyn; en libros y blogs más antiguos lo verás como object safety). Las reglas completas son largas, pero dos cubren casi todos los casos reales:

  • Un método no puede devolver Self. (Detrás de un dyn, Rust no sabe qué es Self, así que no puede saber cuánto ocupa el valor devuelto).
  • Un método no puede tener sus propios parámetros de tipo genéricos, como fn process<T>(&self, item: T).

Esto es lo que pasa si rompes la primera regla:

trait Shape {
fn area(&self) -> f64;
fn duplicate(&self) -> Self;
}
// ... más adelante:
let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];
error[E0038]: the trait `Shape` is not dyn compatible
|
14 | let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];
| ^^^^^ `Shape` is not dyn compatible
|
1 | trait Shape {
| ----- this trait is not dyn compatible...
2 | fn area(&self) -> f64;
3 | fn duplicate(&self) -> Self;
| ^^^^ ...because method `duplicate` references the `Self` type in its return type
= help: consider moving `duplicate` to another trait

El error es claro y útil: te dice que Shape no es compatible con dyn porque el método duplicate usa Self en su tipo de retorno, y te sugiere moverlo a otro trait. Tus opciones son pasar ese método a un trait distinto o añadirle where Self: Sized. Eso le dice a Rust “este método solo existe en tipos reales y conocidos, no en trait objects”:

trait Shape {
fn area(&self) -> f64;
fn duplicate(&self) -> Self where Self: Sized;
}

Por esta misma razón algunos traits estándar, como Clone, no se pueden usar como dyn Clone: clone() devuelve Self.

Olvidar el Box. Es tentador escribir Vec<dyn Shape>:

let shapes: Vec<dyn Shape> = Vec::new();
error[E0277]: the size for values of type `dyn Shape` cannot be known at compilation time
--> src/main.rs:3:17
|
3 | let shapes: Vec<dyn Shape> = Vec::new();
| ^^^^^^^^^^^^^^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `dyn Shape`

El compilador se queja de que no puede conocer el tamaño de dyn Shape al compilar. Un dyn Shape podría tener cualquier tamaño: un Square pequeño o un struct grande con muchos campos. Rust necesita un tamaño fijo, así que un trait object siempre tiene que vivir detrás de un puntero: Box<dyn Shape>, &dyn Shape o Rc<dyn Shape>.

Usar dyn en todas partes. Si una función solo recibe un tipo cada vez, impl Trait o los genéricos son más sencillos y más rápidos (en la página de Traits avanzados hay más sobre impl Trait). Guarda dyn para cuando de verdad necesites mezclar tipos.

Define un trait Animal con fn name(&self) -> String y fn sound(&self) -> String. Impleméntalo para Dog, Cow y Duck, mete uno de cada en un Vec<Box<dyn Animal>> y recórrelo con un bucle para imprimir lo que dice cada animal. Pista: los structs pueden estar vacíos (struct Dog;), y cada uno entra con Box::new(...).

Ver solución
trait Animal {
fn name(&self) -> String;
fn sound(&self) -> String;
}
struct Dog;
struct Cow;
struct Duck;
impl Animal for Dog {
fn name(&self) -> String { String::from("El perro") }
fn sound(&self) -> String { String::from("guau") }
}
impl Animal for Cow {
fn name(&self) -> String { String::from("La vaca") }
fn sound(&self) -> String { String::from("muu") }
}
impl Animal for Duck {
fn name(&self) -> String { String::from("El pato") }
fn sound(&self) -> String { String::from("cuac") }
}
fn main() {
let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cow), Box::new(Duck)];
for animal in &zoo {
println!("{} dice {}", animal.name(), animal.sound());
}
}

Salida:

El perro dice guau
La vaca dice muu
El pato dice cuac
▶ Pruébalo tú mismo

Define un trait Greeter con fn hello(&self, name: &str) -> String, y dos tipos: English y Spanish. Escribe fn make_greeter(lang: &str) -> Box<dyn Greeter>, que devuelva Spanish para "es" y English para cualquier otro código. Saluda a "Mia" con cada código de ["en", "es", "fr"]. Pista: las dos ramas del if tienen que devolver el mismo tipo, así que mete cada una en un Box::new(...).

Ver solución
trait Greeter {
fn hello(&self, name: &str) -> String;
}
struct English;
struct Spanish;
impl Greeter for English {
fn hello(&self, name: &str) -> String {
format!("Hello, {name}!")
}
}
impl Greeter for Spanish {
fn hello(&self, name: &str) -> String {
format!("¡Hola, {name}!")
}
}
fn make_greeter(lang: &str) -> Box<dyn Greeter> {
if lang == "es" {
Box::new(Spanish)
} else {
Box::new(English)
}
}
fn main() {
for lang in ["en", "es", "fr"] {
let greeter = make_greeter(lang);
println!("{lang}: {}", greeter.hello("Mia"));
}
}

Salida:

en: Hello, Mia!
es: ¡Hola, Mia!
fr: Hello, Mia!
▶ Pruébalo tú mismo

Aquí el saludo en inglés se queda en inglés a propósito: es justo lo que debe decir el tipo English.

  • Un Vec solo puede guardar un tipo. Para mezclar tipos, usa un trait object como Box<dyn Trait>.
  • Box<dyn Trait> es dueño del valor; &dyn Trait lo toma prestado. Un trait object siempre vive detrás de un puntero.
  • Los genéricos e impl Trait usan dispatch estático (rápido, se decide al compilar). dyn Trait usa dispatch dinámico (una búsqueda en la vtable al ejecutar).
  • Devuelve Box<dyn Trait> cuando una función elige entre tipos distintos al ejecutarse.
  • Solo los traits dyn compatible pueden ser trait objects: nada de métodos que devuelvan Self ni de métodos genéricos (salvo que lleven where Self: Sized).

Comprueba lo aprendido

1. ¿Por qué no puedes meter un Circle y un Square en el mismo Vec normal?

2. ¿Qué le dice a Rust el dyn de Box<dyn Shape>?

3. ¿Cuál de estos usa dispatch estático?

4. ¿Por qué Vec<dyn Shape> (sin Box) da error?

5. ¿Qué método impide que un trait sea dyn compatible?

Puntuación: 0 / 5