Exploração de Macros Rust no LabEx

Beginner

This tutorial is from open-source community. Access the source code

Introdução

Bem-vindo(a) a Macros. Este laboratório faz parte do Livro de Rust. Você pode praticar suas habilidades em Rust no LabEx.

Neste laboratório, exploramos o conceito de macros em Rust, incluindo macros declarativas com macro_rules! e três tipos de macros procedurais: macros #[derive] customizadas, macros semelhantes a atributos e macros semelhantes a funções.

Macros

Usamos macros como println! ao longo deste livro, mas não exploramos totalmente o que é uma macro e como ela funciona. O termo macro refere-se a uma família de recursos em Rust: macros declarativas com macro_rules! e três tipos de macros procedurais:

  • Macros #[derive] customizadas que especificam o código adicionado com o atributo derive usado em structs e enums
  • Macros semelhantes a atributos que definem atributos personalizados utilizáveis em qualquer item
  • Macros semelhantes a funções que se parecem com chamadas de função, mas operam nos tokens especificados como seus argumentos

Falaremos sobre cada um deles por sua vez, mas primeiro, vamos ver por que precisamos de macros quando já temos funções.

A Diferença Entre Macros e Funções

Fundamentalmente, macros são uma forma de escrever código que escreve outro código, o que é conhecido como metaprogramação (metaprogramming). No Apêndice C, discutimos o atributo derive, que gera uma implementação de vários traits para você. Também usamos as macros println! e vec! ao longo do livro. Todas essas macros expandem-se para produzir mais código do que o código que você escreveu manualmente.

A metaprogramação é útil para reduzir a quantidade de código que você precisa escrever e manter, que também é um dos papéis das funções. No entanto, as macros têm alguns poderes adicionais que as funções não têm.

Uma assinatura de função deve declarar o número e o tipo de parâmetros que a função possui. Macros, por outro lado, podem receber um número variável de parâmetros: podemos chamar println!("hello") com um argumento ou println!("hello {}", name) com dois argumentos. Além disso, as macros são expandidas antes que o compilador interprete o significado do código, então uma macro pode, por exemplo, implementar um trait em um determinado tipo. Uma função não pode, porque ela é chamada em tempo de execução e um trait precisa ser implementado em tempo de compilação.

A desvantagem de implementar uma macro em vez de uma função é que as definições de macro são mais complexas do que as definições de função, porque você está escrevendo código Rust que escreve código Rust. Devido a essa indireção, as definições de macro são geralmente mais difíceis de ler, entender e manter do que as definições de função.

Outra diferença importante entre macros e funções é que você deve definir macros ou trazê-las para o escopo antes de chamá-las em um arquivo, em oposição às funções que você pode definir em qualquer lugar e chamar em qualquer lugar.

Macros Declarativas com macro_rules! para Metaprogramação Geral

A forma mais amplamente utilizada de macros em Rust é a macro declarativa. Estas também são, por vezes, referidas como "macros por exemplo", "macros macro_rules!" ou simplesmente "macros". No seu cerne, as macros declarativas permitem que você escreva algo semelhante a uma expressão match do Rust. Como discutido no Capítulo 6, as expressões match são estruturas de controle que recebem uma expressão, comparam o valor resultante da expressão com padrões e, em seguida, executam o código associado ao padrão correspondente. As macros também comparam um valor com padrões que estão associados a um código específico: nesta situação, o valor é o código-fonte literal do Rust passado para a macro; os padrões são comparados com a estrutura desse código-fonte; e o código associado a cada padrão, quando correspondido, substitui o código passado para a macro. Tudo isso acontece durante a compilação.

Para definir uma macro, você usa a construção macro_rules!. Vamos explorar como usar macro_rules! observando como a macro vec! é definida. O Capítulo 8 abordou como podemos usar a macro vec! para criar um novo vetor com valores específicos. Por exemplo, a seguinte macro cria um novo vetor contendo três inteiros:

let v: Vec<u32> = vec![1, 2, 3];

Também poderíamos usar a macro vec! para criar um vetor de dois inteiros ou um vetor de cinco fatias de string. Não seríamos capazes de usar uma função para fazer o mesmo porque não saberíamos o número ou o tipo de valores antecipadamente.

A Listagem 19-28 mostra uma definição ligeiramente simplificada da macro vec!.

Nome do arquivo: src/lib.rs

1 #[macro_export]
2 macro_rules! vec {
  3 ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
          4 $(
              5 temp_vec.push(6 $x);
            )*
          7 temp_vec
        }
    };
}

Listagem 19-28: Uma versão simplificada da definição da macro vec!

Nota: A definição real da macro vec! na biblioteca padrão inclui código para pré-alocar a quantidade correta de memória antecipadamente. Esse código é uma otimização que não incluímos aqui, para simplificar o exemplo.

A anotação #[macro_export] [1] indica que esta macro deve estar disponível sempre que o crate em que a macro é definida for trazido para o escopo. Sem esta anotação, a macro não pode ser trazida para o escopo.

Em seguida, iniciamos a definição da macro com macro_rules! e o nome da macro que estamos definindo sem o ponto de exclamação [2]. O nome, neste caso vec, é seguido por chaves que denotam o corpo da definição da macro.

A estrutura no corpo vec! é semelhante à estrutura de uma expressão match. Aqui temos um braço com o padrão ( $( $x:expr ),* ), seguido por => e o bloco de código associado a este padrão [3]. Se o padrão corresponder, o bloco de código associado será emitido. Dado que este é o único padrão nesta macro, existe apenas uma forma válida de corresponder; qualquer outro padrão resultará em um erro. Macros mais complexas terão mais de um braço.

A sintaxe de padrão válida em definições de macro é diferente da sintaxe de padrão coberta no Capítulo 18 porque os padrões de macro são correspondidos com a estrutura do código Rust, em vez de valores. Vamos analisar o que as partes do padrão na Listagem 19-28 significam; para a sintaxe completa do padrão de macro, consulte a Referência do Rust em https://doc.rust-lang.org/reference/macros-by-example.html.

Primeiro, usamos um conjunto de parênteses para englobar todo o padrão. Usamos um cifrão ($) para declarar uma variável no sistema de macro que conterá o código Rust correspondente ao padrão. O cifrão deixa claro que esta é uma variável de macro, em oposição a uma variável Rust regular. Em seguida, vem um conjunto de parênteses que captura valores que correspondem ao padrão dentro dos parênteses para uso no código de substituição. Dentro de $() está $x:expr, que corresponde a qualquer expressão Rust e dá à expressão o nome $x.

A vírgula que segue $() indica que um caractere separador de vírgula literal pode opcionalmente aparecer após o código que corresponde ao código em $(). O * especifica que o padrão corresponde a zero ou mais de tudo o que precede o *.

Quando chamamos esta macro com vec![1, 2, 3];, o padrão $x corresponde três vezes com as três expressões 1, 2 e 3.

Agora, vamos analisar o padrão no corpo do código associado a este braço: temp_vec.push() [5] dentro de $()* em [4] e [7] é gerado para cada parte que corresponde a $() no padrão zero ou mais vezes, dependendo de quantas vezes o padrão corresponde. O $x [6] é substituído por cada expressão correspondida. Quando chamamos esta macro com vec![1, 2, 3];, o código gerado que substitui esta chamada de macro será o seguinte:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

Definimos uma macro que pode receber qualquer número de argumentos de qualquer tipo e pode gerar código para criar um vetor contendo os elementos especificados.

Para saber mais sobre como escrever macros, consulte a documentação online ou outros recursos, como "The Little Book of Rust Macros" em https://veykril.github.io/tlborm iniciado por Daniel Keep e continuado por Lukas Wirth.

Macros Procedurais para Geração de Código a partir de Atributos

A segunda forma de macros é a macro procedural, que age mais como uma função (e é um tipo de procedimento). As macros procedurais aceitam algum código como entrada, operam nesse código e produzem algum código como saída, em vez de corresponder a padrões e substituir o código por outro código, como as macros declarativas fazem. Os três tipos de macros procedurais são derive customizado, tipo atributo e tipo função, e todos funcionam de maneira semelhante.

Ao criar macros procedurais, as definições devem residir em seu próprio crate com um tipo de crate especial. Isso se deve a razões técnicas complexas que esperamos eliminar no futuro. Na Listagem 19-29, mostramos como definir uma macro procedural, onde some_attribute é um espaço reservado para usar uma variedade específica de macro.

Nome do arquivo: src/lib.rs

use proc_macro::TokenStream;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}

Listagem 19-29: Um exemplo de definição de uma macro procedural

A função que define uma macro procedural recebe um TokenStream como entrada e produz um TokenStream como saída. O tipo TokenStream é definido pelo crate proc_macro que está incluído com Rust e representa uma sequência de tokens. Este é o núcleo da macro: o código-fonte em que a macro está operando constitui o TokenStream de entrada, e o código que a macro produz é o TokenStream de saída. A função também tem um atributo anexado a ela que especifica qual tipo de macro procedural estamos criando. Podemos ter vários tipos de macros procedurais no mesmo crate.

Vamos analisar os diferentes tipos de macros procedurais. Começaremos com uma macro derive customizada e, em seguida, explicaremos as pequenas diferenças que tornam as outras formas diferentes.

Como Escrever uma Macro derive Customizada

Vamos criar um crate chamado hello_macro que define um trait chamado HelloMacro com uma função associada chamada hello_macro. Em vez de fazer com que nossos usuários implementem o trait HelloMacro para cada um de seus tipos, forneceremos uma macro procedural para que os usuários possam anotar seu tipo com #[derive(HelloMacro)] para obter uma implementação padrão da função hello_macro. A implementação padrão imprimirá Hello, Macro! My name is TypeName! onde TypeName é o nome do tipo no qual este trait foi definido. Em outras palavras, escreveremos um crate que permite que outro programador escreva código como a Listagem 19-30 usando nosso crate.

Nome do arquivo: src/main.rs

use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}

Listagem 19-30: O código que um usuário de nosso crate poderá escrever ao usar nossa macro procedural

Este código imprimirá Hello, Macro! My name is Pancakes! quando terminarmos. O primeiro passo é criar um novo crate de biblioteca, assim:

cargo new hello_macro --lib

Em seguida, definiremos o trait HelloMacro e sua função associada:

Nome do arquivo: src/lib.rs

pub trait HelloMacro {
    fn hello_macro();
}

Temos um trait e sua função. Neste ponto, o usuário do nosso crate poderia implementar o trait para obter a funcionalidade desejada, assim:

use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("Hello, Macro! My name is Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}

No entanto, eles precisariam escrever o bloco de implementação para cada tipo que desejassem usar com hello_macro; queremos poupá-los de ter que fazer esse trabalho.

Além disso, ainda não podemos fornecer a função hello_macro com uma implementação padrão que imprimirá o nome do tipo no qual o trait é implementado: Rust não possui recursos de reflexão, portanto, não pode procurar o nome do tipo em tempo de execução. Precisamos de uma macro para gerar código em tempo de compilação.

O próximo passo é definir a macro procedural. No momento em que este artigo foi escrito, as macros procedurais precisam estar em seu próprio crate. Eventualmente, essa restrição pode ser removida. A convenção para estruturar crates e macros crates é a seguinte: para um crate chamado foo, um crate de macro procedural derive customizado é chamado foo_derive. Vamos iniciar um novo crate chamado hello_macro_derive dentro do nosso projeto hello_macro:

cargo new hello_macro_derive --lib

Nossos dois crates estão intimamente relacionados, então criamos o crate de macro procedural dentro do diretório do nosso crate hello_macro. Se alterarmos a definição do trait em hello_macro, também teremos que alterar a implementação da macro procedural em hello_macro_derive. Os dois crates precisarão ser publicados separadamente, e os programadores que usam esses crates precisarão adicionar ambos como dependências e trazê-los para o escopo. Em vez disso, poderíamos fazer com que o crate hello_macro usasse hello_macro_derive como uma dependência e reexportasse o código da macro procedural. No entanto, a forma como estruturamos o projeto torna possível para os programadores usarem hello_macro mesmo que não queiram a funcionalidade derive.

Precisamos declarar o crate hello_macro_derive como um crate de macro procedural. Também precisaremos de funcionalidade dos crates syn e quote, como você verá em um momento, então precisamos adicioná-los como dependências. Adicione o seguinte ao arquivo Cargo.toml para hello_macro_derive:

Nome do arquivo: hello_macro_derive/Cargo.toml

[lib]
proc-macro = true

[dependencies]
syn = "1.0"
quote = "1.0"

Para começar a definir a macro procedural, coloque o código na Listagem 19-31 em seu arquivo src/lib.rs para o crate hello_macro_derive. Observe que este código não compilará até que adicionemos uma definição para a função impl_hello_macro.

Nome do arquivo: hello_macro_derive/src/lib.rs

use proc_macro::TokenStream;
use quote::quote;
use syn;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation
    impl_hello_macro(&ast)
}

Listagem 19-31: Código que a maioria dos crates de macro procedural exigirá para processar o código Rust

Observe que dividimos o código na função hello_macro_derive, que é responsável por analisar o TokenStream, e na função impl_hello_macro, que é responsável por transformar a árvore de sintaxe: isso torna a escrita de uma macro procedural mais conveniente. O código na função externa (hello_macro_derive neste caso) será o mesmo para quase todos os crates de macro procedural que você vir ou criar. O código que você especifica no corpo da função interna (impl_hello_macro neste caso) será diferente dependendo do propósito da sua macro procedural.

Apresentamos três novos crates: proc_macro, syn (disponível em https://crates.io/crates/syn) e quote (disponível em https://crates.io/crates/quote). O crate proc_macro vem com Rust, então não precisamos adicioná-lo às dependências em Cargo.toml. O crate proc_macro é a API do compilador que nos permite ler e manipular o código Rust do nosso código.

O crate syn analisa o código Rust de uma string em uma estrutura de dados na qual podemos realizar operações. O crate quote transforma as estruturas de dados syn de volta em código Rust. Esses crates tornam muito mais simples analisar qualquer tipo de código Rust que possamos querer manipular: escrever um analisador completo para o código Rust não é uma tarefa simples.

A função hello_macro_derive será chamada quando um usuário de nossa biblioteca especificar #[derive(HelloMacro)] em um tipo. Isso é possível porque anotamos a função hello_macro_derive aqui com proc_macro_derive e especificamos o nome HelloMacro, que corresponde ao nome do nosso trait; esta é a convenção que a maioria das macros procedurais segue.

A função hello_macro_derive primeiro converte o input de um TokenStream em uma estrutura de dados na qual podemos interpretar e realizar operações. É aqui que syn entra em jogo. A função parse em syn recebe um TokenStream e retorna uma struct DeriveInput representando o código Rust analisado. A Listagem 19-32 mostra as partes relevantes da struct DeriveInput que obtemos ao analisar a string struct Pancakes;.

DeriveInput {
    --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}

Listagem 19-32: A instância DeriveInput que obtemos ao analisar o código que possui o atributo da macro na Listagem 19-30

Os campos desta struct mostram que o código Rust que analisamos é uma struct unit com o ident (identificador, significando o nome) de Pancakes. Existem mais campos nesta struct para descrever todos os tipos de código Rust; verifique a documentação syn para DeriveInput em https://docs.rs/syn/1.0/syn/struct.DeriveInput.html para obter mais informações.

Em breve, definiremos a função impl_hello_macro, que é onde construiremos o novo código Rust que queremos incluir. Mas antes de fazermos isso, observe que a saída para nossa macro derive também é um TokenStream. O TokenStream retornado é adicionado ao código que os usuários do nosso crate escrevem, então, quando eles compilarem seu crate, eles obterão a funcionalidade extra que fornecemos no TokenStream modificado.

Você pode ter notado que estamos chamando unwrap para fazer com que a função hello_macro_derive entre em pânico se a chamada para a função syn::parse falhar aqui. É necessário que nossa macro procedural entre em pânico em caso de erros porque as funções proc_macro_derive devem retornar TokenStream em vez de Result para se conformar à API da macro procedural. Simplificamos este exemplo usando unwrap; no código de produção, você deve fornecer mensagens de erro mais específicas sobre o que deu errado usando panic! ou expect.

Agora que temos o código para transformar o código Rust anotado de um TokenStream em uma instância DeriveInput, vamos gerar o código que implementa o trait HelloMacro no tipo anotado, conforme mostrado na Listagem 19-33.

Nome do arquivo: hello_macro_derive/src/lib.rs

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let gen = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!(
                    "Hello, Macro! My name is {}!",
                    stringify!(#name)
                );
            }
        }
    };
    gen.into()
}

Listagem 19-33: Implementando o trait HelloMacro usando o código Rust analisado

Obtemos uma instância da struct Ident contendo o nome (identificador) do tipo anotado usando ast.ident. A struct na Listagem 19-32 mostra que, quando executamos a função impl_hello_macro no código na Listagem 19-30, o ident que obtemos terá o campo ident com um valor de "Pancakes". Assim, a variável name na Listagem 19-33 conterá uma instância da struct Ident que, quando impressa, será a string "Pancakes", o nome da struct na Listagem 19-30.

A macro quote! nos permite definir o código Rust que queremos retornar. O compilador espera algo diferente do resultado direto da execução da macro quote!, então precisamos convertê-lo em um TokenStream. Fazemos isso chamando o método into, que consome esta representação intermediária e retorna um valor do tipo TokenStream necessário.

A macro quote! também fornece algumas mecânicas de modelagem muito legais: podemos inserir #name, e quote! o substituirá pelo valor na variável name. Você pode até fazer alguma repetição semelhante à forma como as macros regulares funcionam. Consulte a documentação do crate quote em https://docs.rs/quote para uma introdução completa.

Queremos que nossa macro procedural gere uma implementação de nosso trait HelloMacro para o tipo que o usuário anotou, o que podemos obter usando #name. A implementação do trait tem a função hello_macro, cujo corpo contém a funcionalidade que queremos fornecer: imprimir Hello, Macro! My name is e, em seguida, o nome do tipo anotado.

A macro stringify! usada aqui é integrada ao Rust. Ele recebe uma expressão Rust, como 1 + 2, e em tempo de compilação transforma a expressão em um literal de string, como "1 + 2". Isso é diferente de format! ou println!, macros que avaliam a expressão e, em seguida, transformam o resultado em uma String. Existe a possibilidade de que a entrada #name possa ser uma expressão para imprimir literalmente, então usamos stringify!. Usar stringify! também economiza uma alocação convertendo #name em um literal de string em tempo de compilação.

Neste ponto, cargo build deve ser concluído com sucesso em hello_macro e hello_macro_derive. Vamos conectar esses crates ao código na Listagem 19-30 para ver a macro procedural em ação! Crie um novo projeto binário em seu diretório project usando cargo new pancakes. Precisamos adicionar hello_macro e hello_macro_derive como dependências no Cargo.toml do crate pancakes. Se você estiver publicando suas versões de hello_macro e hello_macro_derive em https://crates.io, elas seriam dependências regulares; caso contrário, você pode especificá-las como dependências path da seguinte forma:

[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

Coloque o código na Listagem 19-30 em src/main.rs e execute cargo run: ele deve imprimir Hello, Macro! My name is Pancakes! A implementação do trait HelloMacro da macro procedural foi incluída sem que o crate pancakes precisasse implementá-lo; o #[derive(HelloMacro)] adicionou a implementação do trait.

Em seguida, vamos explorar como os outros tipos de macros procedurais diferem das macros derive customizadas.

Macros do Tipo Atributo

Macros do tipo atributo são semelhantes às macros derive customizadas, mas, em vez de gerar código para o atributo derive, elas permitem que você crie novos atributos. Elas também são mais flexíveis: derive só funciona para structs e enums; os atributos podem ser aplicados a outros itens também, como funções. Aqui está um exemplo de como usar uma macro do tipo atributo. Digamos que você tenha um atributo chamado route que anota funções ao usar um framework de aplicação web:

#[route(GET, "/")]
fn index() {

Este atributo #[route] seria definido pelo framework como uma macro procedural. A assinatura da função de definição da macro seria assim:

#[proc_macro_attribute]
pub fn route(
    attr: TokenStream,
    item: TokenStream
) -> TokenStream {

Aqui, temos dois parâmetros do tipo TokenStream. O primeiro é para o conteúdo do atributo: a parte GET, "/". O segundo é o corpo do item ao qual o atributo está anexado: neste caso, fn index() {} e o restante do corpo da função.

Fora isso, as macros do tipo atributo funcionam da mesma forma que as macros derive customizadas: você cria um crate com o tipo de crate proc-macro e implementa uma função que gera o código que você deseja!

Macros do Tipo Função

Macros do tipo função definem macros que se parecem com chamadas de função. Semelhantes às macros macro_rules!, elas são mais flexíveis do que funções; por exemplo, elas podem receber um número desconhecido de argumentos. No entanto, as macros macro_rules! só podem ser definidas usando a sintaxe semelhante a correspondência que discutimos em "Macros Declarativas com macro_rules! para Metaprogramação Geral". Macros do tipo função recebem um parâmetro TokenStream, e sua definição manipula esse TokenStream usando código Rust, como os outros dois tipos de macros procedurais fazem. Um exemplo de uma macro do tipo função é uma macro sql! que pode ser chamada assim:

let sql = sql!(SELECT * FROM posts WHERE id=1);

Esta macro analisaria a instrução SQL dentro dela e verificaria se ela está sintaticamente correta, o que é um processamento muito mais complexo do que uma macro macro_rules! pode fazer. A macro sql! seria definida assim:

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

Esta definição é semelhante à assinatura da macro derive customizada: recebemos os tokens que estão dentro dos parênteses e retornamos o código que queríamos gerar.

Resumo

Parabéns! Você concluiu o laboratório de Macros. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.