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.
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.
- Distinguir la firma de dos
TokenStreamde un attribute macro: los argumentos del atributo y el item anotado. - Parsear el item con
syn, típicamente a unItemFn, 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.
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
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.
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.
- Compila
#[cronometrado], aplícalo a una función con unreturnanticipado y confirma que el cronómetro se imprime igualmente; explica qué garantía del lenguaje lo asegura. - 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? - Extiende la macro para leer un argumento del atributo, como
#[cronometrado(umbral = 5)], y que solo imprima si la duración supera ese umbral; parseaattrconsyn. - 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. - Usa
cargo expandsobre 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.