wandres.dev
MACROS PROCEDURALES · derive y attribute

Macros procedurales: código que se ejecuta en el compilador

Una macro procedural no es una plantilla de sustitución como macro_rules!: es un programa Rust completo que el compilador carga y ejecuta a mitad de la compilación, recibe tu código representado como datos —un TokenStream— y devuelve otro TokenStream que lo reemplaza. Qué son, por qué corren en compilación y cuáles son los tres tipos: derive, attribute y function-like.

⏱ 18 min

Hasta ahora, todo el código que has escrito corría después de compilar: el compilador lo traducía a máquina y el programa lo ejecutaba más tarde. Una macro procedural rompe esa cronología. Es una función Rust ordinaria —con su cuerpo, sus bucles, sus dependencias— que el compilador carga y ejecuta a mitad de la compilación, entregándole un fragmento de tu propio programa convertido en datos. La función lo inspecciona, lo transforma y devuelve código nuevo que el compilador inserta como si tú lo hubieras tecleado. No es una plantilla de sustitución textual como macro_rules!: es un programa arbitrario que se ejecuta sobre la representación tokenizada de otro programa. Esa capacidad —código que, en tiempo de compilación, escribe código— es la que sostiene #[derive(Debug)], #[tokio::main] y la ergonomía de medio ecosistema de Rust.

🎯 Al terminar esta lección sabrás
  • Entender una macro procedural como una función que recibe y devuelve un TokenStream y corre en compilación.
  • Interiorizar el TokenStream como código representado como datos: una secuencia de tokens, ni texto ni AST completo.
  • Nombrar y distinguir los tres tipos —derive, attribute y function-like— con su firma exacta.
  • Saber por qué una proc-macro exige un crate propio de tipo proc-macro y en qué se separa de macro_rules!.

El compilador te cede el control, a mitad de compilación

Cuando rustc tropieza con la invocación de una macro procedural, hace algo insólito: se detiene, llama a tu función pasándole los tokens implicados, espera a que devuelva otros tokens y los injerta en el árbol sintáctico antes de seguir. Para que eso sea posible, tu proc-macro se compila primero, como una biblioteca que el compilador carga y ejecuta como un plugin. La consecuencia es literal y a veces desconcertante: si tu macro tiene un bucle infinito, el compilador se cuelga; si hace panic!, obtienes un error de compilación; si lee un fichero, lo lee durante la build. No estás describiendo una transformación de forma declarativa: estás ejecutando un programa dentro del proceso del compilador.

Esa naturaleza —cómputo real en una fase previa a la ejecución— es lo que en la literatura se llama metaprogramación en tiempo de compilación o staged computation: una etapa del programa que produce la siguiente etapa. La firma canónica de las tres variantes lo revela: todas son funciones cuyo dominio y codominio es el mismo tipo, TokenStream.

ℹ️
La macro corre en la máquina que compila, no en la que ejecuta

Una proc-macro se ejecuta en el host de compilación, no en el target. Si compilas de forma cruzada para un microcontrolador, la macro corre en tu portátil con toda la biblioteca estándar disponible, aunque el binario final no tenga sistema operativo. Por eso una proc-macro puede leer ficheros, consultar variables de entorno o generar tablas: dispone del entorno completo de la build, no de las restricciones del destino.

Código como datos: el TokenStream

El tipo que entra y sale, proc_macro::TokenStream, es una secuencia de tokens: identificadores, literales, signos de puntuación y grupos delimitados por llaves, corchetes o paréntesis. No es texto plano —ya está léxicamente analizado, así que foo es un identificador y no cinco caracteres— pero tampoco es todavía un árbol sintáctico con significado semántico. Es el nivel intermedio exacto: lo bastante estructurado para recorrerlo sin volver a tokenizar, lo bastante crudo para que puedas emitir cualquier construcción del lenguaje.

Pensar en el código como en un dato manipulable es una idea vieja y poderosa: es el corazón de las macros de Lisp, donde el programa es una lista que otro programa modifica. Rust hereda ese linaje, pero con una diferencia crucial: no expone un AST universal y mutable, sino este TokenStream deliberadamente humilde. Trabajar con tokens crudos a mano es tedioso, y por eso casi nadie lo hace: en la lección siguiente veremos que syn los eleva a un AST tipado y quote! construye tokens nuevos con comodidad. Pero el contrato con el compilador —lo que de verdad cruza la frontera— son siempre tokens.

flowchart LR
A[Codigo fuente con la invocacion] --> B[El compilador extrae el TokenStream]
B --> C[Tu funcion proc-macro se ejecuta]
C --> D[Devuelve un TokenStream nuevo]
D --> E[El compilador lo injerta y sigue compilando]
style C fill:#cba6f7,color:#11111b
style D fill:#89b4fa,color:#11111b
style E fill:#a6e3a1,color:#11111b

Los tres tipos de macro procedural

Rust ofrece exactamente tres formas de proc-macro, y se distinguen por cómo se invocan y qué firma tienen. Todas viven, obligatoriamente, en un crate marcado con proc-macro = true en su Cargo.toml.

use proc_macro::TokenStream;

// 1. Derive: se activa con #[derive(MiTrait)] sobre un struct o enum.
//    Recibe el item anotado; su salida se ANADE junto a el (no lo sustituye).
#[proc_macro_derive(MiTrait)]
pub fn derivar_mitrait(input: TokenStream) -> TokenStream {
    TokenStream::new() // aqui se generaria, p. ej., un impl MiTrait for ...
}

// 2. Attribute: se activa con #[mi_atributo] sobre casi cualquier item.
//    attr son los tokens del propio atributo; item es lo anotado, que se REEMPLAZA.
#[proc_macro_attribute]
pub fn mi_atributo(attr: TokenStream, item: TokenStream) -> TokenStream {
    item // por ahora, devuelve el item intacto
}

// 3. Function-like: se activa con mi_macro!(...) en cualquier posicion.
//    Recibe los tokens entre los delimitadores; los reemplaza por su salida.
#[proc_macro]
pub fn mi_macro(input: TokenStream) -> TokenStream {
    input
}

Las diferencias no son cosméticas. El derive solo añade código —jamás toca el struct original, únicamente genera items nuevos a su lado, típicamente un impl— y por eso es el más seguro y el más común. El attribute reemplaza por completo el item que decora, lo que le permite reescribir una función entera: es la maquinaria tras #[tokio::main] o las rutas de un framework web. El function-like se parece a macro_rules! en la sintaxis de llamada, nombre!(...), pero por dentro es un programa arbitrario, no un conjunto de reglas de patrones; es la vía para domain-specific languages como sqlx::query! o html!.

🧬

Derive

#[proc_macro_derive(Nombre)], invocado con #[derive(Nombre)]. Añade items junto a un struct o enum, casi siempre un impl. El más común y el más inofensivo, porque nunca altera lo anotado.

🎀

Attribute

#[proc_macro_attribute], invocado con #[mi_atributo]. Recibe dos TokenStream —los argumentos y el item— y reemplaza el item. Reescribe funciones enteras: #[tokio::main], #[test], rutas web.

🔧

Function-like

#[proc_macro], invocado con nombre!(...). Como macro_rules! en la llamada, pero por dentro es un programa. La vía para los DSL: sqlx::query!, html!.

macro_rules! y proc-macro: dos familias distintas

Comparten nombre y fase —ambas se expanden en compilación— pero son mecanismos de naturaleza opuesta. Una macro_rules! es un conjunto de reglas de patrón: empareja la forma sintáctica de su entrada y la reescribe según plantillas fijas, sin ejecutar lógica imperativa ni consultar dependencias, y es higiénica por defecto. Una proc-macro es un programa que corre: itera, cuenta, ramifica, lee ficheros, invoca syn para inspeccionar tipos. La declarativa vive en cualquier módulo del crate que ya tienes; la procedural exige crate propio y arrastra syn y quote.

// Declarativa: sustitucion de patrones, en el mismo crate, sin dependencias.
macro_rules! cuadrado {
    ($x:expr) => { $x * $x };
}
let n = cuadrado!(7); // se expande literalmente a 7 * 7

// Procedural: un programa que corre en compilacion, en un crate aparte.
// #[derive(Serialize)] es imposible con macro_rules!, porque necesita
// LEER los campos del struct: eso es introspeccion, no sustitucion.

La frontera es nítida: si basta con reescribir una forma sintáctica, macro_rules! sobra y es más barato; si necesitas inspeccionar la estructura del código —cuántos campos hay, de qué tipo es cada uno—, solo una proc-macro puede, porque solo ella ejecuta código sobre el AST. La última lección del nivel volverá sobre este criterio para decidir cuándo el poder de la proc-macro justifica su coste.

Una proc-macro es el compilador prestándote su propia reflexión

Detente en lo que de verdad ocurre aquí, porque es más profundo que una comodidad sintáctica. En la mayoría de lenguajes, la barrera entre compilar y ejecutar es infranqueable: el compilador es una caja negra que traduce, y tu código no puede asomarse dentro ni intervenir en el proceso. Una macro procedural perfora esa barrera. El compilador, al llegar a la invocación, hace una pausa y te entrega las llaves de su fase de expansión: te pasa un pedazo del programa convertido en datos y ejecuta tu lógica para decidir qué código nace en su lugar. Es una forma de reflexión en tiempo de compilación —la capacidad de un programa de examinar y generar la estructura de otro programa— y su árbol genealógico se remonta a las macros de Lisp de los años sesenta, donde por primera vez el código y los datos compartieron una misma representación manipulable. Rust hace esa idea segura y tipada: el cómputo ocurre antes de que el binario exista, así que no cuesta un solo ciclo en ejecución, y el resultado vuelve a pasar por el verificador de tipos y el borrow checker como cualquier otro código, de modo que una macro no puede inyectar clandestinamente algo inseguro. Esta es la razón de que las proc-macros se sientan a la vez omnipotentes y contenidas: pueden generar arbitrariamente mucho código —un impl completo, una función reescrita, un DSL entero— pero lo generan hacia un lenguaje que sigue exigiendo pruebas de corrección. Comprende esto y comprenderás por qué Rust delega tanta ergonomía a las macros sin traicionar sus garantías: el poder de escribir código está desatado, pero el poder de romper el sistema de tipos, no. La macro propone; el compilador, imperturbable, dispone.

📝
Lo esencial de qué son las macros procedurales

Una macro procedural es una función Rust que el compilador ejecuta en tiempo de compilación; recibe tu código como un TokenStream —una secuencia de tokens, código representado como datos— y devuelve otro TokenStream que lo reemplaza o lo acompaña. Corre en la máquina que compila, con la biblioteca estándar completa, y su salida vuelve a verificarse como cualquier código. Existen tres tipos: derive (#[proc_macro_derive], añade items junto a un struct o enum), attribute (#[proc_macro_attribute], reemplaza el item que decora) y function-like (#[proc_macro], se invoca con nombre!(...)). A diferencia de macro_rules!, no es sustitución de patrones sino un programa arbitrario, y por eso exige un crate propio de tipo proc-macro.

⚔️ Reconoce el tipo por la firma
  1. Escribe las tres firmas de proc-macro de memoria e indica, para cada una, con qué sintaxis se invoca desde el código de un usuario.
  2. Explica por qué la salida de un derive se añade al struct mientras que la de un attribute lo reemplaza, y qué implica esa diferencia para lo que cada tipo puede hacer.
  3. Argumenta qué ocurre en la build si tu proc-macro entra en un bucle infinito o hace panic!, y por qué esa consecuencia se deduce de que la función corre en el compilador.
  4. Da un ejemplo real del ecosistema para cada uno de los tres tipos y justifica por qué esa herramienta eligió esa variante y no otra.
  5. Contrasta un TokenStream con, por un lado, una cadena de texto y, por otro, un árbol sintáctico completo: ¿qué información tiene ya resuelta y cuál todavía no?