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’attributoderive
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!
.
#[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 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.
use proc_macro;
#[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 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.
use ciao_macro::CiaoMacro;
use ciao_macro_derive::CiaoMacro;
#[derive(CiaoMacro)]
struct Pancake;
fn main() {
Pancake::ciao_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 ciao_macro --lib
Successivamente, nel Listato 20-38, definiremo il trait CiaoMacro
e la sua
funzione associata.
pub trait CiaoMacro {
fn ciao_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.
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();
}
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
:
[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
.
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)
}
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
)
}
)
}
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;
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.
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()
}
CiaoMacro
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_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.
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!