wandres.dev
TRAIT OBJECTS · dispatch dinámico

Estático o dinámico: cuándo elegir cada dispatch

La decisión de ingeniería sobre varios ejes: rendimiento, tamaño de código, flexibilidad y si el conjunto de tipos es abierto o cerrado. Cuándo el dinámico es obligatorio, cuándo el estático gana, el enum como tercera vía, y la regla práctica en tres pasos para elegir bien.

⏱ 16 min

Ya tienes las dos herramientas y sabes cómo funcionan por dentro. Queda la pregunta de ingeniería: ¿cuál usar, y cuándo? La respuesta madura no es “el estático es más rápido, úsalo siempre”. Es una decisión con varios ejes —rendimiento, tamaño de código, flexibilidad, ergonomía de la API, y si el conjunto de tipos es abierto o cerrado— y, muchas veces, la mejor opción no es ninguno de los dos, sino un tercero: el enum.

🎯 Al terminar esta lección sabrás
  • Decidir entre estático y dinámico a partir de criterios concretos, no de reflejos.
  • Reconocer cuándo la heterogeneidad o un conjunto abierto de tipos obligan al dinámico.
  • Pesar la duplicación de código (monomorfización) frente a la indirección (vtable).
  • Conocer el valor por defecto idiomático y el papel del enum como tercera vía.

Los ejes de la decisión

No hay una respuesta única porque no hay una sola variable. Estos son los ejes reales que inclinan la balanza:

  • Conjunto de tipos abierto o cerrado. Si no controlas los tipos —plugins, extensiones de terceros, tipos futuros— necesitas dinámico. Si el conjunto es cerrado y lo conoces entero, tienes más opciones.
  • Heterogeneidad. ¿Necesitas mezclar tipos distintos en una misma colección? Entonces dyn o un enum, nunca genéricos puros.
  • Rendimiento en caliente. En un bucle interno muy repetido, el estático permite inlinar y optimizar; ahí la diferencia puede notarse. Fuera de los puntos calientes, casi nunca.
  • Tamaño de binario y tiempo de compilación. Monomorfizar sobre muchos tipos infla ambos; una sola copia dinámica los contiene. En sistemas embebidos o binarios sensibles al tamaño, el dinámico puede ganar también en la práctica.
  • Ergonomía de la API. dyn en las firmas simplifica los tipos y reduce la presión de monomorfización sobre quien te usa; los genéricos dan más flexibilidad al llamador pero propagan complejidad hacia arriba.

Cuándo el dinámico es obligatorio

Hay situaciones donde no hay elección: el estático sencillamente no puede expresarlas.

🧩

Conjunto abierto

Plugins, drivers, manejadores registrados en runtime. Los tipos viven en otros crates o ni existen al compilar: solo dyn los une.

🎭

Colección heterogénea

Un Vec<Box<dyn Trait>> con tipos distintos a la vez. Ningún Vec<T> ni impl Trait admite la mezcla.

Si tu problema cae en cualquiera de estas dos casillas, la conversación sobre rendimiento sobra: el dispatch dinámico no es “la opción lenta”, es la única opción. Devolver Box<dyn Error> de una función de aplicación, guardar una lista de comandos heterogéneos, cargar plugins: todos son dinámicos por naturaleza, no por descuido.

Cuándo el estático gana

Cuando el conjunto es cerrado y conocido, y sobre todo cuando el código es caliente, el estático rinde mejor y suele ser lo idiomático:

// Generico: monomorfizado, inlinable. Ideal en un bucle caliente
// donde en cada sitio de llamada se conoce el tipo concreto.
fn procesar<T: Forma>(formas: &[T]) -> f64 {
    formas.iter().map(|f| f.area()).sum()
}

Aquí cada instanciación de procesar conoce el tipo, inlina area y deja que el optimizador vea el bucle entero. Pero cuidado con el reflejo: mide antes de decidir. La indirección de la vtable es un acceso a memoria; con la tabla en caché y un sitio de llamada monomórfico, el predictor de saltos la absorbe casi siempre. El coste dominante del dinámico no es el salto, es el inlining perdido —y eso solo pesa de verdad en bucles muy repetidos con métodos diminutos—.

Compara las dos firmas que resuelven “sumar áreas” y verás el compromiso escrito en el propio tipo:

// Estatico: monomorfizado por tipo. Un solo tipo concreto por llamada,
// llamadas inlinables, pero una copia de codigo por cada T instanciado.
fn total_estatico<T: Forma>(v: &[T]) -> f64 {
    v.iter().map(|f| f.area()).sum()
}

// Dinamico: una sola copia de codigo. Admite un slice heterogeneo,
// pero cada f.area() es una llamada indirecta que no se inlina.
fn total_dinamico(v: &[Box<dyn Forma>]) -> f64 {
    v.iter().map(|f| f.area()).sum()
}

La primera es más rápida y menos flexible; la segunda, al revés. Y solo la segunda acepta un slice con círculos y rectángulos mezclados. Ninguna es “la correcta”: son dos puntos distintos del mismo eje.

⚠️
La monomorfización tiene un coste que no aparece en el perfil

Cada tipo con el que instancias una función genérica añade otra copia al binario. En una biblioteca muy genérica, monomorfizada sobre docenas de tipos, eso infla el tiempo de compilación y el tamaño del ejecutable —un coste que no verás en un perfil de CPU pero sí en tu ciclo de desarrollo y en un binario embebido—. A veces se introduce un dyn deliberado en la frontera de una API precisamente para cortar la explosión de monomorfización y que el código de dentro exista una sola vez.

La tercera vía y la regla práctica

Cuando el conjunto de tipos es cerrado y pequeño, a menudo la mejor herramienta no es dyn ni un genérico, sino un enum: reúne todas las variantes en un tipo, despacha con match —estático, sin vtable, sin heap— y el compilador te obliga a cubrir todos los casos.

enum Forma {
    Circulo { radio: f64 },
    Rect { ancho: f64, alto: f64 },
}

impl Forma {
    fn area(&self) -> f64 {
        match self {
            Forma::Circulo { radio } => std::f64::consts::PI * radio * radio,
            Forma::Rect { ancho, alto } => ancho * alto,
        }
    }
}

// Un Vec de Forma es heterogeneo (mezcla variantes) SIN box, sin vtable
// y con match exhaustivo comprobado por el compilador.

El enum brilla cuando el conjunto es cerrado: si mañana añades una variante, el compilador te señala cada match que olvidaste actualizar —una red de seguridad que el dyn no da—. Su límite es el reverso exacto: no sirve para conjuntos abiertos. No puedes añadir una variante desde otro crate, así que en cuanto los tipos deban llegar de fuera —plugins, extensiones—, el enum se queda corto y dyn Trait vuelve a ser la única respuesta.

De ahí sale una regla práctica en tres pasos:

  1. Empieza por genéricos o impl Trait. Cuestan cero y son flexibles; es el valor por defecto.
  2. Si el conjunto es cerrado y pequeño, considera un enum. Dispatch estático, heterogeneidad sin heap, exhaustividad comprobada.
  3. Baja a dyn Trait cuando el conjunto se abra, los tipos deban convivir en algo poseído, o quieras frenar una explosión de monomorfización. Es la herramienta de la flexibilidad, y su coste rara vez importa fuera del bucle caliente.

Genéricos

Conjunto conocido, un tipo por sitio de llamada. Coste cero, máxima velocidad, código monomorfizado. El valor por defecto.

🔢

enum

Conjunto cerrado y pequeño. Dispatch estático con match, sin heap ni vtable, y exhaustividad garantizada por el compilador.

🎭

dyn Trait

Conjunto abierto o heterogéneo. Una copia de código y tipos que conviven poseídos, a cambio de una indirección por llamada.

flowchart TB
q1[Necesitas mezclar tipos distintos] -->|no| est[Genericos o impl Trait]
q1 -->|si| q2[El conjunto de tipos es cerrado y pequeno]
q2 -->|si| en[Usa un enum con match]
q2 -->|no| dyn[Usa dyn Trait normalmente en un Box]
style est fill:#a6e3a1,color:#11111b
style en fill:#89b4fa,color:#11111b
style dyn fill:#cba6f7,color:#11111b
El ingeniero maduro no tiene bando: lee la forma del problema

El principiante llega a los trait objects con un prejuicio heredado —“el dispatch dinámico es lento, evítalo”— y confunde una herramienta con un defecto. La verdad es más fina. El dispatch estático y el dinámico no son rivales, sino respuestas a preguntas opuestas: “¿cómo de rápido, dado que conozco el tipo?” frente a “¿cómo de flexible, dado que no lo conozco?”. La monomorfización cambia tamaño de binario y tiempo de compilación por velocidad de ejecución; el dispatch dinámico cambia velocidad de ejecución por tamaño, por tiempo de compilación y —lo que no se consigue de otro modo— por heterogeneidad en runtime y un mundo abierto. Y hay un tercer contendiente que el debate suele olvidar: el enum, que da heterogeneidad de conjunto cerrado con dispatch estático y exhaustividad comprobada, sin heap ni vtable. La mayor parte del código vive en la zona donde ninguno de estos costes importa un microsegundo, y ahí el criterio no son los ciclos sino la expresividad: cuál modela mejor tu dominio, cuál deja la firma más limpia, cuál el compilador comprueba con más fuerza. Por eso el reflejo correcto no es “elige el rápido”, sino “identifica qué pregunta hace tu problema”. ¿Los tipos se conocen y no cambian? Genéricos, gratis. ¿Son pocos y cerrados? Un enum, y que el match te cuide. ¿Se abren, llegan de fuera, deben convivir poseídos en una lista? dyn Trait, y la indirección es un precio justo por la flexibilidad. Saber cuál de esas tres preguntas plantea tu problema —y no tener un favorito de antemano— es, entera, la habilidad que este nivel venía a enseñar.

⚔️ Elige con criterio
  1. Para cada caso di estático, enum o dyn y justifícalo: (a) sumar áreas de un slice de un solo tipo en un bucle caliente; (b) una escena con círculos, textos y sprites mezclados; (c) un parser con exactamente cuatro clases de token; (d) un sistema de plugins cargados de crates externos.
  2. Reescribe una función fn total<T: Forma>(v: &[T]) y otra fn total(v: &[Box<dyn Forma>]); explica qué cambia en el binario y en las capacidades de cada una.
  3. Modela Forma como enum con Circulo y Rect, despáchalo con match, y argumenta qué ganas frente a Box<dyn Forma> y qué pierdes.
  4. Explica por qué “el dispatch dinámico es más lento” es un error de categoría cuando el problema exige heterogeneidad de conjunto abierto.
  5. Enuncia con tus palabras la regla de tres pasos —genérico, luego enum, luego dyn— y aplícala a un problema propio.