wandres.dev
MACROS PROCEDURALES · derive y attribute

Attribute macros: reescribir un item entero

Donde un derive solo añade código, un attribute macro reemplaza por completo el item que decora. Su firma tiene dos TokenStream —los argumentos del atributo y el item anotado— y su superpoder es reescribir una función de arriba abajo. Construimos un #[cronometrado] que envuelve el cuerpo para medir su duración, y vemos por qué esta es la maquinaria tras #[tokio::main] y las rutas de los frameworks web.

⏱ 19 min

Un derive es discreto: mira tu tipo y añade un impl a su lado, sin tocar lo que ya escribiste. Un attribute macro es intervencionista: recibe el item que decora y devuelve otro item que lo sustituye por completo. Puede envolver el cuerpo de una función, inyectar código antes y después, cambiar su firma, registrarla en una tabla o incluso descartarla. Esa potencia de reescritura total es la que explica magias que parecían del compilador y no lo son: #[tokio::main] convierte tu async fn main en un fn main corriente que arranca un runtime; #[test] envuelve tu función para que el arnés la descubra; y las rutas #[get("/ruta")] de un framework web transforman una función en un manejador registrado. Todas son attribute macros, y en esta lección construiremos una desde cero para desmitificarlas.

🎯 Al terminar esta lección sabrás
  • Distinguir la firma de dos TokenStream de un attribute macro: los argumentos del atributo y el item anotado.
  • Parsear el item con syn, típicamente a un ItemFn, y acceder a su firma, cuerpo y visibilidad.
  • Reescribir la función envolviendo su cuerpo, preservando firma, atributos y valor de retorno.
  • Reconocer el patrón de reescritura tras #[tokio::main], #[test] y las rutas de los frameworks.

Dos TokenStream: los argumentos y el item

La firma de un attribute macro tiene dos parámetros, no uno. El primero, attr, son los tokens que van dentro del atributo entre paréntesis —si el usuario escribe #[ruta("/usuarios", GET)], attr contiene "/usuarios", GET—; está vacío si el atributo se usa desnudo. El segundo, item, son los tokens del elemento decorado: una función, un struct, un módulo. La salida reemplaza a item por entero.

use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn mi_atributo(attr: TokenStream, item: TokenStream) -> TokenStream {
    // attr: los argumentos del atributo, p. ej. lo que va entre parentesis.
    // item: el item anotado, que devolvemos reemplazado.
    item
}

Esa asimetría respecto al derive es la clave conceptual. El derive acompaña; el attribute sustituye. Por eso el attribute puede hacer cosas que el derive no puede ni soñar: cambiar el cuerpo de una función, alterar su firma, o descartarla y emitir algo distinto en su lugar.

Construir #[cronometrado]: envolver el cuerpo de una función

Queremos un atributo que mida cuánto tarda una función y lo imprima al terminar, sin que el autor cambie una línea de su lógica:

#[cronometrado]
fn calculo_pesado(n: u64) -> u64 {
    (0..n).sum()
}
// Al llamarla, ademas de devolver su resultado, imprime por stderr:
// calculo_pesado tardo 1.2ms

La implementación parsea el item como ItemFn —el AST de una función completa—, desarma sus partes y las vuelve a ensamblar con un cronómetro alrededor del cuerpo original:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};

#[proc_macro_attribute]
pub fn cronometrado(_attr: TokenStream, item: TokenStream) -> TokenStream {
    // Parsear la funcion anotada a su AST.
    let funcion = parse_macro_input!(item as ItemFn);

    let firma = &funcion.sig;          // fn nombre(args) -> Ret, con genericos
    let vis = &funcion.vis;            // pub, pub(crate) o nada
    let attrs = &funcion.attrs;        // otros atributos sobre la funcion
    let cuerpo = &funcion.block;       // el bloque { ... } original
    let nombre = &funcion.sig.ident;   // el identificador, para el mensaje

    // Reconstruir la funcion: misma firma, cuerpo envuelto en un cronometro.
    let expandido = quote! {
        #(#attrs)*
        #vis #firma {
            // Un guarda que imprime la duracion al salir del ambito,
            // incluso si el cuerpo retorna antes de tiempo.
            struct __Cronometro(std::time::Instant, &'static str);
            impl Drop for __Cronometro {
                fn drop(&mut self) {
                    eprintln!("{} tardo {:?}", self.1, self.0.elapsed());
                }
            }
            let __guarda = __Cronometro(std::time::Instant::now(), stringify!(#nombre));

            #cuerpo
        }
    };

    expandido.into()
}

El truco está en la reconstrucción fiel. Reemitimos #(#attrs)*, #vis y #firma tal cual venían, de modo que la función conserva su nombre, sus argumentos, su tipo de retorno y sus genéricos: para quien la llama, es indistinguible de la original. Lo único que cambia es el interior: antes del #cuerpo original insertamos un guarda que arranca un cronómetro y, gracias a Drop, imprime la duración cuando la función termina —por su tail o por un return anticipado, da igual, porque Drop se ejecuta en todos los caminos de salida—. El valor de retorno fluye intacto: #cuerpo sigue siendo la última expresión del bloque, así que lo que la función devolvía, lo sigue devolviendo.

💡
El guarda con Drop preserva los retornos anticipados

Envolver el cuerpo en un cierre (|| #cuerpo)() es una tentación, pero cambia la semántica de return y del operador ?. El guarda con Drop es superior: no altera el flujo de control del cuerpo en absoluto, y la medición se cierra sola en cualquier punto de salida —tail, return, propagación de error— porque el destructor corre siempre. Es RAII puesto al servicio de la metaprogramación: la limpieza garantizada del lenguaje se convierte en la garantía de que el cronómetro siempre se detiene.

Por qué así funcionan tokio::main y los frameworks

Con este patrón en la mano, la magia del ecosistema se vuelve transparente. #[tokio::main] es un attribute macro que toma tu async fn main, extrae su cuerpo asíncrono y lo reescribe como un fn main síncrono que crea un runtime y bloquea sobre ese cuerpo. No hay nada especial en el compilador: es reescritura de item, la misma que acabas de programar.

// Lo que tu escribes:
// #[tokio::main]
// async fn main() { trabajo().await; }

// En lo que, a grandes rasgos, lo reescribe la macro:
fn main() {
    tokio::runtime::Runtime::new()
        .unwrap()
        .block_on(async { trabajo().await; });
}

Los frameworks web hacen lo análogo: #[get("/ruta")] sobre una función manejador genera, además de la función, el andamiaje que la registra como ruta —a menudo un tipo o una entrada en una tabla que el router recorrerá—. El atributo attr transporta la ruta y el método; el item es el manejador; la salida es el manejador más su registro. Reescribir un item es, resulta, una de las herramientas más productivas de todo Rust aplicado.

flowchart TD
A[Atributo sobre una funcion] --> B[attr con los argumentos]
A --> C[item con la funcion anotada]
C --> D[syn parsea a ItemFn]
D --> E[Desarmar firma vis attrs y cuerpo]
E --> F[quote reensambla con codigo inyectado]
B --> F
F --> G[La funcion original queda reemplazada]
style D fill:#89b4fa,color:#11111b
style F fill:#cba6f7,color:#11111b
style G fill:#a6e3a1,color:#11111b
El atributo es una transformación de programas, y por eso es tan peligroso como poderoso

Conviene entender el attribute macro por lo que lo separa del derive, porque esa diferencia es un cambio de categoría, no de grado. El derive es aditivo y por ello inofensivo: contempla tu tipo y engendra código nuevo a su lado, pero jamás altera lo que escribiste; el struct que lees es el struct que se compila. El attribute es transformador: lo que escribes ya no es necesariamente lo que se compila. Tu async fn main desaparece y en su lugar corre un fn main que tú nunca tecleaste; tu función síncrona adquiere un cronómetro que no está en su código fuente. Esto otorga un poder inmenso —envolver, instrumentar, registrar, reescribir la firma, tejer aspectos transversales como logging o autorización sin ensuciar la lógica— y es la razón de que frameworks enteros se construyan sobre atributos: permiten que el usuario escriba lo que quiere decir mientras la macro genera lo que hay que hacer. Pero el mismo poder es una responsabilidad afilada. Un attribute macro puede volver el código fuente engañoso: si abusa, lo que el lector ve deja de corresponderse con lo que se ejecuta, y depurar se convierte en arqueología, porque el error salta en líneas que nadie escribió. La disciplina que distingue a las buenas macros de las malas es una sola: transformar de forma predecible y mínima, respetando la firma, preservando el valor de retorno, sin sorpresas en el flujo de control, de modo que el lector pueda razonar sobre el código como si la macro no estuviera y solo consulte su expansión cuando de verdad importe. Herramientas como cargo expand existen precisamente para reabrir esa caja y mostrar el código real generado. La lección de fondo es que el attribute te entrega la tijera de reescribir programas, y con ella el deber de que la reescritura siga siendo comprensible: el poder de cambiar lo que corre conlleva el deber de no traicionar lo que se lee.

📝
Lo esencial de los attribute macros

Un attribute macro tiene la firma (attr: TokenStream, item: TokenStream) -> TokenStream: attr son los argumentos entre paréntesis del atributo, item es el elemento decorado, y la salida lo reemplaza por completo —a diferencia del derive, que solo añade—. El patrón típico parsea el item a ItemFn, desarma su firma, visibilidad, atributos y cuerpo, y los reensambla con código inyectado. Un guarda con Drop alrededor del cuerpo instrumenta la función sin alterar sus retornos. Así funcionan #[tokio::main] (reescribe async fn main en un fn main con runtime), #[test] y las rutas de los frameworks web. Su poder de reescritura exige disciplina: transformar de forma predecible y mínima.

⚔️ Domina la reescritura de items
  1. Compila #[cronometrado], aplícalo a una función con un return anticipado y confirma que el cronómetro se imprime igualmente; explica qué garantía del lenguaje lo asegura.
  2. Comprueba que la función conserva su firma exacta llamándola desde otro sitio con sus argumentos y usando su valor de retorno; ¿qué habría pasado si hubieras olvidado reemitir #firma?
  3. Extiende la macro para leer un argumento del atributo, como #[cronometrado(umbral = 5)], y que solo imprima si la duración supera ese umbral; parsea attr con syn.
  4. Escribe, en pseudocódigo o de verdad, la reescritura que haría #[tokio::main] y razona por qué necesita ser un attribute macro y no un derive.
  5. Usa cargo expand sobre una función anotada con tu macro y contrasta el código generado con el que escribiste; explica por qué esta herramienta es indispensable para depurar attribute macros.