wandres.dev
GENÉRICOS · código para cualquier tipo

Monomorfización: la abstracción de coste cero

El compilador no ejecuta genéricos: por cada tipo concreto que usas genera una versión especializada del código, como si lo hubieras escrito a mano. Qué es la monomorfización, por qué hace los genéricos tan rápidos como el código específico, y qué precio se paga a cambio.

⏱ 17 min

Una duda razonable acecha a todo el que aprende genéricos: si mi función sirve para cualquier tipo, ¿no tendrá que decidir en ejecución con cuál está tratando, y pagar por esa flexibilidad? En Java, en C#, en Python, la respuesta suele ser sí: hay boxing, indirección o información de tipos en tiempo de ejecución. En Rust la respuesta es un rotundo no. El mecanismo que lo hace posible se llama monomorfización, y es la razón de que en Rust puedas abstraer sin culpa: el genérico se compila hasta convertirse, literalmente, en el mismo código máquina que habrías escrito a mano para cada tipo.

🎯 Al terminar esta lección sabrás
  • Explicar qué genera el compilador por cada tipo concreto usado en un genérico.
  • Justificar por qué la monomorfización hace del genérico una abstracción de coste cero.
  • Reconocer su precio: más tiempo de compilación y binarios mayores (code bloat).
  • Contrastar el despacho estático de los genéricos con el despacho dinámico de dyn.

Qué hace el compilador con un genérico

“Monomorfización” significa, literalmente, convertir en una sola forma: de mono (una) y morfo (forma). El compilador toma tu código polimórfico —de muchas formas posibles— y, examinando qué tipos usas de verdad, emite una copia concreta y especializada por cada uno. Considera:

use std::fmt::Display;

fn imprime<T: Display>(x: T) {
    println!("{x}");
}

fn main() {
    imprime(5_i32);
    imprime("hola");
}

El compilador ve que imprime se usa con i32 y con &str, y genera dos funciones independientes, más o menos así:

// Generado por el compilador (conceptual, no lo escribes tú)
fn imprime_i32(x: i32) { /* cuerpo especializado para i32 */ }
fn imprime_str(x: &str) { /* cuerpo especializado para &str */ }

Cada llamada del programa se reescribe para apuntar a la copia adecuada. El compilador no es ingenuo: si dos partes del programa usan imprime con el mismo tipo, comparten una única copia; solo hay tantas versiones como tipos distintos aparezcan. Por eso un genérico jamás deja una decisión pendiente para la ejecución: para cuando el programa corre, ya no queda ni un solo T sin resolver.

Lo mismo vale para los tipos genéricos. Cada instanciación es un tipo concreto con su propia disposición de memoria; Caja<u8> y Caja<i64> no comparten tamaño ni código:

use std::mem::size_of;

struct Caja<T> {
    valor: T,
}

fn main() {
    assert_eq!(size_of::<Caja<u8>>(), 1);   // una instanciación
    assert_eq!(size_of::<Caja<i64>>(), 8);  // otra, con otro tamaño
}

El genérico existe en tu fuente; en el binario solo hay versiones monomórficas, cada una tan concreta como si la hubieras tecleado a mano.

Coste cero: tan rápido como a mano

Como cada llamada acaba invocando una función concreta, no queda ni rastro de la genericidad en ejecución: no hay boxing, ni indirección por puntero, ni tabla de tipos que consultar, ni ramas para decidir el tipo. La copia generada para i32 es indistinguible de la que habrías escrito a mano:

// Escrito a mano
fn max_i32(a: i32, b: i32) -> i32 { if a > b { a } else { b } }

// Genérico, que al instanciarse con i32 produce exactamente lo mismo
fn max<T: PartialOrd>(a: T, b: T) -> T { if a > b { a } else { b } }

fn main() {
    let x = max_i32(3, 9);   // versión artesanal
    let y = max(3, 9);       // max::<i32>, generado por monomorfización
    assert_eq!(x, y);        // mismo resultado y mismo código máquina
}

Donde más se nota es en las cadenas de iteradores: varias capas genéricas apiladas que el optimizador, una vez monomorfizadas, funde en un único bucle tan ceñido como el que escribirías con un índice a mano.

let v = vec![1, 2, 3, 4, 5];

// Cadena de iteradores: capas genéricas apiladas
let suma: i32 = v.iter().filter(|&&x| x % 2 == 0).map(|&x| x * 10).sum();

// Tras monomorfizar e inlinear, equivale a este bucle:
let mut manual = 0;
for &x in &v {
    if x % 2 == 0 { manual += x * 10; }
}
assert_eq!(suma, manual);
💡
Coste cero, la definición exacta

“Abstracción de coste cero” (zero-cost abstraction) significa dos cosas precisas: lo que no usas, no lo pagas, y lo que usas, no podrías escribirlo a mano más rápido. Los genéricos cumplen ambas al pie de la letra gracias a la monomorfización. No es que sean “rápidos”; es que compilan hasta desaparecer, dejando solo el código específico que representaban.

El precio: compilación y tamaño

Nada es gratis del todo; la monomorfización mueve el coste, no lo elimina. Lo paga en dos monedas, ambas en tiempo de compilación:

  • Tiempo de compilación. Generar y optimizar una copia por tipo es trabajo real. Un genérico muy usado, con muchos tipos, multiplica la labor del compilador. Es una de las razones de que Rust compile más despacio que lenguajes con borrado de tipos.
  • Tamaño del binario (code bloat). Cada copia ocupa espacio. Un genérico instanciado con veinte tipos deja veinte cuerpos en el ejecutable. En la mayoría de programas es irrelevante; en binarios muy sensibles al tamaño (embebido, WebAssembly), puede importar.

Es un intercambio deliberado: Rust regala velocidad de ejecución a cambio de compilación más lenta y binarios algo mayores. Y es la elección correcta para un lenguaje de sistemas, porque el tiempo de compilación lo pagas una vez, en tu máquina, mientras que el coste de ejecución lo pagarían tus usuarios, en cada llamada, para siempre.

flowchart TB
gen[Funcion generica imprime de T] --> uso[El compilador ve los tipos usados]
uso --> a[Copia especializada para i32]
uso --> b[Copia especializada para f64]
uso --> c[Copia especializada para str]
a --> opt[Cada copia se optimiza e inlinea sola]
b --> opt
c --> opt
opt --> cero[Coste cero en ejecucion mas binario mayor]
style gen fill:#cba6f7,color:#11111b
style opt fill:#89b4fa,color:#11111b
style cero fill:#a6e3a1,color:#11111b

Estático frente a dinámico

Los genéricos usan despacho estático: qué función concreta se ejecuta se decide en compilación, lo que permite inlining y cero indirección, a costa del code bloat. Rust ofrece también la palanca contraria, el despacho dinámico, con objetos de trait como dyn Display: una sola copia del código compartida por todos los tipos, pero cada llamada consulta una tabla de métodos (vtable) en ejecución.

use std::fmt::Display;

// Estático: monomorfizado, una copia por tipo, sin indirección
fn estatico<T: Display>(x: T) {
    println!("{x}");
}

// Dinámico: una sola copia, resuelto por vtable en ejecución
fn dinamico(x: &dyn Display) {
    println!("{x}");
}

Despacho estático · genéricos

Se resuelve en compilación. Permite inlining y cero indirección; el precio es code bloat. Es el modo por defecto.

📦

Despacho dinámico · dyn Trait

Una copia compartida y una vtable consultada en ejecución. Binario compacto; el precio es una indirección por llamada.

Lo esencial es que tú eliges. Los genéricos son estáticos por defecto —rápidos y grandes—; dyn es dinámico —compacto y con indirección—. El objeto de trait tiene su propio nivel más adelante; por ahora basta con situar la monomorfización como el porqué de la velocidad de los genéricos.

Coste cero es el alma de Rust, y el genérico es su ejemplo más puro

La frase “abstracción de coste cero” no es marketing: es la promesa fundacional que distingue a Rust de casi todo lo demás. Durante décadas, los programadores vivieron atrapados en un dilema falso: o escribías código expresivo y de alto nivel y aceptabas la penalización de rendimiento, o bajabas a un lenguaje de sistemas y renunciabas a la abstracción. La monomorfización disuelve ese dilema. Un max infinitamente reutilizable compila a las instrucciones exactas de un max especializado; un iterador con diez transformaciones encadenadas se funde en un bucle tan ceñido como el que escribirías con índices a mano. Obtienes la expresividad de un lenguaje de alto nivel y el código máquina de uno de bajo nivel, sin tener que elegir. Por eso Rust puede permitirse poner genéricos en todas partes —en Option, en Result, en Vec, en el ecosistema entero de iteradores— sin la mala conciencia que atormenta a otros lenguajes, donde cada capa de abstracción cuesta ciclos. El truco consiste en mover el coste a donde puedes permitírtelo: al tiempo de compilación, que pagas una vez en tu máquina, y no al tiempo de ejecución, que pagarían tus usuarios millones de veces. Entender la monomorfización es entender por qué “abstrae con libertad” es un consejo sensato en Rust y una temeridad en la mayoría de los lenguajes. La abstracción, aquí, no cuesta; se evapora en el código que representaba.

📝
Lo esencial de la monomorfización

Por cada tipo concreto con que usas un genérico, el compilador emite una copia especializada; en el binario no queda genericidad, solo funciones concretas. De ahí el coste cero en ejecución: son tan rápidas como escritas a mano. El precio —compilación más lenta y binario mayor— se paga en compilación, no en ejecución. Y si prefieres el reparto inverso, dyn te ofrece despacho dinámico.

⚔️ Piensa como el compilador
  1. Escribe fn dobla<T: std::ops::Add<Output = T> + Copy>(x: T) -> T { x + x }, úsala con i32, f64 y u8, y razona cuántas copias emite el compilador.
  2. Argumenta, apoyándote en la monomorfización, por qué dobla(3_i32) no es ni un ciclo más lento que un fn dobla_i32(x: i32) -> i32 { x + x }.
  3. Comprueba con std::mem::size_of que Caja<u8> y Caja<i64> tienen tamaños distintos, y explica qué revela eso sobre las instanciaciones.
  4. Contrasta despacho estático y dinámico: para un genérico usado con cientos de tipos en un binario embebido, ¿qué palanca elegirías y por qué?
  5. Busca la cita de coste cero (“lo que no usas no lo pagas; lo que usas no podrías escribirlo mejor a mano”) y explica cómo la monomorfización cumple sus dos mitades.