Macros procedurais (introdução)
Esta aula introduz macros procedurais em Rust, explicando os três tipos (derives, attribute-like e function-like), o funcionamento da crate proc_macro, as bibliotecas syn e quote para análise e geração de código, além de cuidados importantes ao escrevê-las.
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 deDebug. 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, comominha_macro!(...). Elas podem receber qualquer fluxo de tokens e retornar qualquer token, permitindo criar expressões ou itens. Exemplos incluemprintln!evec!, 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 é:
- Receber o
TokenStreamde entrada. - Usar
synpara fazer o parse em um tipo estruturado (ex.:DeriveInput). - Inspecionar e transformar os dados conforme necessário.
- Usar
quote!para gerar o código de saída. - Converter o
TokenStreamgerado 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
Spanpara 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::Errore retornarTokenStream::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
trybuildpara testes de compilação que verificam mensagens de erro. - Compatibilidade com versões: As macros procedurais dependem de APIs estáveis, mas
synequotepodem mudar. Fixe as versões noCargo.tomle 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::parseem 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-cratepara localizar crates em tempo de compilação.
Referências
- Procedural Macros - Rust Reference
- Documentação da crate proc_macro
- Documentação da crate syn
- Documentação da crate quote
- The Rust Programming Language - Macros
- Repositório do syn
- Repositório do quote
Exercícios
- 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, comoDebug. Attribute-like macros são usadas como atributos em itens, como#[test], e podem transformar o item. Function-like macros são chamadas com!, comoprintln!, e podem gerar qualquer código. Exemplos: derive para serialização (serde), attribute para testes, function-like para DSLs. - Qual é o papel da crate
proc_macroe o que é o tipoTokenStream?✓ Resposta: A crateproc_macrofornece a API para escrever macros procedurais.TokenStreamé uma sequência de tokens que representa o código Rust. As macros recebem umTokenStreamde entrada e retornam outro de saída, permitindo transformar o código. - Como as bibliotecas
synequotefacilitam a escrita de macros?✓ Resposta:synpermite analisar oTokenStreamem uma AST tipada, facilitando a extração de informações.quotepermite 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. - 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() } - 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.