Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Macro

Abbiamo usato macro come println! in tutto il libro, ma non abbiamo ancora esplorato appieno cosa sia una macro e come funzioni. Il termine macro si riferisce a una famiglia di funzionalità in Rust: le macro dichiarative con macro_rules!, e tre tipi di macro procedurali:

  • Macro #[derive] personalizzate che specificano codice aggiunto con l’attributo derive usato su struct ed enum.
  • Macro simil-attributo che definiscono attributi personalizzati usabili su qualsiasi elemento.
  • Macro simil-funzione che sembrano chiamate di funzione ma operano sui token specificati come argomento.

Parleremo di ciascuna di queste a turno, ma prima vediamo perché abbiamo bisogno delle macro se abbiamo già le funzioni.

Differenza Tra Macro e Funzioni

Fondamentalmente, le macro sono un modo di scrivere codice che scrive altro codice, noto come meta-programmazione. Nell’Appendice C parliamo dell’attributo derive, che genera per te l’implementazione di vari trait. Abbiamo anche usato le macro println! e vec! in tutto il libro. Tutte queste macro espandono il codice, producendo più codice di quello scritto manualmente.

La meta-programmazione è utile per ridurre la quantità di codice da scrivere e mantenere, che è uno degli scopi delle funzioni. Tuttavia, le macro hanno poteri aggiuntivi che le funzioni non hanno.

La firma di una funzione deve dichiarare il numero ed il type dei parametri. Le macro, invece, possono accettare un numero variabile di parametri: possiamo chiamare println!("ciao") con un argomento o println!("ciao {}", nome) con due. Inoltre, le macro sono espanse prima che il compilatore interpreti il codice, quindi una macro può, ad esempio, implementare un trait su un type. Una funzione non può farlo, perché viene chiamata durante l’esecuzione e i trait devono essere implementati durante la compilazione.

Lo svantaggio delle macro rispetto alle funzioni è che definire macro è più complesso, perché stai scrivendo codice Rust che scrive codice Rust. Per questo, definire macro è generalmente più difficile da leggere, capire e mantenere rispetto alle funzioni.

Un’altra differenza importante è che devi definire o importare le macro prima di usarle in un file, mentre le funzioni possono essere definite e chiamate ovunque.

Macro dichiarative per la metaprogrammazione generale

La forma di macro più diffusa in Rust è la macro dichiarativa. A volte queste sono anche chiamate “macro per esempio”, “macro macro_rules!” o semplicemente “macro”. Nel loro nucleo, le macro dichiarative permettono di scrivere qualcosa di simile a un’espressione match di Rust. Come discusso nel Capitolo 6, le espressioni match sono strutture di controllo che prendono un’espressione, confrontano il valore risultante con dei pattern, e quindi eseguono il codice associato al pattern corrispondente. Le macro confrontano anch’esse un valore con dei pattern associati a codice particolare: in questa situazione, il valore è il codice sorgente letterale di Rust passato alla macro; i pattern sono confrontati con la struttura di quel codice sorgente; e il codice associato a ciascun pattern, quando corrisponde, sostituisce il codice passato alla macro. Tutto ciò accade durante la compilazione.

Per definire una macro, si usa il costrutto macro_rules!. Esploriamo come utilizzare macro_rules! osservando come viene definita la macro vec!. Il Capitolo 8 ha trattato come possiamo usare la macro vec! per creare un nuovo vettore con valori particolari. Per esempio, la seguente macro crea un nuovo vettore contenente tre interi:

#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

Potremmo anche usare la macro vec! per creare un vettore di due interi o un vettore di cinque stringhe. Non potremmo usare una funzione per fare lo stesso perché non sapremmo a priori il numero o il tipo di valori.

Il Listato 20-35 mostra una definizione leggermente semplificata della macro vec!.

File: src/lib.rs
#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}
Listato 20-35: Una versione semplificata della definizione della macro vec!

Nota: La definizione reale della macro vec! nella libreria standard include codice per pre-allocare la quantità corretta di memoria anticipatamente. Quel codice è un’ottimizzazione che non includiamo qui, per rendere l’esempio più semplice.

L’annotazione #[macro_export] indica che questa macro deve essere resa disponibile ogni volta che il crate in cui è definita la macro viene incluso nello scope. Senza questa annotazione, la macro non può essere portata nello scope.

Iniziamo quindi la definizione della macro con macro_rules! e il nome della macro che stiamo definendo senza il punto esclamativo. Il nome, in questo caso vec, è seguito da parentesi graffe che indicano il corpo della definizione della macro.

La struttura nel corpo di vec! è simile alla struttura di un’espressione match. Qui abbiamo un ramo con il pattern ( $( $x:expr ),* ), seguito da => e dal blocco di codice associato a questo pattern. Se il pattern corrisponde, il blocco di codice associato verrà espanso. Poiché questo è l’unico pattern in questa macro, c’è solo un modo valido per fare match; qualsiasi altro pattern porterà a un errore. Macro più complesse avranno più rami.

La sintassi valida dei pattern nelle definizioni di macro è diversa dalla sintassi dei pattern trattata nel Capitolo 19 perché i pattern delle macro vengono confrontati con la struttura del codice Rust piuttosto che con valori. Vediamo cosa significano le parti del pattern nel Listato 20-35; per la sintassi completa dei pattern nelle macro, consultare il Rust Reference.

Prima usiamo una coppia di parentesi per racchiudere tutto il pattern. Usiamo un segno del dollaro ($) per dichiarare una variabile nel sistema macro che conterrà il codice Rust che corrisponde al pattern. Il segno del dollaro rende chiaro che questa è una variabile macro e non una variabile Rust normale. Poi viene una coppia di parentesi che cattura i valori che corrispondono al pattern dentro le parentesi per l’uso nel codice di sostituzione. Dentro $() c’è $x:expr, che corrisponde a qualsiasi espressione Rust e assegna il nome $x a quell’espressione.

La virgola che segue $() indica che deve apparire un carattere letterale di separazione virgola tra ogni istanza del codice che corrisponde al codice in $(). L’asterisco * specifica che il pattern corrisponde a zero o più occorrenze di qualunque cosa preceda l’asterisco.

Quando chiamiamo questa macro con vec![1, 2, 3];, il pattern $x fa match tre volte con le tre espressioni 1, 2 e 3.

Ora vediamo il pattern nel corpo del codice associato a questo ramo: temp_vec.push() dentro $()* viene generato per ciascuna parte che corrisponde a $() nel pattern da zero a più volte a seconda di quante volte il pattern fa match. Il $x viene sostituito con ogni espressione trovata. Quando chiamiamo questa macro con vec![1, 2, 3];, il codice generato che sostituisce questa chiamata di macro sarà il seguente:

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

Abbiamo definito una macro che può prendere qualsiasi numero di argomenti di qualsiasi type e può generare codice per creare un vettore contenente gli elementi specificati.

Per imparare di più su come scrivere macro, consultare la documentazione online o altre risorse, come “The Little Book of Rust Macros” iniziato da Daniel Keep e continuato da Lukas Wirth.

Macro Procedurali Per Generare Codice da Attributi

La seconda forma di macro è la macro procedurale, che si comporta più come una funzione (e è un tipo di procedura). Le macro procedurali accettano del codice come input, operano su quel codice, e producono del codice come output, invece di fare match su dei pattern e sostituire il codice con altro codice come fanno le macro dichiarative. Le tre tipologie di macro procedurali sono derive personalizzate, macro simil-attributi, e macro simil-funzioni, e tutte funzionano in modo simile.

Quando si creano macro procedurali, le definizioni devono risiedere in un proprio crate con un tipo speciale di crate. Questo per ragioni tecniche complesse che si spera di eliminare in futuro. Nel Listato 20-36 mostriamo come definire una macro procedurale, dove qualche_attributo è un segnaposto per l’uso di una specifica varietà di macro.

File: src/lib.rs
use proc_macro;

#[qualche_attributo]
pub fn qualche_attributo(input: TokenStream) -> TokenStream {
}
Listato 20-36: Un esempio di definizione di una macro procedurale

La funzione che definisce una macro procedurale prende un TokenStream come input e produce un TokenStream come output. Il type TokenStream è definito dal crate proc_macro incluso in Rust e rappresenta una sequenza di token. Questo è il nucleo della macro: il codice sorgente su cui la macro opera costituisce il TokenStream di input, e il codice che la macro produce è il TokenStream di output. La funzione ha anche un attributo che specifica quale tipo di macro procedurale stiamo creando. Possiamo avere più tipi di macro procedurali nello stesso crate.

Vediamo le diverse tipologie di macro procedurali. Inizieremo con una macro derive personalizzata per poi spiegare le piccole differenze che caratterizzano le altre forme.

Macro derive Personalizzate

Creiamo un crate chiamato ciao_macro che definisce un trait chiamato CiaoMacro con una funzione associata chiamata ciao_macro. Invece di far implementare agli utenti il trait CiaoMacro per ogni loro type, forniremo una macro procedurale che consente agli utenti di annotare il loro type con #[derive(CiaoMacro)] per ottenere un’implementazione di default della funzione ciao_macro. L’implementazione di default stamperà Ciao, Macro! Il mio nome è NomeType! dove NomeType è il nome del type su cui il trait è stato definito. In altre parole, scriveremo un crate che permette a un altro programmatore di scrivere codice come nel Listato 20-37 usando il nostro crate.

File: src/main.rs
use ciao_macro::CiaoMacro;
use ciao_macro_derive::CiaoMacro;

#[derive(CiaoMacro)]
struct Pancake;

fn main() {
    Pancake::ciao_macro();
}
Listato 20-37: Il codice che un utente del nostro crate potrà scrivere usando la nostra macro procedurale

Questo codice stamperà Ciao, Macro! Il mio nome è Pancake! quando sarà eseguito. Il primo passo è creare un nuovo crate libreria come segue:

$ cargo new ciao_macro --lib

Successivamente, nel Listato 20-38, definiremo il trait CiaoMacro e la sua funzione associata.

File: src/lib.rs
pub trait CiaoMacro {
    fn ciao_macro();
}
Listato 20-38: Un semplice trait che useremo con la macro derive

Abbiamo un trait e la sua funzione. A questo punto, l’utente del nostro crate potrebbe implementare il trait per ottenere la funzionalità desiderata, come mostrato nel Listato 20-39.

File: src/main.rs
use ciao_macro::CiaoMacro;

struct Pancake;

impl CiaoMacro for Pancake {
    fn ciao_macro() {
        println!("Ciao, Macro! Il mio nome è Pancake!");
    }
}

fn main() {
    Pancake::ciao_macro();
}
Listato 20-39: Come apparirebbe se gli utenti scrivessero manualmente l’implementazione del trait CiaoMacro

Tuttavia, gli utenti dovrebbero scrivere il blocco di implementazione per ogni type su cui vogliono usare ciao_macro; vogliamo risparmiarli da questo lavoro.

Inoltre, non possiamo ancora fornire alla funzione ciao_macro un’implementazione di default che stampi il nome del type su cui il trait è implementato: Rust non possiede capacità riflessive (reflection), cioè non può ricavare il nome del type durante l’esecuzione. Abbiamo bisogno di una macro per generare il codice a durante la compilazione.

Il passo successivo è definire la macro procedurale. Al momento della scrittura, le macro procedurali devono risiedere in un crate a parte. Questa restrizione potrebbe essere rimossa in futuro. La convenzione per strutturare crate e crate macro è la seguente: per un crate chiamato foo, un crate di macro procedurali derive personalizzate si chiama foo_derive. Creiamo quindi un nuovo crate chiamato ciao_macro_derive all’interno del progetto ciao_macro:

$ cargo new ciao_macro_derive --lib

I due crate sono strettamente correlati, quindi creiamo il crate di macro procedurali nella cartella del crate ciao_macro. Se cambiamo la definizione del trait in ciao_macro, dovremo cambiare anche l’implementazione della macro procedurale in ciao_macro_derive. I due crate dovranno essere pubblicati separatamente, e i programmatori che usano questi crate dovranno aggiungerli entrambi come dipendenze e importarli entrambi nello scope. Potremmo invece far sì che il crate ciao_macro utilizzi ciao_macro_derive come dipendenza e rimandi il codice della macro procedurale. Tuttavia, la struttura scelta consente ai programmatori di usare ciao_macro anche se non vogliono la funzionalità derive.

Dobbiamo dichiarare il crate ciao_macro_derive come crate di macro procedurali. Avremo anche bisogno della funzionalità dai crate syn e quote, come vedremo a breve, quindi dobbiamo aggiungerli come dipendenze. Aggiungi quanto segue al file Cargo.toml di ciao_macro_derive:

File: ciao_macro_derive/Cargo.toml
[lib]
proc-macro = true

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

Per iniziare a definire la macro procedurale, inserisci il codice del Listato 20-40 nel file src/lib.rs del crate ciao_macro_derive. Nota che questo codice non si compila fino a quando non aggiungiamo una definizione per la funzione impl_ciao_macro.

File: ciao_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(CiaoMacro)]
pub fn ciao_macro_derive(input: TokenStream) -> TokenStream {
    // Costruisci una rappresentazione di codice Rust come
    // albero sintattico che possiamo manipolare
    let ast = syn::parse(input).unwrap();

    // Costruisci l'implementazione del trait
    impl_ciao_macro(&ast)
}
Listato 20-40: Codice che la maggior parte dei crate di macro procedurali richiederà per processare codice Rust

Nota che abbiamo diviso il codice in una funzione ciao_macro_derive, responsabile del parsing del TokenStream, e una funzione impl_ciao_macro, responsabile della trasformazione dell’albero sintattico: questo rende la scrittura di una macro procedurale più comoda. Il codice nella funzione esterna (ciao_macro_derive in questo caso) sarà simile in quasi tutti i crate di macro procedurali che vedrai o creerai. Il codice che specifichiamo nel corpo della funzione interna (impl_ciao_macro in questo caso) sarà diverso a seconda dello scopo della macro procedurale.

Abbiamo introdotto tre nuovi crate: proc_macro, syn e quote. Il crate proc_macro fa parte di Rust, quindi non abbiamo dovuto aggiungerlo alle dipendenze in Cargo.toml. Il crate proc_macro è l’API del compilatore che consente di leggere e manipolare codice Rust dal nostro codice.

Il crate syn analizza il codice Rust da una stringa in una struttura dati su cui possiamo eseguire operazioni. Il crate quote trasforma le strutture dati di syn nuovamente in codice Rust. Questi crate rendono molto più semplice analizzare qualsiasi tipo di codice Rust che vogliamo gestire: scrivere un parser completo per Rust non è un compito semplice.

La funzione ciao_macro_derive verrà chiamata quando un utente della nostra libreria specifica #[derive(CiaoMacro)] su un type. Questo è possibile perché abbiamo annotato la funzione ciao_macro_derive con proc_macro_derive e specificato il nome CiaoMacro, che corrisponde al nostro nome di trait; questa è la convenzione che la maggior parte delle macro procedurali segue.

La funzione ciao_macro_derive prima converte l’input da un TokenStream a una struttura dati che possiamo interpretare ed elaborare. Qui entra in gioco syn. La funzione parse di syn prende un TokenStream e restituisce una struttura DeriveInput che rappresenta il codice Rust analizzato. Il Listato 20-41 mostra le parti rilevanti della struttura DeriveInput ottenuta analizzando la stringa struct Pancake;.

DeriveInput {
    // --taglio--

    ident: Ident {
        ident: "Pancake",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}
Listato 20-41: L’istanza DeriveInput che otteniamo analizzando il codice con l’attributo macro del Listato 20-37

I campi di questa struttura mostrano che il codice Rust che abbiamo analizzato è una struct unit con ident (identificatore, cioè il nome) Pancake. Ci sono altri campi in questa struttura per descrivere ogni tipo di codice Rust; consultare la documentazione syn per DeriveInput per maggiori dettagli.

Presto definiremo la funzione impl_ciao_macro, dove costruiremo il nuovo codice Rust da includere. Ma prima nota che l’output della nostra macro derive è anch’esso un TokenStream. Il TokenStream ritornato viene aggiunto al codice scritto dai nostri utenti, così che quando compilano il loro crate ottengano la funzionalità aggiuntiva che forniamo nel TokenStream modificato.

Potresti aver notato che chiamiamo unwrap per far generare un panic alla funzione ciao_macro_derive se la chiamata a syn::parse fallisce. È necessario che la macro procedurale vada in panic su errori perché le funzioni proc_macro_derive devono restituire un TokenStream e non un Result per conformarsi all’API delle macro procedurali. Abbiamo semplificato questo esempio usando unwrap; nei codici di produzione, si dovrebbero fornire messaggi di errore più specifici usando panic! o expect.

Ora che abbiamo il codice per trasformare il codice Rust annotato da un TokenStream a un’istanza DeriveInput, generiamo il codice che implementa il trait CiaoMacro sul type annotato, come mostrato nel Listato 20-42.

File: hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(CiaoMacro)]
pub fn ciao_macro_derive(input: TokenStream) -> TokenStream {
    // Costruisci una rappresentazione di codice Rust come
    // albero sintattico che possiamo manipolare
    let ast = syn::parse(input).unwrap();

    // Costruisci l'implementazione del trait
    impl_ciao_macro(&ast)
}

fn impl_ciao_macro(ast: &syn::DeriveInput) -> TokenStream {
    let nome = &ast.ident;
    let generato = quote! {
        impl CiaoMacro for #nome {
            fn ciao_macro() {
                println!("Ciao, Macro! Il mio nome è {}!", stringify!(#nome));
            }
        }
    };
    generato.into()
}
Listato 20-42: Implementazione del trait CiaoMacro usando il codice Rust analizzato

Otteniamo un’istanza Ident contenente il nome (identificatore) del type annotato usando ast.ident. La struct nel Listato 20-41 mostra che quando eseguiamo la funzione impl_ciao_macro sul codice nel Listato 20-37, il campo ident sarà Pancake. Quindi la variabile nome nel Listato 20-42 sarà un’istanza di Ident che quando stampata sarà la stringa "Pancake", il nome della struct del Listato 20-37.

La macro quote! ci permette di definire il codice Rust che vogliamo restituire. Il compilatore si aspetta qualcosa di diverso dal risultato diretto dell’esecuzione della macro quote!, quindi dobbiamo convertirlo in un TokenStream. Lo facciamo chiamando il metodo into, che consuma questa rappresentazione intermedia e ritorna un valore richiesto di type TokenStream.

La macro quote! fornisce anche un meccanismo di modellazione molto interessante: possiamo inserire #nome e quote! lo sostituirà con il valore contenuto nella variabile nome. Si possono anche fare ripetizioni simili alle macro normali. Consulta la documentazione del crate quote per un’introduzione completa.

Vogliamo che la nostra macro procedurale generi un’implementazione del trait CiaoMacro per il type annotato dall’utente, che otteniamo usando #nome. L’implementazione del trait ha una funzione, ciao_macro, il cui corpo contiene la funzionalità che vogliamo fornire: stampare Ciao, Macro! Il mio nome è e poi il nome del type annotato.

La macro stringify! utilizzata qui è incorporata in Rust. Prende un’espressione Rust, come 1 + 2, e durante la compilazione la trasforma in una stringa letterale, ad esempio "1 + 2". Questo è diverso da format! o println!, macro che valutano l’espressione e poi trasformano il risultato in una String. C’è la possibilità che l’input #nome possa essere un’espressione da stampare letteralmente, quindi usiamo stringify!. Usare stringify! evita anche un’allocazione convertendo #nome in una stringa letterale durante la compilazione.

A questo punto, il comando cargo build dovrebbe completarsi con successo sia in ciao_macro che in ciao_macro_derive. Colleghiamo questi crate al codice nel Listato 20-37 per vedere la macro procedurale in azione! Creiamo un nuovo progetto binario nella directory progetti con cargo new pancake. Dobbiamo aggiungere ciao_macro e ciao_macro_derive come dipendenze nel Cargo.toml del crate pancake. Se pubblichi le tue versioni di ciao_macro e ciao_macro_derive su crates.io, saranno dipendenze normali; altrimenti puoi specificarle come dipendenze di tipo path come segue:

[dependencies]
ciao_macro = { path = "../ciao_macro" }
ciao_macro_derive = { path = "../ciao_macro/ciao_macro_derive" }

Inserisci il codice del Listato 20-37 in src/main.rs, ed esegui cargo run: dovrebbe stampare Ciao, Macro! Il mio nome è Pancake!. L’implementazione del trait CiaoMacro dalla macro procedurale è stata inclusa senza che il crate pancakes dovesse implementarla; il #[derive(CiaoMacro)] ha aggiunto l’implementazione del trait.

Successivamente, esploreremo come le altre tipologie di macro procedurali differiscono dalle macro derive personalizzate.

Macro Simil-Attributo

Le macro simil-attributo sono simili alle macro derive personalizzate, ma invece di generare codice per l’attributo derive, permettono di creare nuovi attributi. Sono anche più flessibili: derive funziona solo per struct ed enum; gli attributi possono essere applicati anche ad altri elementi, come funzioni. Ecco un esempio di utilizzo di una macro simil-attributo. Supponiamo di avere un attributo chiamato route che annota funzioni in un framework per applicazioni web:

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

Questo attributo #[route] sarebbe definito dal framework come una macro procedurale. La firma della funzione che definisce la macro sarebbe simile a questa:

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

Qui abbiamo due parametri di type TokenStream. Il primo è per il contenuto dell’attributo: la parte GET, "/". Il secondo è il corpo dell’elemento a cui l’attributo è associato: in questo caso, fn index() {} e il resto del corpo della funzione.

A parte questo, le macro simil-attributo funzionano come le macro derive personalizzate: si crea un crate con tipo crate proc-macro e si implementa una funzione che genera il codice desiderato.

Macro Simil-Funzioni

Le macro simil-funzioni definiscono macro che sembrano chiamate di funzione. Come le macro macro_rules!, sono più flessibili delle funzioni; per esempio, possono prendere un numero variabile di argomenti. Tuttavia, le macro macro_rules! possono essere definite solo usando la sintassi simile a match vista nella sezione sulle macro dichiarative con macro_rules!. Le macro simil-funzioni prendono un parametro TokenStream e la loro definizione manipola quel TokenStream usando codice Rust, come fanno le altre due tipologie di macro procedurali.

Un esempio di macro simil-funzione è una macro sql! che potrebbe essere chiamata in questo modo:

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

Questa macro potrebbe analizzare la dichiarazione SQL al suo interno e verificare che sia sintatticamente corretta, un’elaborazione molto più complessa di quella che una macro macro_rules! può fare. La macro sql! sarebbe definita così:

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

Questa definizione è simile alla firma di una macro derive personalizzata: riceviamo i token che stanno dentro le parentesi e restituiamo il codice che vogliamo generare.

Wow! Ora hai appreso alcune funzionalità di Rust che probabilmente non userai troppo spesso, ma sarà utile sapere che sono disponibili in circostanze particolari. Abbiamo introdotto diversi argomenti complessi in modo che, quando li incontrerai nei suggerimenti dei messaggi di errore o nel codice scritto da altri, tu possa riconoscere questi concetti e sintassi. Usa questo capitolo come riferimento per guidarti nelle soluzioni.

Adesso metteremo in pratica tutto ciò di cui abbiamo discusso durante il libro e realizzeremo un altro progetto!