wandres.dev
TRAIT OBJECTS · dispatch dinámico

Object safety: qué traits pueden ser trait objects

No todo trait puede convertirse en un dyn Trait. Las reglas de la object safety —renombrada dyn compatibility en el Rust actual— y por qué existen: sin métodos genéricos, sin Self por valor, sin funciones sin receptor. Cada restricción se deduce de cómo funciona la vtable, y where Self Sized rescata los traits.

⏱ 17 min

No todo trait puede convertirse en un dyn Trait. Intenta un Box<dyn Clone> y el compilador te frena en seco. Las reglas que deciden qué traits sí y cuáles no se llamaban históricamente object safety, y Rust las renombró hace poco a dyn compatibility (compatibilidad con dyn). No son caprichos: cada regla es una consecuencia inevitable de cómo funciona la vtable. Si entendiste la tabla de métodos de la lección anterior, estas restricciones dejarán de parecer arbitrarias y se volverán casi obvias.

🎯 Al terminar esta lección sabrás
  • Entender por qué algunos traits no pueden ser trait objects.
  • Conocer las reglas principales: sin métodos genéricos, sin Self por valor, sin funciones sin receptor.
  • Conectar cada regla con una limitación concreta de la vtable.
  • Rescatar un trait usando where Self: Sized para excluir los métodos problemáticos.

Por qué no todo trait sirve

Recuerda qué es una llamada dinámica: buscar un puntero a función en una tabla y saltar a él, pasándole un puntero a datos como self. Todo lo que un trait object sabe hacer tiene que caber en esa mecánica. Un trait es dyn compatible —antes object safe— cuando todos sus métodos pueden expresarse como una entrada en esa tabla. Si algún método no encaja, no puede haber vtable, y por tanto no puede haber dyn Trait.

trait Constructor {
    fn nueva() -> Self;              // no recibe self...
    fn combinar(&self, otro: Self);  // ...y toma Self por valor
}

// Esto NO compila: Constructor no es dyn compatible.
// let x: Box<dyn Constructor> = /* ... */;

Las reglas de la dyn compatibility

Las condiciones son varias, pero las que encontrarás en la práctica son tres, y todas se leen mejor como “qué NO puede tener un método despachable”:

🚫

Sin métodos genéricos

fn metodo<T>(&self, x: T) no cabe: una entrada de la vtable es UN puntero a función, pero un método genérico serían infinitas funciones, una por cada T.

📦

Sin Self por valor

fn consumir(self) o -> Self exigen conocer el tamaño de Self, que el trait object borró. No se puede pasar ni devolver algo cuyo tamaño se desconoce.

🔑

Sin funciones sin receptor

fn nueva() -> Self no tiene self: sin receptor no hay puntero gordo del que sacar la vtable, y además devuelve Self.

El ejemplo canónico con el que todo el mundo tropieza es Clone:

// La firma de Clone es, en esencia:
trait Clone {
    fn clone(&self) -> Self;   // devuelve Self por valor
}

// Por eso esto NO compila:
// let cosas: Vec<Box<dyn Clone>> = vec![];  // Clone no es dyn compatible

clone devuelve Self por valor; quien recibe el valor clonado necesitaría saber cuánto ocupa, pero a través de dyn Clone ese tamaño está borrado. La regla no es un capricho del diseño de Clone: es que devolver Self es, literalmente, irrepresentable tras el borrado de tipo.

De dónde salen las reglas: la vtable manda

Cada restricción es la sombra que proyecta el borrado de tipo. Vale la pena verlas como teoremas, no como prohibiciones:

  • Métodos genéricos. La vtable tiene un número fijo de ranuras, una por método. Un fn map<U>(&self, f: F) necesitaría una ranura por cada U posible —infinitas—, y no existe un único puntero a función que las represente. Sin puntero, no hay entrada.
  • Self por valor. Para pasar o devolver un Self por valor, el llamador debe conocer su tamaño y colocarlo en la pila. Pero dyn Trait es ?Sized: el tamaño se olvidó. &self y &mut self sí valen, porque una referencia siempre mide una palabra, conozcas o no el tipo. Y self: Box<Self> también vale, porque un Box es un puntero de tamaño fijo.
  • Funciones asociadas sin self. Self::nueva() no tiene receptor: no hay puntero gordo del que extraer la vtable, así que ni siquiera se sabe qué tabla consultar. Son métodos que solo tienen sentido sobre un tipo conocido en compilación.

Un ejemplo concreto del primer punto, el más sutil:

trait Serializar {
    fn escribir<W: std::io::Write>(&self, dst: &mut W); // metodo generico
}

// NO compila como trait object: escribir<W> no tiene una unica direccion.
// let s: Box<dyn Serializar> = /* ... */;

El truco idiomático cuando necesitas algo así es borrar también el genérico: cambia <W: Write> por un &mut dyn Write, y el método recupera una sola firma despachable. Es dispatch dinámico anidado dentro del método, pero rescata la compatibilidad del trait entero.

flowchart TB
t[Un trait] --> g[Algun metodo es generico]
g -->|si| no[No es dyn compatible]
g -->|no| s[Algun metodo usa Self por valor o no tiene self]
s -->|si y sin acotar| no
s -->|acotado con Self Sized| ok
s -->|no| ok[Es dyn compatible y admite dyn Trait]
style no fill:#f38ba8,color:#11111b
style ok fill:#a6e3a1,color:#11111b

Rescatar un trait con where Self: Sized

Un método problemático no tiene por qué condenar al trait entero. Si marcas ese método con where Self: Sized, le dices al compilador que solo existe para tipos de tamaño conocido, y con ello queda excluido de la vtable. El trait vuelve a ser dyn compatible; simplemente, ese método no se podrá llamar a través de dyn, solo sobre tipos concretos.

trait Registro {
    fn escribir(&self, msg: &str);   // despachable: va a la vtable

    fn por_defecto() -> Self         // problematico: devuelve Self y no tiene self...
    where
        Self: Sized;                 // ...pero excluido del objeto, asi que no estorba
}

struct Consola;
impl Registro for Consola {
    fn escribir(&self, msg: &str) { println!("{msg}"); }
    fn por_defecto() -> Self { Consola }
}

fn main() {
    let r: Box<dyn Registro> = Box::new(Consola); // OK: Registro es dyn compatible
    r.escribir("hola");                           // OK: escribir esta en la vtable
    // r.por_defecto();                           // ERROR: excluido del trait object
    let _c = Consola::por_defecto();              // OK sobre el tipo concreto
}

Es el mismo mecanismo por el que muchos traits de la biblioteca estándar ofrecen métodos “de conveniencia” acotados con Self: Sized sin renunciar a ser usables como trait objects.

💡
Rust 2024 ya permite el upcasting de trait objects

Desde su estabilización reciente, el compilador admite el trait upcasting: si declaras trait Sub: Super, puedes convertir un &dyn Sub en un &dyn Super directamente. La vtable de Sub incluye la de Super, así que la conversión es de coste cero —solo reapunta a una sección de la misma tabla—. Es la pieza que faltaba para modelar jerarquías de capacidades con trait objects, y hoy funciona sin trucos ni métodos puente.

📝
El nombre cambió: de object safety a dyn compatibility

Durante años estas reglas se llamaron object safety y los diagnósticos decían “the trait is not object safe”. Rust las renombró a dyn compatibility: hoy el compilador dice “the trait is not dyn compatible”, y la documentación habla de traits dyn compatible. El concepto es idéntico; solo cambió el nombre para que describa lo que de verdad significa —“¿se puede usar tras dyn?”— en vez de sugerir una vaga noción de seguridad.

Las reglas no prohíben: revelan qué sobrevive al olvido del tipo

La reacción instintiva ante la dyn compatibility es leerla como una lista de prohibiciones arbitrarias que hay que memorizar. Es justo lo contrario: es la descripción exacta de qué operaciones sobreviven cuando borras el tipo concreto. Un trait object es un pacto —“olvido cuál eres, pero recuerdo cómo actúas”—, y ese pacto solo puede honrar aquello que se exprese a través de una tabla fija de punteros a función, cada uno recibiendo un self ya anonimizado. Desde ahí, cada regla se deduce sola: no puedes devolver Self porque el llamador perdió su tamaño al aceptar el trato; no puedes tener métodos genéricos porque una tabla tiene ranuras finitas y un genérico pediría infinitas; no puedes llamar a Self::nueva() porque sin receptor no hay tabla que consultar, ni tipo al que rutar la creación. El compilador no te está poniendo trabas: te está enseñando, con precisión casi matemática, la frontera entre lo que un tipo puede hacer sabiendo quién es y lo que puede hacer habiéndolo olvidado. Los métodos genéricos, los constructores y todo lo que devuelve Self viven del lado del conocimiento estático; los métodos que solo tocan &self viven del lado del comportamiento puro, y solo esos caben en una vtable. Y cuando de verdad quieres ambas cosas —un trait usable como objeto y con métodos que necesitan el tipo—, where Self: Sized te deja trazar la línea a mano: esto para el mundo dinámico, aquello solo para el estático. Leer las reglas como consecuencias del borrado de tipo, y no como una lista que memorizar, es la señal de que de verdad entendiste qué es un trait object.

⚔️ Diagnostica la compatibilidad
  1. Escribe un trait con un método fn nueva() -> Self e intenta crear un Box<dyn ...>; lee el error de dyn compatibility completo.
  2. Explica, apoyándote en la vtable, por qué Clone —con fn clone(&self) -> Self— no puede ser un trait object.
  3. Añade a un trait un método genérico fn aplicar<T>(&self, x: T) y razona por qué eso rompe la compatibilidad.
  4. Toma un trait no compatible por culpa de un solo método y rescátalo con where Self: Sized; comprueba que ahora admite dyn pero ese método solo se llama sobre el tipo concreto.
  5. Clasifica estos métodos en “va a la vtable” o “hay que excluir”: fn area(&self) -> f64, fn escala(self) -> Self, fn desde(n: u32) -> Self, fn nombre(&self) -> &str.