wandres.dev
MACROS DECLARATIVAS · macro_rules!

macro_rules!: metaprogramación por ejemplo

Una macro es código que escribe código. macro_rules! transforma fragmentos de sintaxis en tiempo de compilación emparejando árboles de tokens, no texto crudo. Por eso las macros de Rust son higiénicas y seguras donde el preprocesador de C es un campo de minas de precedencia.

⏱ 20 min

Una macro es código que escribe código. Mientras una función transforma valores en tiempo de ejecución, una macro transforma fragmentos de sintaxis en tiempo de compilación: recibe un trozo de programa y lo reescribe en otro, antes de que el comprobador de tipos vea nada. macro_rules! es la puerta de entrada a esa metaprogramación en Rust, y su apodo lo dice todo: son macros por ejemplo. Escribes un patrón que se parece a la llamada y una plantilla con el código que debe generarse, y el compilador hace de calco. Pero la diferencia con el #define de C no es cosmética: las macros de Rust operan sobre árboles de tokens ya estructurados, no sobre texto crudo, y de ahí nacen su higiene y su seguridad allí donde las de C son un campo de minas.

🎯 Al terminar esta lección sabrás
  • Entender qué es una macro y en qué se diferencia de una función.
  • Leer y escribir la estructura básica de macro_rules!: patrón y expansión.
  • Comprender que Rust empareja árboles de tokens, no texto.
  • Ver por qué eso elimina de raíz los bugs de precedencia del preprocesador de C.

Función frente a macro: dos planos distintos

Una función opera sobre valores ya evaluados y con una firma fija: recibe tantos argumentos de tantos tipos y devuelve uno. Una macro opera un plano más abajo, sobre la sintaxis sin evaluar, y su interfaz no tiene por qué ser fija. Esa es la razón de existir de las macros que ya usas a diario. println! acepta un número variable de argumentos y valida la cadena de formato en compilación; vec! construye un Vec a partir de una lista arbitraria; assert_eq! imprime, si falla, el texto literal de las expresiones comparadas. Ninguna de las tres es expresable como una función normal, porque su interfaz —variádica, o que inspecciona el código fuente— vive por debajo del sistema de tipos.

La macro más simple que existe no toma nada y expande a una sentencia:

macro_rules! saluda {
    () => {
        println!("Hola desde una macro");
    };
}

fn main() {
    saluda!(); // durante la compilacion, esto se sustituye por el println!
}

El ! marca una invocación de macro, y la distingue a simple vista de una llamada a función. Antes de compilar el cuerpo de main, el compilador reemplaza saluda!() por los tokens de la expansión. Cuando la comprobación de tipos entra en escena, la macro ya no existe: solo queda el println! que generó.

ℹ️
Los delimitadores de la invocación son intercambiables

Habrás visto vec![...] con corchetes, println!(...) con paréntesis y macro_rules! con llaves. No es casual ni caprichoso: en el sitio de la llamada, los tres delimitadores son intercambiables para el analizador. vec!(1, 2, 3) y vec! { 1, 2, 3 } compilan igual que vec![1, 2, 3]. La elección es una convención de legibilidad: corchetes para lo que evoca una colección, llaves para lo que evoca un bloque o un cuerpo, y paréntesis para lo demás. Lo único rígido es que una invocación en posición de sentencia terminada en ) o ] necesita su punto y coma, mientras que la terminada en } no lo lleva, igual que un bloque.

La anatomía de macro_rules!: un match sobre sintaxis

La estructura de macro_rules! es deliberadamente parecida a la de un match: una lista de reglas, cada una de la forma (patrón) => { expansión };. El compilador prueba las reglas de arriba abajo y la primera cuyo patrón cuadra con los tokens de la invocación gana; su expansión sustituye la llamada. Dentro del patrón, una metavariable $nombre:fragmento captura un pedazo de sintaxis para reutilizarlo en la expansión.

macro_rules! cuadrado {
    ($x:expr) => {
        $x * $x
    };
}

Aquí $x:expr captura una expresión completa y le da el nombre $x; en la plantilla, cada $x se sustituye por lo capturado. El modelo mental es exacto y conviene grabarlo: un match compara valores y devuelve valores; macro_rules! compara árboles de tokens y devuelve árboles de tokens. Todo lo demás son detalles de esa idea.

💡
Ve la expansión con cargo expand

La mejor forma de entender una macro es leer lo que genera. cargo install cargo-expand y luego cargo expand imprime tu crate con todas las macros ya expandidas. Es un rayos X sobre el codegen: verás el Vec::new que esconde vec!, el bucle que esconde println! y el código exacto que produce cualquier macro tuya. Depurar macros a ciegas es un suplicio; con cargo expand es trivial.

Árboles de tokens, no texto: por qué C falla y Rust no

Aquí está el corazón del asunto. El preprocesador de C hace sustitución textual: pega caracteres antes de que el compilador entienda de gramática. El resultado es el bug más clásico del mundo de las macros:

#define CUADRADO(x) x * x
CUADRADO(1 + 2)   // se expande a  1 + 2 * 1 + 2  ==  5, no 9

Como es texto, la precedencia se rompe: el * se cuela entre el 2 y el 1 vecinos. La defensa habitual en C es parentizar de forma obsesiva —#define CUADRADO(x) ((x) * (x))— y no olvidarse jamás de un paréntesis.

En Rust nada de esto ocurre, porque $x:expr no captura texto: captura 1 + 2 como un nodo de expresión completo y opaco. Al sustituirlo, se comporta como si estuviera plenamente parentizado, porque es una unidad atómica de la gramática, no una tira de caracteres. La precedencia queda sellada dentro del fragmento:

fn main() {
    assert_eq!(cuadrado!(1 + 2), 9); // nueve, no cinco: la precedencia se respeta
}

Ahora el matiz de nivel PhD. Rust cura el peligro sintáctico —la precedencia— pero no el peligro semántico de la evaluación múltiple. El fragmento $x se inserta dos veces en la plantilla, así que si lleva efectos, esos efectos ocurren dos veces:

let mut n = 0;
let _ = cuadrado!({ n += 1; n }); // n se incrementa dos veces

El remedio no es parentizar —eso ya lo hace Rust por ti— sino ligar el argumento a un nombre una sola vez, evaluándolo una única vez, dentro de un bloque de expansión:

macro_rules! cuadrado {
    ($x:expr) => {{
        let v = $x;   // evaluado exactamente una vez
        v * v
    }};
}

Fíjate en la llave doble {{ }}: la externa pertenece a la sintaxis de macro_rules!, y la interna es un bloque de Rust, de modo que la macro expande a una expresión de bloque que evalúa el argumento, lo guarda y opera. Y una garantía final: tras la expansión, el código generado pasa por el sistema de tipos y el borrow checker como cualquier otro. Una macro no puede colar código mal tipado; si expande a un disparate, obtienes un error de compilación normal —a veces señalando dentro de la expansión, pero un error honesto al fin y al cabo.

flowchart LR
src[Codigo fuente] --> tok[Tokenizador]
tok --> mac[Expansion de macro_rules]
mac --> ast[Arbol de sintaxis]
ast --> chk[Tipos y borrow checker]
style src fill:#cba6f7,color:#11111b
style mac fill:#89b4fa,color:#11111b
style chk fill:#a6e3a1,color:#11111b
Operar al nivel de la gramática, no por debajo de ella

La distancia entre el #define de C y macro_rules! de Rust es, en el fondo, la distancia entre trabajar por debajo del lenguaje y trabajar dentro de él. El preprocesador de C es un manipulador de cadenas que actúa antes de que exista noción alguna de expresión, de tipo o de ámbito: para él, 1 + 2 no es una suma, son cinco caracteres, y por eso puede destrozar la precedencia, evaluar dos veces sin avisar y filtrar nombres al código que lo rodea. Es potente precisamente porque es ciego, y es peligroso por la misma razón. Rust toma la decisión opuesta y más profunda: sus macros no ven texto, ven la misma estructura que ve el compilador, el árbol de tokens ya organizado en unidades gramaticales. Un expr capturado es una expresión de verdad, atómica e inquebrantable, no una tira de símbolos que el siguiente operador pueda partir por la mitad. Ese único cambio de nivel —de los caracteres a los tokens— es lo que convierte la metaprogramación de una técnica temida, rodeada de convenciones defensivas y nombres feos con guiones bajos, en una herramienta de primera clase que respeta la precedencia, comprende el ámbito y se somete al sistema de tipos. La lección trasciende a las macros: cuando construyas una herramienta que manipule un lenguaje, hazlo sobre su estructura, nunca sobre su superficie. La superficie miente; la estructura no. Rust eligió la estructura, y por eso sus macros son de las pocas del mundo de los lenguajes de sistemas en las que se puede confiar sin rezar.

📝
Lo esencial de macro_rules!

Una macro reescribe sintaxis en compilación, no valores en ejecución; el ! marca su invocación. macro_rules! es un match sobre árboles de tokens: reglas (patrón) => { expansión }, con metavariables $x:frag que capturan y se sustituyen. Como Rust empareja tokens y no texto, la precedencia queda sellada dentro de cada fragmento y desaparece el bug clásico de C, aunque la evaluación múltiple sigue siendo tu responsabilidad —lígala con let. Tras expandir, el código pasa por tipos y borrow checker como cualquier otro.

⚔️ Escribe tus primeras macros por ejemplo
  1. Define una macro di_hola! sin argumentos que expanda a un println! y llámala desde main; luego usa cargo expand para ver en qué se convirtió.
  2. Escribe cuadrado! en su versión ingenua y comprueba con assert_eq! que cuadrado!(1 + 2) vale nueve; explica por qué en C valdría cinco.
  3. Provoca la evaluación múltiple pasando un argumento con efectos y demuéstralo con un contador; reescribe la macro con un bloque y un let para evaluar una sola vez.
  4. Haz que cuadrado! reciba mal tipo, por ejemplo una cadena, y lee el error del compilador: razona en qué fase aparece y por qué la macro no lo detectó.
  5. Añade una segunda regla a una macro para que acepte tanto cero como un argumento, y explica cómo elige el compilador entre las reglas.