Abstracciones de coste cero: la promesa y cómo leerla en el ensamblador
Coste cero significa dos cosas exactas: lo que no usas no lo pagas, y lo que usas no podrías escribirlo mejor a mano. Qué abstracciones de Rust cumplen esa doble promesa —iteradores, Option de referencia, newtypes, Drop—, por qué a veces el idiomático es más rápido que el índice manual, y cómo comprobarlo tú mismo mirando el asm con cargo-show-asm y Compiler Explorer.
La frase fundacional que Rust hereda de C++ —el zero-overhead principle de Stroustrup— tiene dos mitades, y ambas son verificables: lo que no usas, no lo pagas, y lo que usas, no podrías escribirlo a mano más rápido. No es un eslogan: es una afirmación falsable sobre el código máquina que sale del compilador. Cuando escribes una cadena de iteradores, un Option<&T> o un newtype, estás apostando a que esas capas de expresividad se evaporan en la compilación y dejan exactamente las instrucciones que habrías tecleado en un lenguaje sin abstracciones. Lo hermoso de Rust es que no tienes que creerlo por fe: puedes abrir el ensamblador y comprobarlo con tus ojos. Esta lección te da el significado preciso de “coste cero” y las herramientas —cargo-show-asm y Compiler Explorer— para auditarlo.
- Enunciar con exactitud las dos cláusulas de una abstracción de coste cero.
- Reconocer las abstracciones de Rust que la cumplen y por qué el idiomático a veces gana.
- Inspeccionar el ensamblador generado con
cargo-show-asmy en el navegador con godbolt. - Impedir que el optimizador borre lo que quieres medir con
black_boxeinline(never).
Las dos mitades de la promesa
“Coste cero” no significa “gratis” ni “rápido” a secas; significa dos cosas técnicas y comprobables. La primera cláusula —lo que no usas no lo pagas— exige que una característica no imponga sobrecoste a quien no la invoca: tener genéricos en el lenguaje no ralentiza el código que usa tipos concretos, y un Result que nunca falla no arrastra maquinaria de excepciones. La segunda —lo que usas no podrías escribirlo mejor a mano— es más fuerte: cuando sí usas la abstracción, el código máquina resultante debe ser tan bueno como el que un experto escribiría a mano en un lenguaje de bajo nivel. Juntas, disuelven el falso dilema entre expresividad y rendimiento que atormentó a la programación durante décadas.
El mecanismo que sostiene la mayoría de estas garantías ya lo viste en el nivel 14: la monomorfización convierte cada genérico en código concreto especializado, y el inlining del optimizador funde las capas. Aquí subimos un peldaño: no basta con confiar en el mecanismo, hay que saber auditarlo, porque el coste cero es una propiedad que el compilador casi siempre entrega pero que, en casos límite, puede escapársele.
Los ejemplos canónicos, y por qué el idiomático gana
Cuatro abstracciones ilustran la promesa. La primera es el iterador: una cadena iter().filter().map().sum() se compila, tras monomorfizar e inlinear, en el mismo bucle ceñido que escribirías con un índice. Pero hay un matiz que invierte la intuición: el iterador a menudo es más rápido que el índice manual, porque garantiza por construcción que cada acceso está dentro de rango y elimina las comprobaciones de límites que el indexado obliga a insertar.
// Indexado: cada v[i] puede requerir comprobar i < v.len() en ejecucion.
fn suma_indexada(v: &[i32]) -> i32 {
let mut s = 0;
for i in 0..v.len() {
s += v[i]; // el optimizador debe *probar* que no se sale
}
s
}
// Iterador: el recorrido esta acotado por construccion, sin comprobaciones.
fn suma_iter(v: &[i32]) -> i32 {
v.iter().sum() // a menudo mas rapido: sin bounds checks, vectorizable
}
La segunda es el Option de un puntero. Gracias a la optimización de nicho (usar un valor imposible del tipo interno para codificar None), Option<&T> ocupa lo mismo que &T: el compilador representa None con el puntero nulo, que una referencia jamás puede tomar. La ausencia sin null no cuesta ni un byte.
use std::mem::size_of;
fn main() {
assert_eq!(size_of::<&i32>(), 8);
assert_eq!(size_of::<Option<&i32>>(), 8); // mismo tamano: nicho del puntero nulo
assert_eq!(size_of::<Option<Box<i32>>>(), 8);
}
La tercera es el newtype. Envolver un f64 en struct Metros(f64) da seguridad de tipos —no confundirás metros con segundos— y desaparece por completo en el binario; con #[repr(transparent)] el lenguaje garantiza que su representación es idéntica a la del f64 que envuelve. La cuarta es Drop y el RAII: la liberación determinista de recursos no invoca a ningún recolector ni añade indirección; el compilador inserta la llamada a drop en el punto exacto donde el valor muere, ni antes ni después.
Conviene fijar el matiz: “coste cero” describe lo que ocurre en ejecución, no en todo el ciclo de vida. El precio existe, pero se paga en otra moneda y en otro momento —tiempo de compilación y tamaño del binario, por la monomorfización del nivel 14—. Y hay un tercer coste que la frase silencia y conviene nombrar con honestidad: el coste cognitivo. async/await es de coste cero en ejecución pero impone una carga real de complejidad al programador. La abstracción no cuesta ciclos; a veces cuesta neuronas.
Verificarlo: leer el ensamblador
La única prueba definitiva de coste cero es mirar el código máquina. La herramienta idiomática en Rust es cargo-show-asm, que compila en modo release y te muestra el ensamblador de una función concreta:
cargo install cargo-show-asm
# Muestra el asm de una funcion; --rust intercala el codigo fuente.
cargo asm --rust mi_crate::suma_iter
El obstáculo recurrente es que el optimizador es demasiado bueno: si una función no se usa o su resultado se descarta, la elimina entera y no verás nada que inspeccionar. Dos utilidades lo evitan. std::hint::black_box es una barrera opaca que obliga al compilador a materializar un valor como si tuviera efectos observables, y #[inline(never)] conserva la función como símbolo propio en vez de fundirla en quien la llama.
use std::hint::black_box;
#[inline(never)] // conserva el simbolo para poder inspeccionarlo
fn producto(a: &[i64]) -> i64 {
a.iter().product()
}
fn main() {
let datos = black_box(vec![1_i64, 2, 3, 4, 5]);
println!("{}", producto(black_box(&datos))); // black_box impide que se plegue
}
Para exploración rápida y compartible, Compiler Explorer (godbolt.org) hace lo mismo en el navegador: pegas el código, eliges el canal de Rust y las banderas —-C opt-level=3—, y ves el ensamblador coloreado junto a la fuente. Es el instrumento perfecto para responder la pregunta que cierra la promesa: ¿de verdad este iterador compila al mismo bucle que el índice manual?. Compila ambas versiones y compara; casi siempre son idénticas, y cuando el iterador difiere, suele ser porque ganó al eliminar una comprobación.
flowchart TB A[Codigo idiomatico iteradores genericos y newtypes] --> B[El compilador monomorfiza e inlinea] B --> C[Ensamblador tan bueno como el escrito a mano] C --> V1[cargo asm inspecciona el binario local] C --> V2[Compiler Explorer godbolt en el navegador] V1 --> D[Confirmas el coste cero con tus ojos] V2 --> D style A fill:#cba6f7,color:#11111b style C fill:#a6e3a1,color:#11111b style D fill:#89b4fa,color:#11111b
cargo-show-asm
cargo asm ruta::funcion muestra el asm, la IR de LLVM (--llvm) o la MIR de una función en release. Local y preciso.
Compiler Explorer
godbolt.org: asm coloreado junto a la fuente, con banderas ajustables. Ideal para comparar dos versiones y compartir el resultado.
black_box e inline(never)
Barreras contra un optimizador que, de tan bueno, borra lo que querías medir. Sin ellas, inspeccionas una función que ya no existe.
Muchos lenguajes prometen que sus abstracciones son “eficientes”, pero esa palabra es humo hasta que puedes verificarla, y casi ninguno te da los medios para hacerlo. La grandeza de Rust no es solo que sus abstracciones sean de coste cero; es que la afirmación es falsable y tú tienes el instrumento para falsarla. Cuando Stroustrup enunció el principio de sobrecoste cero, lo hizo como un ideal de diseño que los implementadores debían perseguir; Rust lo convierte en un contrato que el programador puede exigir y comprobar, abriendo el ensamblador como quien abre el capó de un coche. Esa comprobabilidad cambia tu relación con la abstracción entera. Dejas de elegir entre “escribo elegante y rezo” y “escribo feo pero sé que es rápido”, porque puedes escribir elegante y saber que es rápido, con la certeza que da haber leído las instrucciones exactas. Y la sorpresa pedagógica, la que reordena la intuición de todo el que viene de C, es descubrir que el iterador no es un impuesto sobre el rendimiento sino a menudo un descuento: al codificar en el sistema de tipos la garantía de que el recorrido no se sale de rango, le regala al optimizador una prueba que el índice manual le oculta, y esa prueba se traduce en comprobaciones de límites eliminadas y bucles vectorizados. La lección profunda es que la expresividad, bien diseñada, no es enemiga del rendimiento sino su aliada: cuanto más sabe el compilador sobre tu intención —y las abstracciones de Rust existen precisamente para decírsela con precisión—, mejor código puede generar. Auditar el asm de vez en cuando no es paranoia; es la manera de interiorizar que en Rust puedes abstraer sin culpa, porque la culpa se disuelve en el momento en que ves, con tus propios ojos, que la abstracción se evaporó.
Coste cero significa dos cosas exactas: lo que no usas no lo pagas, y lo que usas no podrías escribirlo mejor a mano. Lo cumplen los iteradores (que además eliminan comprobaciones de límites y a veces baten al índice), el Option de un puntero (mismo tamaño por la optimización de nicho), los newtypes (repr(transparent) garantiza layout idéntico) y Drop (liberación sin recolector ni indirección). El precio no desaparece: se paga en compilación y tamaño de binario, y a veces en carga cognitiva. Y todo es auditable: cargo asm en local y Compiler Explorer en el navegador te muestran el ensamblador; black_box e #[inline(never)] evitan que el optimizador borre lo que quieres ver.
- Escribe
suma_indexadaysuma_itersobre un&[i32], compílalas concargo asm --rusten release y compara el ensamblador. Localiza (o su ausencia) las comprobaciones de límites. - Comprueba con
size_ofqueOption<&i32>yOption<Box<i32>>ocupan lo mismo que su contenido, y explica qué valor usa el compilador para codificarNone. - Define
#[repr(transparent)] struct Metros(f64)y una función que sume dosMetros; verifica en godbolt que su asm es idéntico al de sumar dosf64. - Toma una función cuyo resultado descartas y observa cómo el optimizador la elimina; envuelve entrada y salida en
black_boxy confirma que reaparece en el asm. - Argumenta, con un ejemplo concreto, por qué la frase “coste cero” describe el binario en ejecución y no el ciclo completo; nombra las tres monedas en que sí se paga.