Macros procedurais são uma das ferramentas mais poderosas de metaprogramação em Rust. Diferentemente das macros declarativas (macro_rules!), que operam sobre padrões de tokens, as macros procedurais permitem que você escreva código Rust que recebe tokens de entrada e produz tokens de saída, possibilitando transformações complexas em tempo de compilação. Nesta aula, vamos explorar os fundamentos: os três tipos de macros proc, o crate proc_macro, as bibliotecas syn e quote, e os cuidados essenciais ao desenvolver suas próprias macros.

Se você já usou atributos como #[derive(Debug)] ou #[test], já viu macros procedurais em ação. Elas são amplamente utilizadas em crates populares como serde, tokio e diesel. Entender como elas funcionam abre portas para criar APIs elegantes e reduzir boilerplate.

Tipos de macros proc

Existem três tipos de macros procedurais em Rust, cada uma com um propósito específico:

  • Macros derivadas (derive macros): são usadas com #[derive(...)] para gerar implementações automáticas de traits. Por exemplo, #[derive(Clone)] gera a implementação de Clone para o tipo. Você pode criar suas próprias macros derive, como #[derive(MyTrait)].
  • Macros de atributo (attribute macros): são usadas como atributos em itens (funções, structs, módulos, etc.) e podem modificar o item ou gerar código adicional. Por exemplo, #[test] é uma macro de atributo que transforma uma função em um teste.
  • Macros do tipo função (function-like macros): são invocadas como funções, mas com sintaxe especial, como my_macro!(...). Elas podem receber qualquer sequência de tokens e retornar qualquer código. Um exemplo é println!, embora este seja declarativo; function-like procedurais oferecem mais flexibilidade.

Para criar uma macro procedural, você precisa de um crate separado (ou um módulo com proc-macro crate type) que exporta funções marcadas com atributos específicos. Cada tipo tem um atributo correspondente: #[proc_macro_derive], #[proc_macro_attribute] e #[proc_macro].

Exemplo de declaração de uma macro derive simples:

use proc_macro::TokenStream;

#[proc_macro_derive(MyTrait)]
pub fn my_trait_derive(input: TokenStream) -> TokenStream {
    // implementação
    input
}

proc_macro

O crate proc_macro é a base de todas as macros procedurais. Ele fornece o tipo TokenStream, que é uma sequência de tokens (identificadores, literais, pontuação, etc.) representando o código Rust. A função da macro recebe um TokenStream e retorna outro TokenStream.

Para usar macros procedurais, você deve configurar seu crate com o atributo proc-macro = true no Cargo.toml:

[lib]
proc-macro = true

Isso faz com que o crate seja compilado como uma biblioteca especial que só pode exportar macros procedurais. Você também pode usar proc_macro diretamente, mas é mais comum usar bibliotecas auxiliares como syn e quote para simplificar o processamento.

O tipo TokenStream pode ser convertido para strings e vice-versa, mas a manipulação direta é trabalhosa. Por isso, usamos syn para analisar a entrada em estruturas de dados tipadas e quote para gerar código de saída de forma ergonômica.

Exemplo de função que apenas repassa a entrada:

use proc_macro::TokenStream;

#[proc_macro]
pub fn passthrough(input: TokenStream) -> TokenStream {
    input
}

syn e quote (visão geral)

syn é uma biblioteca para analisar (parse) tokens Rust em uma árvore sintática abstrata (AST). Ela fornece tipos como DeriveInput, ItemFn, Attribute, etc., que representam construções da linguagem. Com syn, você pode extrair informações como nomes de structs, campos, visibilidade, etc., de forma segura e tipada.

quote é uma biblioteca para gerar código Rust a partir de templates. A macro quote! permite escrever código quase como uma string, mas com interpolação de variáveis. Por exemplo, quote! { #ident } insere o valor da variável ident no código gerado. Isso torna a geração de código muito mais legível e menos propensa a erros.

Juntas, syn e quote formam o par padrão para desenvolvimento de macros procedurais. Você adiciona as dependências no Cargo.toml:

[dependencies]
syn = { version = "2.0", features = ["full"] }
quote = "1.0"

Exemplo de uso de syn para analisar uma struct:

use syn::{parse_macro_input, DeriveInput};
use proc_macro::TokenStream;

#[proc_macro_derive(MyTrait)]
pub fn my_trait_derive(input: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(input as DeriveInput);
    let name = &ast.ident;
    // ...
    TokenStream::new()
}

Cuidados

Ao escrever macros procedurais, alguns cuidados são essenciais:

  • Erros de compilação: Use compile_error! ou syn::Error para reportar erros claros ao usuário. Se sua macro falhar, o compilador mostrará a mensagem na linha onde a macro é usada.
  • Higiene: Macros procedurais não são higiênicas por padrão, ou seja, podem introduzir nomes que colidem com itens do usuário. Use identificadores com prefixos ou use quote::format_ident! para gerar nomes únicos.
  • Performance: Macros são executadas em tempo de compilação, então código ineficiente pode aumentar o tempo de compilação. Evite loops desnecessários e parse complexos.
  • Compatibilidade: Lembre-se de que macros procedurais só podem ser usadas em crates que dependem do seu crate. Elas não podem ser exportadas de um crate binário.
  • Documentação: Documente bem suas macros, pois elas são uma API para os usuários. Inclua exemplos e avisos sobre limitações.

Exemplo de geração de código com quote e tratamento de erros:

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

#[proc_macro_derive(MyTrait)]
pub fn my_trait_derive(input: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(input as DeriveInput);
    let name = &ast.ident;
    if !matches!(ast.data, syn::Data::Struct(_)) {
        return syn::Error::new_spanned(&ast, "MyTrait só pode ser derivado em structs")
            .to_compile_error()
            .into();
    }
    let expanded = quote! {
        impl MyTrait for #name {
            fn hello(&self) {
                println!("Hello from {}", stringify!(#name));
            }
        }
    };
    expanded.into()
}

Boas práticas

Ao desenvolver macros procedurais, siga estas boas práticas:

  • Use syn e quote em vez de manipular TokenStream diretamente, para evitar erros.
  • Valide a entrada o mais cedo possível e retorne erros claros.
  • Teste suas macros com exemplos reais usando o crate trybuild para testes de compilação.
  • Mantenha a compatibilidade com versões anteriores se você publicar um crate.
  • Considere o impacto no tempo de compilação; macros complexas podem tornar a compilação lenta.

Referências

Exercícios

  1. Crie uma macro derive chamada Hello que implementa um trait Hello com um método hello() que imprime "Hello, <nome do tipo>!". A macro deve funcionar apenas em structs.

    ✓ Resposta:
    use proc_macro::TokenStream;
    use quote::quote;
    use syn::{parse_macro_input, DeriveInput, Data};
    
    #[proc_macro_derive(Hello)]
    pub fn hello_derive(input: TokenStream) -> TokenStream {
        let ast = parse_macro_input!(input as DeriveInput);
        let name = &ast.ident;
        if !matches!(ast.data, Data::Struct(_)) {
            return syn::Error::new_spanned(&ast, "Hello só pode ser derivado em structs")
                .to_compile_error()
                .into();
        }
        let gen = quote! {
            impl Hello for #name {
                fn hello(&self) {
                    println!("Hello, {}!", stringify!(#name));
                }
            }
        };
        gen.into()
    }
    
  2. Escreva uma macro de atributo chamada log que, aplicada a uma função, imprime uma mensagem de log antes e depois da execução da função.

    ✓ Resposta:
    use proc_macro::TokenStream;
    use quote::quote;
    use syn::{parse_macro_input, ItemFn, AttributeArgs};
    
    #[proc_macro_attribute]
    pub fn log(_attr: TokenStream, item: TokenStream) -> TokenStream {
        let input = parse_macro_input!(item as ItemFn);
        let fn_name = &input.sig.ident;
        let block = &input.block;
        let gen = quote! {
            fn #fn_name() {
                println!("Iniciando {}...", stringify!(#fn_name));
                #block
                println!("Finalizando {}.", stringify!(#fn_name));
            }
        };
        gen.into()
    }
    
  3. Implemente uma macro function-like chamada make_answer que retorna uma função answer() que retorna o inteiro 42.

    ✓ Resposta:
    use proc_macro::TokenStream;
    use quote::quote;
    
    #[proc_macro]
    pub fn make_answer(_input: TokenStream) -> TokenStream {
        quote! {
            fn answer() -> i32 { 42 }
        }.into()
    }
    
  4. Explique a diferença entre TokenStream e proc_macro2::TokenStream e por que syn e quote usam o tipo do crate proc-macro2.

    ✓ Resposta:

    proc_macro::TokenStream é o tipo nativo do compilador, mas não pode ser usado fora de um contexto de macro (por exemplo, em bibliotecas que são usadas por macros). proc_macro2::TokenStream é uma reimplementação que funciona em qualquer contexto, facilitando testes e reutilização. syn e quote usam proc_macro2 para que possam ser usados em código não-macro, e a conversão para proc_macro::TokenStream é feita no final via .into().

  5. Escreva uma macro derive que adicione um método to_json() que retorna uma string JSON simples com os campos da struct. Considere apenas structs com campos nomeados e use a biblioteca serde_json para gerar o JSON (não é necessário implementar a serialização, apenas gerar uma representação).

    ✓ Resposta:
    use proc_macro::TokenStream;
    use quote::quote;
    use syn::{parse_macro_input, DeriveInput, Data, Fields};
    
    #[proc_macro_derive(ToJson)]
    pub fn to_json_derive(input: TokenStream) -> TokenStream {
        let ast = parse_macro_input!(input as DeriveInput);
        let name = &ast.ident;
        let fields = match &ast.data {
            Data::Struct(data) => match &data.fields {
                Fields::Named(fields) => &fields.named,
                _ => return syn::Error::new_spanned(&ast, "ToJson requer campos nomeados").to_compile_error().into(),
            },
            _ => return syn::Error::new_spanned(&ast, "ToJson só pode ser usado em structs").to_compile_error().into(),
        };
        let field_names: Vec<_> = fields.iter().map(|f| f.ident.as_ref().unwrap()).collect();
        let gen = quote! {
            impl ToJson for #name {
                fn to_json(&self) -> String {
                    let mut map = serde_json::Map::new();
                    #(map.insert(stringify!(#field_names).to_string(), serde_json::to_value(&self.#field_names).unwrap());)*
                    serde_json::Value::Object(map).to_string()
                }
            }
        };
        gen.into()
    }