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”.
El problema: un Vec, tipos distintos
Sección titulada «El problema: un Vec, tipos distintos»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.57El cuadrado tiene un área de 9.00El círculo tiene un área de 3.14Leamos Box<dyn Shape> pieza por pieza:
dyn Shapesignifica “algún tipo que implementaShape; sabremos cuál cuando el programa se ejecute”.dynviene de dynamic (dinámico).Boxguarda el valor en el heap (el montón de memoria) y se queda con un puntero hacia él (ya conocisteBoxen la página de punteros inteligentes). Todos losBoxocupan lo mismo, sin importar lo que lleven dentro. Por eso ahora elVecestá 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.0Tenemos 2 figurasUna 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 estático y dinámico
Sección titulada «Dispatch estático y dinámico»“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 tipofn static_area(shape: &impl Shape) -> f64 { shape.area()}
// Dispatch dinámico: una sola función; el método se busca al ejecutarfn 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.
Devolver Box<dyn Trait> desde una función
Sección titulada «Devolver Box<dyn Trait> desde una función»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.5Creé un cuadrado de lado 2Es 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.
Un ejemplo práctico: enviar notificaciones
Sección titulada «Un ejemplo práctico: enviar notificaciones»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(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!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.
Los límites: compatibilidad con dyn
Sección titulada «Los límites: compatibilidad con dyn»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 undyn, Rust no sabe qué esSelf, 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 traitEl 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.
Errores comunes
Sección titulada «Errores comunes»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.
Práctica
Sección titulada «Práctica»1. Un zoológico en un solo Vec
Sección titulada «1. Un zoológico en un solo Vec»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 guauLa vaca dice muuEl pato dice cuac2. Elegir un saludo al ejecutar
Sección titulada «2. Elegir un saludo al ejecutar»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!Aquí el saludo en inglés se queda en inglés a propósito: es justo lo que debe decir el tipo English.
Resumen
Sección titulada «Resumen»- Un
Vecsolo puede guardar un tipo. Para mezclar tipos, usa un trait object comoBox<dyn Trait>. Box<dyn Trait>es dueño del valor;&dyn Traitlo toma prestado. Un trait object siempre vive detrás de un puntero.- Los genéricos e
impl Traitusan dispatch estático (rápido, se decide al compilar).dyn Traitusa 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
Selfni de métodos genéricos (salvo que llevenwhere Self: Sized).
Comprueba lo que aprendiste
Sección titulada «Comprueba lo que aprendiste»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
Para leer más
Sección titulada «Para leer más»- Using Trait Objects to Abstract over Shared Behavior — El libro de Rust (en inglés): el capítulo oficial, que construye una lista de componentes de interfaz gráfica con
Box<dyn Draw>. - Returning Traits with dyn — Rust by Example (en inglés): un ejemplo corto y ejecutable de una función que devuelve
Box<dyn Trait>. - Trait objects — The Rust Reference (en inglés): las reglas exactas de los tipos
dyn Trait, incluidos los lifetimes y los traits marcadores extra comoSend. - Dyn compatibility — The Rust Reference (en inglés): la lista completa de reglas que debe cumplir un trait para usarse con
dyn. - La palabra clave
dyn— documentación de std (en inglés): una explicación rápida dedyn, las vtables y lo que ganas y pierdes frente a los genéricos.