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 recebem um fluxo de tokens de entrada, executam código Rust arbitrário e produzem um novo fluxo de tokens de saída. Isso permite transformar a sintaxe do Rust de maneiras complexas, como gerar implementações automáticas de traits, criar DSLs (linguagens de domínio específico) e reduzir boilerplate.

Nesta aula, vamos explorar os fundamentos das macros procedurais: seus três tipos, a crate proc_macro que fornece a API base, e as bibliotecas syn e quote que simplificam a análise e a geração de código. Também discutiremos as práticas recomendadas e os cuidados necessários para evitar erros comuns.

Tipos de macros proc

Existem três tipos de macros procedurais em Rust, cada uma com uma finalidade específica e uma sintaxe de invocação distinta. São elas:

  • Derive macros: são usadas com o atributo #[derive(...)] para gerar automaticamente implementações de traits para tipos definidos pelo usuário. Por exemplo, #[derive(Debug)] gera a implementação de Debug. Você pode criar suas próprias derives para traits personalizados.
  • Attribute-like macros: são invocadas como atributos em itens (funções, structs, módulos, etc.) e podem transformar o item completo. Elas são usadas com a sintaxe #[nome_da_macro] ou #[nome_da_macro(argumentos)]. Um exemplo comum é #[test] (embora seja implementado internamente).
  • Function-like macros: são invocadas como funções, mas com um ! no final, como minha_macro!(...). Elas podem receber qualquer fluxo de tokens e retornar qualquer token, permitindo criar expressões ou itens. Exemplos incluem println! e vec!, mas esses são declarativos; as procedurais permitem mais flexibilidade.

Cada tipo é definido por uma função específica marcada com um atributo que indica o tipo. A assinatura da função varia conforme o tipo. Vamos ver um exemplo de cada tipo, mas primeiro precisamos entender a crate proc_macro.

proc_macro

A crate proc_macro é fornecida pelo compilador e expõe a API fundamental para escrever macros procedurais. Ela define o tipo TokenStream, que é uma sequência de tokens (palavras-chave, identificadores, literais, pontuação, etc.) que o compilador usa para representar código. As macros procedurais recebem um TokenStream de entrada e retornam um TokenStream de saída.

Para criar uma macro procedural, você precisa declarar uma função com um dos seguintes atributos:

  • #[proc_macro_derive(NomeDoTrait)] para derive macros
  • #[proc_macro_attribute] para attribute-like macros
  • #[proc_macro] para function-like macros

As funções devem estar em uma crate do tipo proc-macro, definido no Cargo.toml com [lib] proc-macro = true. Vamos ver um exemplo mínimo de uma função-like macro:

use proc_macro::TokenStream;

#[proc_macro]
pub fn minha_macro(input: TokenStream) -> TokenStream {
    // Simplesmente retorna o mesmo código de entrada
    input
}

Esse exemplo é trivial, mas ilustra a estrutura básica. O parâmetro input contém os tokens que o usuário escreveu entre parênteses. A saída pode ser qualquer TokenStream. No entanto, manipular TokenStream diretamente é difícil e propenso a erros. Por isso, a comunidade criou bibliotecas como syn e quote.

syn e quote (visão geral)

syn é uma biblioteca para analisar (parse) um TokenStream em uma árvore sintática abstrata (AST) de Rust. Ela fornece tipos que representam itens como DeriveInput, ItemFn, Expr, etc. Você pode usar syn::parse para converter um TokenStream em um tipo desejado, facilitando a inspeção e manipulação do código.

quote é uma biblioteca para gerar código Rust de forma ergonômica. Ela fornece a macro quote!, que permite escrever código quase como uma string de template, mas com interpolação de variáveis. Por exemplo, para gerar uma implementação de trait, você pode escrever:

use quote::quote;

let nome_trait = "MeuTrait";
let tokens = quote! {
    impl #nome_trait for MinhaStruct {
        fn metodo(&self) {}
    }
};

O #nome_trait interpola o valor da variável dentro do código gerado. A macro quote! retorna um TokenStream (especificamente, um proc_macro2::TokenStream), que pode ser convertido para proc_macro::TokenStream usando into().

Um fluxo típico de uma macro procedural é:

  1. Receber o TokenStream de entrada.
  2. Usar syn para fazer o parse em um tipo estruturado (ex.: DeriveInput).
  3. Inspecionar e transformar os dados conforme necessário.
  4. Usar quote! para gerar o código de saída.
  5. Converter o TokenStream gerado e retorná-lo.

Vejamos um exemplo completo de uma derive macro que implementa um trait simples chamado OlaMundo:

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

#[proc_macro_derive(OlaMundo)]
pub fn ola_mundo_derive(input: TokenStream) -> TokenStream {
    // Parse a entrada como DeriveInput
    let ast = parse_macro_input!(input as DeriveInput);
    let nome = &ast.ident;

    // Gera a implementação do trait
    let gen = quote! {
        impl OlaMundo for #nome {
            fn ola(&self) {
                println!("Olá do tipo {}!", stringify!(#nome));
            }
        }
    };

    gen.into()
}

Nesse exemplo, usamos parse_macro_input! para converter o TokenStream de entrada em DeriveInput, extraímos o nome do tipo, e geramos a implementação com quote!. A macro stringify! converte o identificador em uma string literal no código gerado.

Cuidados

Escrever macros procedurais requer atenção a vários detalhes para evitar problemas de compilação, manutenção e uso indevido. Aqui estão alguns cuidados importantes:

  • Hygiene e escopo: Macros procedurais não são higiênicas, ou seja, os identificadores que você gera podem colidir com os do usuário. Para evitar conflitos, use nomes únicos (por exemplo, prefixando com o nome da macro) ou use a funcionalidade de Span para controlar a resolução de nomes.
  • Erros de compilação: Quando a macro encontra um erro (ex.: entrada inválida), você deve emitir uma mensagem de erro clara usando syn::Error e retornar TokenStream::from(err.to_compile_error()). Isso mostra o erro no local correto do código do usuário.
  • Documentação: Como as macros são invocadas de forma diferente, documente bem o que elas fazem, os argumentos esperados e os efeitos colaterais. Use comentários e, se possível, exemplos.
  • Testes: Teste suas macros com uma suíte de testes, incluindo casos de sucesso e falha. Use a crate trybuild para testes de compilação que verificam mensagens de erro.
  • Compatibilidade com versões: As macros procedurais dependem de APIs estáveis, mas syn e quote podem mudar. Fixe as versões no Cargo.toml e atualize regularmente.
  • Desempenho: Macros são executadas em tempo de compilação; código ineficiente pode aumentar o tempo de compilação. Evite operações desnecessárias.

Além disso, lembre-se de que as macros procedurais devem ser definidas em uma crate separada, dedicada exclusivamente a elas, para que possam ser usadas em outras crates. Isso é um requisito do compilador.

Boas práticas

Algumas práticas recomendadas ao desenvolver macros procedurais:

  • Comece com um protótipo simples e vá incrementando.
  • Use syn::parse em vez de manipular tokens manualmente sempre que possível.
  • Gere código que seja o mais próximo possível do que um humano escreveria.
  • Forneça mensagens de erro detalhadas e acionáveis.
  • Considere usar a crate proc-macro-crate para localizar crates em tempo de compilação.

Referências

Exercícios

  1. Explique a diferença entre os três tipos de macros procedurais e dê um exemplo de uso para cada um.

    ✓ Resposta: Derive macros são usadas com #[derive(Trait)] para gerar implementações de traits automaticamente, como Debug. Attribute-like macros são usadas como atributos em itens, como #[test], e podem transformar o item. Function-like macros são chamadas com !, como println!, e podem gerar qualquer código. Exemplos: derive para serialização (serde), attribute para testes, function-like para DSLs.
  2. Qual é o papel da crate proc_macro e o que é o tipo TokenStream?

    ✓ Resposta: A crate proc_macro fornece a API para escrever macros procedurais. TokenStream é uma sequência de tokens que representa o código Rust. As macros recebem um TokenStream de entrada e retornam outro de saída, permitindo transformar o código.
  3. Como as bibliotecas syn e quote facilitam a escrita de macros?

    ✓ Resposta: syn permite analisar o TokenStream em uma AST tipada, facilitando a extração de informações. quote permite gerar código Rust de forma ergonômica com interpolação de variáveis, tornando a geração de código mais legível e segura.
  4. Escreva uma macro procedural function-like que receba uma expressão e retorne a mesma expressão, mas envolta em um bloco que imprime o valor antes de retorná-lo.

    ✓ Resposta:
    use proc_macro::TokenStream;
    use quote::quote;
    use syn::{parse_macro_input, Expr};
    
    #[proc_macro]
    pub fn imprimir_e_retornar(input: TokenStream) -> TokenStream {
        let expr = parse_macro_input!(input as Expr);
        let gen = quote! {
            {
                let val = #expr;
                println!("{:?}", val);
                val
            }
        };
        gen.into()
    }
  5. Quais são os principais cuidados ao escrever macros procedurais? Escolha três e explique.

    ✓ Resposta: 1. Hygiene: evitar colisões de nomes usando identificadores únicos ou spans. 2. Erros de compilação: emitir mensagens claras com syn::Error. 3. Documentação e testes: documentar o comportamento e testar com casos de sucesso e falha.