dyn Trait por dentro: el puntero gordo y la vtable
Un trait object no es un puntero cualquiera: es un puntero gordo de dos palabras, una a los datos y otra a la tabla de métodos. Qué guarda la vtable (destructor, tamaño, alineación y un puntero por método), por qué dyn T ocupa el doble, y cómo funciona la coerción de unsizing.
Un &dyn Resumir se escribe como una referencia normal, pero pesa el doble. No es un puntero cualquiera: es un puntero gordo (fat pointer), dos palabras de máquina en lugar de una. La primera apunta a los datos; la segunda, a una tabla de métodos que el compilador construyó para el par (tipo concreto, trait). Abrir esa caja —ver qué guarda la vtable y cómo se usa en cada llamada— es entender de verdad qué es un trait object.
- Ver que un trait object es un puntero gordo: puntero a datos más puntero a vtable.
- Conocer qué guarda la vtable: destructor, tamaño, alineación y un puntero por método.
- Entender por qué
&dyn Tocupa dos palabras y&Tsolo una. - Seguir la coerción de
&Concretoa&dyn Trait—el unsizing— paso a paso.
Un puntero gordo: datos más vtable
Una referencia ordinaria &T es un puntero fino: una sola dirección de memoria, una palabra de máquina (8 bytes en 64 bits). Un trait object es distinto: mídelo y verás que ocupa el doble.
use std::mem::size_of;
trait Dibujar { fn dibujar(&self); }
fn main() {
println!("{}", size_of::<&u8>()); // 8: puntero fino, una palabra
println!("{}", size_of::<&dyn Dibujar>()); // 16: puntero gordo, dos palabras
}
Esos 16 bytes son dos punteros yuxtapuestos:
- Puntero a datos: la dirección del valor concreto —un
Circulo, por ejemplo—, esté en la pila o en el heap. - Puntero a vtable: la dirección de la tabla de métodos que corresponde al par
(Circulo, Dibujar).
El tipo concreto ha desaparecido del sistema de tipos —a eso se le llama borrado de tipo (type erasure)—, pero no se ha perdido: su comportamiento vive ahora en la segunda mitad del puntero, en la vtable.
Qué guarda la vtable
La vtable es una estructura de punteros que el compilador genera una vez por cada par (tipo, trait) y coloca en memoria de solo lectura. Conceptualmente contiene esto —no es código que escribas, es lo que fabrica el compilador—:
// Disposicion conceptual de la vtable de (Circulo, Dibujar):
struct VTable {
drop_in_place: fn(*mut ()), // como destruir el valor concreto
size: usize, // tamano en bytes del valor concreto
align: usize, // alineacion del valor concreto
dibujar: fn(*const ()), // puntero al metodo dibujar de Circulo
// ...un puntero mas por cada metodo despachable del trait
}
Los tres primeros campos —destructor, tamaño y alineación— existen aunque el trait no tenga métodos: son los que permiten destruir correctamente un Box<dyn Dibujar>, llamando al destructor del tipo real, y saber cuánta memoria liberar. Después vienen los punteros a los métodos, uno por cada método despachable del trait, en un orden fijo que el compilador conoce.
Detalle clave: hay una sola vtable por par (tipo, trait), compartida por todos los trait objects de esa combinación. Un Vec con mil círculos tras dyn Dibujar tiene mil punteros a datos distintos, pero el mismo puntero a vtable repetido mil veces.
Puedes incluso observar que el tamaño viaja en la vtable: size_of_val sobre un trait object consulta el campo size de la tabla para responder en ejecución algo que, para un tipo concreto, se sabría en compilación.
use std::mem::size_of_val;
let c = Circulo { radio: 2.0 };
let obj: &dyn Dibujar = &c;
println!("{}", size_of_val(obj)); // lee 'size' de la vtable: el tamano real de Circulo
Sin ese campo, un Box<dyn Dibujar> no sabría cuánta memoria devolver al heap al destruirse. La vtable no es solo una tabla de métodos: es el mínimo de información sobre el tipo borrado que hace falta para usarlo y liberarlo con seguridad.
La llamada: una indirección más
Con esta estructura, obj.dibujar() no es una llamada directa, sino dos pasos:
let c = Circulo { radio: 2.0 };
let obj: &dyn Dibujar = &c; // coercion: se adjunta la vtable de (Circulo, Dibujar)
obj.dibujar(); // 1) lee el puntero 'dibujar' de la vtable
// 2) salta a el, pasando el puntero a datos como self
Esa lectura del puntero en la tabla es la indirección del dispatch dinámico: un acceso a memoria extra frente a la llamada directa del código monomorfizado. En una CPU moderna, con la vtable caliente en caché y un predictor de saltos entrenado, su coste directo suele ser pequeño; lo caro, cuando lo es, es que el compilador no puede inlinar a través de ella ni optimizar el cuerpo del método en el contexto de la llamada.
flowchart LR fp[Puntero gordo dyn Dibujar] --> d[Puntero a datos] fp --> v[Puntero a vtable] d --> val[Valor Circulo en memoria] v --> tab[Tabla de metodos de Circulo y Dibujar] tab --> drop[destructor] tab --> size[tamano y alineacion] tab --> met[puntero al metodo dibujar] met --> val style fp fill:#cba6f7,color:#11111b style val fill:#a6e3a1,color:#11111b style tab fill:#89b4fa,color:#11111b
La coerción a trait object: unsizing
¿De dónde sale el puntero a vtable? De una coerción que el compilador inserta al pasar de un tipo concreto a un dyn Trait. Se llama unsized coercion porque dyn Dibujar es un tipo de tamaño desconocido (?Sized): no sabes cuánto ocupa el valor real detrás de él, por eso siempre lo manejas tras un puntero —&dyn, Box<dyn>, Rc<dyn>—.
let c = Circulo { radio: 2.0 };
let r: &Circulo = &c; // puntero fino: solo la direccion de c
let d: &dyn Dibujar = r; // puntero gordo: direccion de c + vtable de (Circulo, Dibujar)
La coerción es de coste cero en sí misma: solo empaqueta el puntero que ya tenías junto con la dirección —conocida en compilación— de la vtable correcta. Ocurre automáticamente en las fronteras: al asignar a una variable &dyn, al pasar un argumento, al meter un Box::new(...) en un Vec<Box<dyn Dibujar>>.
La coerción también ocurre al pasar argumentos: si una función pide &dyn Dibujar y le das un &Circulo, el compilador inserta el unsizing en la frontera de la llamada.
fn pintar(obj: &dyn Dibujar) { obj.dibujar(); }
let c = Circulo { radio: 1.0 };
pintar(&c); // &Circulo se coacciona a &dyn Dibujar aqui, en la llamada
A diferencia de C++, donde cada objeto polimórfico lleva dentro un puntero a su tabla —el vptr—, en Rust el puntero a vtable vive en el puntero gordo, no en el valor. Un Circulo ocupa exactamente lo que ocupan sus campos, ni un byte más, lo uses o no como trait object. Solo pagas las dos palabras cuando formas un &dyn o un Box<dyn>. Es el principio de “no pagas por lo que no usas” aplicado al polimorfismo.
No puedes escribir let x: dyn Dibujar = ...; ni tener un dyn Dibujar en la pila como valor suelto: su tamaño no se conoce en compilación. Un trait object solo existe detrás de un puntero —&dyn T, &mut dyn T, Box<dyn T>, Rc<dyn T>, Arc<dyn T>—, que es lo que aporta la indirección y el tamaño fijo del puntero gordo. Por eso el tipo se escribe casi siempre acompañado: el dyn desnudo es un tipo ?Sized.
La vtable materializa una idea honda: coger la pregunta “¿de qué tipo es esto y cómo se comporta?”, que normalmente vive y muere en compilación, y convertirla en un valor de tiempo de ejecución —un puntero que puedes guardar, pasar, copiar y comparar—. Eso es el borrado de tipo: el trait object olvida cuál era el tipo concreto, y a cambio recuerda, en la vtable, todo lo necesario para actuar como él —cómo ejecutar cada método, cómo destruirse, cuánto mide—. Es una reificación: comportamiento estático transformado en datos manipulables. La consecuencia es que un mismo trozo de código máquina puede operar sobre valores cuyos tipos ni siquiera existían cuando se compiló, porque no habla con el tipo, habla con la tabla. Es exactamente el mecanismo de las funciones virtuales de C++, pero con dos diferencias que revelan la filosofía de Rust: aquí el puntero a vtable no vive dentro de cada objeto —como el vptr de C++—, sino en el puntero gordo, de modo que un tipo no paga ni un byte por ser potencialmente “dinámico” mientras no lo uses como tal; y la vtable incluye el destructor, el tamaño y la alineación, porque en un lenguaje con destrucción determinista y sin recolector, olvidar el tipo sin recordar cómo liberarlo sería una fuga, o algo peor. La vtable, en suma, es el precio y el poder del polimorfismo tardío hecho estructura de datos: una tabla que sabe ser un tipo que el código ya olvidó.
- Mide con
size_ofun&u8, un&dyn Dibujary unBox<dyn Dibujar>; explica por qué los dos últimos ocupan dos palabras. - Dibuja en papel el puntero gordo de
&dyn Dibujarsobre unCirculo: qué apunta a los datos y qué a la vtable. - Enumera los campos de la vtable e indica cuáles existen aunque el trait no declare ningún método, y por qué.
- Explica por qué mil círculos tras
dyn Dibujarcomparten el mismo puntero a vtable pero no el mismo puntero a datos. - Intenta declarar
let x: dyn Dibujar;sin puntero, lee el error sobreSized, y razona por qué un trait object siempre necesita ir tras una indirección.