Dispatch estático frente a dinámico: el compromiso fundamental
Llamar a un método a través de un trait puede resolverse en compilación (genéricos, monomorfización) o en ejecución (trait objects). Qué significa cada camino, por qué el estático es de coste cero e inlinable, y el compromiso entre velocidad y flexibilidad que gobierna todo el nivel.
Cuando llamas a un método a través de un trait, alguien debe decidir qué código concreto se ejecuta. Esa resolución del destino de la llamada puede ocurrir en dos momentos distintos: durante la compilación, grabando la dirección exacta en el binario, o durante la ejecución, consultando una tabla. Rust te da ambos caminos, y elegir entre ellos es uno de los compromisos de diseño más consecuentes del lenguaje: velocidad frente a flexibilidad, tamaño de código frente a tamaño de binario. Todo este nivel gira alrededor de esa bifurcación.
- Distinguir el dispatch estático (genéricos y monomorfización) del dinámico (trait objects).
- Entender la monomorfización: una copia especializada del código por cada tipo concreto.
- Ver por qué el dispatch estático es inlinable y de coste cero, y el dinámico no.
- Pesar el compromiso central: rendimiento y optimización frente a flexibilidad y tamaño.
Dos formas de llamar a través de un trait
Partimos de un trait mínimo y dos tipos que lo implementan:
trait Resumir {
fn resumen(&self) -> String;
}
struct Articulo { titulo: String }
struct Tuit { autor: String }
impl Resumir for Articulo {
fn resumen(&self) -> String { format!("Articulo: {}", self.titulo) }
}
impl Resumir for Tuit {
fn resumen(&self) -> String { format!("@{}", self.autor) }
}
Ahora queremos una función que acepte “cualquier cosa que sepa resumirse”. Rust ofrece dos redacciones que parecen equivalentes:
// Estatico: un parametro generico acotado por el trait.
fn anunciar_estatico<T: Resumir>(item: &T) {
println!("Novedad: {}", item.resumen());
}
// Dinamico: un trait object detras de una referencia.
fn anunciar_dinamico(item: &dyn Resumir) {
println!("Novedad: {}", item.resumen());
}
Ambas compilan y ambas se llaman igual. La diferencia es invisible en el código fuente pero profunda en el binario que genera el compilador. La primera usa dispatch estático; la segunda, dinámico.
Dispatch estático: monomorfización
Cuando el compilador ve una llamada a anunciar_estatico con un tipo concreto, genera una copia especializada de la función para ese tipo. Este proceso se llama monomorfización: convertir una función polimórfica en muchas funciones monomórficas, una por cada tipo con el que se instancia.
anunciar_estatico(&articulo); // instancia anunciar_estatico para Articulo
anunciar_estatico(&tuit); // otra instancia distinta para Tuit
En el binario resultante existen, de hecho, dos funciones: una donde item.resumen() salta directamente al resumen de Articulo, y otra donde salta al de Tuit. Cada llamada es una llamada directa a una dirección conocida en compilación. Y como el destino es conocido, el optimizador puede inlinar el cuerpo del método, propagar constantes y, a menudo, hacer desaparecer la llamada por completo. Es el famoso coste cero: el código genérico corre exactamente tan rápido como si hubieras escrito a mano cada versión. Escribir fn anunciar(item: &impl Resumir) es azúcar sintáctico para exactamente esto.
Es como si el compilador hubiera escrito esto por ti, una versión por tipo:
// Generado conceptualmente por monomorfizacion (no lo escribes tu):
fn anunciar_estatico_para_articulo(item: &Articulo) {
println!("Novedad: {}", item.resumen()); // llamada directa al resumen de Articulo
}
fn anunciar_estatico_para_tuit(item: &Tuit) {
println!("Novedad: {}", item.resumen()); // llamada directa al resumen de Tuit
}
Cada versión tiene el tipo clavado, así que el compilador conoce la dirección exacta del método y puede fundir la llamada con su cuerpo. Ese es el mecanismo del coste cero: no hay abstracción en el binario, solo código especializado.
El precio se paga en otra moneda: duplicación de código. Cada tipo con el que instancias añade otra copia al binario. Una API muy genérica, monomorfizada sobre decenas de tipos, infla el tiempo de compilación y el tamaño del ejecutable.
Dispatch dinámico: una decisión en ejecución
&dyn Resumir es un trait object. Aquí existe una única copia de anunciar_dinamico en el binario, válida para todos los tipos. ¿Cómo sabe entonces qué resumen llamar? No lo sabe en compilación: lo decide en ejecución, consultando una tabla de métodos —la vtable— que viaja junto al puntero. Cada item.resumen() se traduce a “lee el puntero a función de la tabla y salta a él”; el mecanismo interno es el tema de la próxima lección.
Las consecuencias son el reverso exacto del caso estático. El compilador no puede inlinar la llamada, porque no conoce el destino hasta ejecución, ni optimizar a través de ella. Hay una indirección por cada llamada. A cambio obtienes una sola copia de código y —crucial— la capacidad de guardar valores de tipos distintos tras un mismo dyn Resumir, algo que los genéricos puros no pueden hacer.
Ese “guardar tipos distintos juntos” es la capacidad estrella del dispatch dinámico: un Vec<Box<dyn Resumir>> puede contener a la vez artículos y tuits, cosa imposible con un Vec<T> genérico, que fija un solo T. Lo desarrollaremos en la lección 15.4; por ahora, quédate con que la flexibilidad del dinámico no es un lujo, es una capacidad que el estático literalmente no tiene.
fn f<T: Resumir>(x: &T), fn f(x: &impl Resumir) y fn f() -> impl Resumir son tres caras del dispatch estático: todas monomorfizan y ninguna borra el tipo. El impl Trait en posición de argumento es azúcar exacto para un genérico anónimo; en posición de retorno significa “un tipo concreto único que no te digo, pero fijo en compilación”. Ninguna de las tres te deja mezclar tipos: para eso hace falta el dyn, que es la otra cara, la dinámica.
flowchart TB src[Codigo que llama a traves de un trait] --> q[Cuando se resuelve el destino] q -->|en compilacion| est[Dispatch estatico] q -->|en ejecucion| din[Dispatch dinamico] est --> m[Monomorfizacion una copia por tipo] m --> inl[Llamada directa inlinable de coste cero] din --> vt[Consulta a la tabla de metodos] vt --> ind[Una indireccion por llamada sin inlining] style est fill:#a6e3a1,color:#11111b style din fill:#cba6f7,color:#11111b style inl fill:#89b4fa,color:#11111b style ind fill:#f38ba8,color:#11111b
El eje del compromiso
Ningún camino es “mejor”: cada uno optimiza una variable distinta y empeora la otra.
Estático: velocidad
Monomorfizado, inlinable, coste cero en ejecución. El precio: una copia de código por tipo, más binario y más tiempo de compilación.
Dinámico: flexibilidad
Una sola copia de código y tipos heterogéneos tras un mismo dyn. El precio: una indirección por llamada y la pérdida del inlining.
El dispatch estático gana cuando el conjunto de tipos es cerrado y conocido y el rendimiento importa; el dinámico gana cuando el conjunto es abierto, los tipos son heterogéneos, o quieres minimizar el código generado. La decisión completa —con todos sus matices— es el tema de la lección 15.5; por ahora basta con ver que son dos mecanismos con perfiles de coste opuestos, no un ganador y un perdedor.
En C++ cada método es estático salvo que lo marques virtual; en Java casi todo es virtual por defecto. Rust invierte la ergonomía: el dispatch estático es lo natural (los genéricos), y el dinámico se pide explícitamente con la palabra dyn. Nunca pagas una indirección sin haberla escrito, y nunca duplicas código sin que el tipo lo revele. El coste está siempre a la vista en la firma: <T> o impl significan monomorfización; dyn significa vtable.
Todo el dilema se comprime en una sola pregunta: ¿cuándo se sabe qué código ejecutar? El dispatch estático responde “en compilación”, y de esa respuesta se deduce todo lo demás. Si el compilador conoce el tipo concreto, puede escribir una copia a medida —monomorfización—, grabar la dirección exacta de cada método, inlinar, y optimizar como si el polimorfismo nunca hubiera existido: por eso es de coste cero, y por eso duplica código. El dispatch dinámico responde “en ejecución”, y de ahí sale su reverso: si el tipo se conoce tarde, hace falta una tabla que lo represente, una indirección para consultarla, y el optimizador queda ciego ante el destino; a cambio, un mismo trozo de código sirve para infinitos tipos, incluso tipos que aún no existían cuando compilaste tu biblioteca. Esta tensión —early binding contra late binding— atraviesa toda la informática, desde las llamadas virtuales de C++ hasta los despachadores de mensajes de Smalltalk. Lo que distingue a Rust es que se niega a elegir por ti y a esconder el precio: no hay un “virtual por defecto” que te cobre indirecciones invisibles, ni una monomorfización silenciosa que hinche tu binario sin avisar. El polimorfismo no es una cosa, son dos mecanismos con costes inversos, y el tipo —un T acotado o un dyn Trait— siempre te dice cuál estás pagando. Interiorizar que “genérico” y “trait object” son dos respuestas a la misma pregunta temporal es el marco mental que ordena todo este nivel.
- Define el trait
Resumiry los tiposArticuloyTuit, y escribe las dos funcionesanunciar_estaticoyanunciar_dinamico. - Llama a la versión estática con un
Articuloy con unTuit; explica cuántas copias de la función existen en el binario y por qué. - Llama a la versión dinámica pasando
&articulocoaccionado a&dyn Resumir; razona por qué aquí solo hay una copia de la función. - Argumenta cuál de las dos permitiría al optimizador inlinar
resumen, y por qué la otra no puede. - Piensa un caso donde la duplicación de código del dispatch estático sería un problema real, y otro donde la indirección del dinámico lo sería.