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’attributoderiveusato 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 Meta-Programmazione 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”. Nella loro essenza, 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!.
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
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 farlo
corrispondere; 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 tonde 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 tonde 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
corrisponde 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 corrisponde. 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.
use proc_macro::TokenStream;
#[qualche_attributo]
pub fn qualche_attributo(input: TokenStream) -> TokenStream {
}
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 hello_macro che definisce un trait chiamato
HelloMacro con una funzione associata chiamata hello_macro. Invece di far
implementare agli utenti il trait HelloMacro per ogni loro type, forniremo
una macro procedurale che consente agli utenti di annotare il loro type con
#[derive(HelloMacro)] per ottenere un’implementazione di default della
funzione hello_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.
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)]
struct Pancake;
fn main() {
Pancake::hello_macro();
}
Questo codice stamperà Ciao, Macro! Il mio nome è Pancake! quando sarà
eseguito. Il primo passo è creare un nuovo crate libreria come segue:
$ cargo new hello_macro --lib
Successivamente, nel Listato 20-38, definiremo il trait HelloMacro e la sua
funzione associata.
pub trait HelloMacro {
fn hello_macro();
}
deriveAbbiamo 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.
use hello_macro::HelloMacro;
struct Pancake;
impl HelloMacro for Pancake {
fn hello_macro() {
println!("Ciao, Macro! Il mio nome è Pancake!");
}
}
fn main() {
Pancake::hello_macro();
}
HelloMacroTuttavia, gli utenti dovrebbero scrivere il blocco di implementazione per ogni
type su cui vogliono usare hello_macro; vogliamo risparmiarli da questo
lavoro.
Inoltre, non possiamo ancora fornire alla funzione hello_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 hello_macro_derive all’interno del progetto
hello_macro:
$ cargo new hello_macro_derive --lib
I due crate sono strettamente correlati, quindi creiamo il crate di macro
procedurali nella cartella del crate hello_macro. Se cambiamo la definizione
del trait in hello_macro, dovremo cambiare anche l’implementazione della
macro procedurale in hello_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 hello_macro utilizzi
hello_macro_derive come dipendenza e rimandi il codice della macro
procedurale. Tuttavia, la struttura scelta consente ai programmatori di usare
hello_macro anche se non vogliono la funzionalità derive.
Dobbiamo dichiarare il crate hello_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 hello_macro_derive:
[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 hello_macro_derive. Nota che questo
codice non si compila fino a quando non aggiungiamo una definizione per la
funzione impl_hello_macro.
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(HelloMacro)]
pub fn hello_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_hello_macro(&ast)
}
Nota che abbiamo diviso il codice in una funzione hello_macro_derive,
responsabile della lettura ed elaborazione del TokenStream, e una funzione
impl_hello_macro, responsabile della trasformazione dell’albero sintattico:
questo rende la scrittura di una macro procedurale più comoda. Il codice nella
funzione esterna (hello_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_hello_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 e trasforma 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 hello_macro_derive verrà chiamata quando un utente della nostra
libreria specifica #[derive(HelloMacro)] su di un type. Questo è possibile
perché abbiamo annotato la funzione hello_macro_derive con proc_macro_derive
e specificato il nome HelloMacro, che corrisponde al nostro nome di trait;
questa è la convenzione che la maggior parte delle macro procedurali segue.
La funzione hello_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
)
}
)
}
DeriveInput che otteniamo analizzando il codice con l’attributo macro del Listato 20-37I 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;
consulta la documentazione syn per DeriveInput per maggiori
dettagli.
Presto definiremo la funzione impl_hello_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 hello_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 HelloMacro sul type annotato, come mostrato nel Listato 20-42.
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(HelloMacro)]
pub fn hello_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_hello_macro(&ast)
}
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let nome = &ast.ident;
let generato = quote! {
impl HelloMacro for #nome {
fn hello_macro() {
println!("Ciao, Macro! Il mio nome è {}!", stringify!(#nome));
}
}
};
generato.into()
}
HelloMacro usando il codice Rust analizzatoOtteniamo 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_hello_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 del type richiesto
TokenStream.
La macro quote! fornisce anche un meccanismo di sostituzione 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
HelloMacro per il type annotato dall’utente, che otteniamo usando #nome.
L’implementazione del trait ha una funzione, hello_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, in questo caso "1 + 2". Questo è diverso da format! o
println!, che sono 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 hello_macro che in hello_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 hello_macro e hello_macro_derive come dipendenze nel
file Cargo.toml del crate pancake. Se pubblichi le tue versioni di
hello_macro e hello_macro_derive su crates.io, saranno
dipendenze normali; altrimenti puoi specificarle come dipendenze di tipo path
come segue:
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_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 HelloMacro dalla macro procedurale è stata inclusa senza che il
crate pancakes dovesse implementarla; il #[derive(HelloMacro)] 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-Funzione
Le macro simil-funzione 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 “Macro Dichiarative Per la Meta-Programmazione
Generale” vista in precedenza. 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.
Riepilogo
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!