Aller au contenu

Les macros procédurales en Rust : écrire votre propre #[derive]

Chaque fois que vous écrivez #[derive(Debug)] ou #[derive(Serialize)], un petit programme s’exécute à l’intérieur du compilateur. Il regarde votre struct et écrit un bloc impl à votre place. Ce petit programme est une macro procédurale (procedural macro), et vous pouvez aussi écrire les vôtres.

Imaginez un employé de bureau très serviable. Vous lui tendez un formulaire (votre struct), il lit chaque champ et vous rend toute la paperasse ennuyeuse déjà remplie. Sur la page Les macros, vous avez découvert macro_rules!, qui fonctionne en comparant des motifs. Les macros procédurales sont différentes : ce sont des fonctions Rust ordinaires qui reçoivent du code et renvoient du code. Sur cette page, vous allez en construire une vraie, pas à pas, et voir la vraie sortie.

Il en existe trois sortes. À l’intérieur, elles fonctionnent toutes de la même façon, mais elles ne se présentent pas pareil pour la personne qui les utilise :

Sorte Ce qu’écrit l’utilisateur Exemples célèbres
Macro derive #[derive(Serialize)] sur une struct ou un enum Serialize de serde, Error de thiserror, Parser de clap
Macro d’attribut #[route(GET, "/")] sur une fonction ou un autre élément #[tokio::main], #[get("/")] d’actix-web
Macro de type fonction sql!(SELECT * FROM users) query! de sqlx, html! de yew
  • Une macro derive se contente d’ajouter du code à côté de votre type. Votre struct reste exactement telle que vous l’avez écrite.
  • Une macro d’attribut remplace l’élément sur lequel elle est placée. #[tokio::main] prend votre async fn main et la transforme en une fn main normale qui démarre le runtime de tokio.
  • Une macro de type fonction ressemble à un appel de macro_rules!, mais c’est une fonction Rust qui décide de ce que l’entrée veut dire. C’est comme ça que sqlx::query! peut vérifier votre SQL à la compilation.

Vous utilisez déjà des macros derive venues d’autres crates. En voici deux qui font un vrai travail. Serialize écrit le code pour le JSON (voir JSON avec Serde) et Error écrit le code de Display à partir du texte de #[error(...)] (voir anyhow et thiserror) :

use serde::Serialize;
use thiserror::Error;
#[derive(Serialize)]
struct Order {
id: u32,
item: String,
quantity: u8,
}
#[derive(Debug, Error)]
enum ShopError {
#[error("l'article {0} est en rupture de stock")]
OutOfStock(String),
}
fn main() {
let order = Order { id: 42, item: "Théière".to_string(), quantity: 2 };
println!("{}", serde_json::to_string(&order).unwrap());
let err = ShopError::OutOfStock("Théière".to_string());
println!("{err}");
}

Sortie :

{"id":42,"item":"Théière","quantity":2}
l'article Théière est en rupture de stock
▶ Essayez par vous-même

Vous n’avez jamais écrit d’impl Display, ni la moindre ligne de code JSON. Les derive l’ont écrit pendant la compilation de votre programme.

Comment ça marche : des tokens entrent, des tokens sortent

Section intitulée « Comment ça marche : des tokens entrent, des tokens sortent »

Le compilateur commence par découper votre code source en tokens (jetons) : des noms, de la ponctuation, des littéraux, des parenthèses et des accolades. Une macro procédurale est une fonction qui reçoit ces tokens sous la forme d’un TokenStream et renvoie un nouveau TokenStream. Le compilateur compile ensuite ce que vous avez renvoyé, comme si vous l’aviez tapé vous-même.

Voici les trois formes de fonction. Chacune est marquée par un attribut, pour que le compilateur sache de quelle sorte il s’agit :

use proc_macro::TokenStream;
// #[derive(HelloMacro)] : reçoit la struct ou l'enum, renvoie du code à AJOUTER.
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream { /* ... */ }
// #[route(GET, "/")] : reçoit les tokens de l'attribut lui-même et l'élément, renvoie un REMPLACEMENT.
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { /* ... */ }
// sql!(...) : reçoit tout ce qui se trouve entre les parenthèses, renvoie le remplacement.
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream { /* ... */ }

Un TokenStream brut n’est qu’une liste de tokens, et c’est pénible à manipuler. C’est pourquoi presque toutes les macros procédurales utilisent trois crates d’aide :

  • syn transforme les tokens en arbre syntaxique. Au lieu de tokens, vous obtenez un DeriveInput avec le nom du type, ses génériques et ses champs.
  • quote retransforme du code qui ressemble à du Rust en tokens. Dans quote! { ... }, vous écrivez du Rust normal, et #name y colle la valeur d’une variable.
  • proc-macro2 est une enveloppe autour des types proc_macro du compilateur ; c’est ce qu’utilisent syn et quote. Elle fonctionne aussi en dehors du compilateur, ce qui facilite les fonctions d’aide et les tests.

Une idée importante : votre macro s’exécute à la compilation. Cargo compile d’abord la crate de la macro, pour votre propre ordinateur, puis le compilateur l’exécute pendant qu’il construit la crate qui l’utilise. Quand le programme finit par s’exécuter, la macro a disparu depuis longtemps. Il ne reste que le code qu’elle a écrit.

Une macro procédurale doit vivre dans sa propre crate, avec un type de crate spécial. Cette crate ne peut exporter que des macros, pas de fonctions ni de traits normaux. L’organisation habituelle, c’est donc deux crates côte à côte, comme serde et serde_derive : un pour le trait, un pour le derive. Nous ajouterons une troisième crate, un petit programme appelé shop qui les utilise. (Besoin de revoir les crates et les workspaces ? Voir Cargo et les crates.)

Terminal window
mkdir procmacro-demo && cd procmacro-demo
cargo new --lib hello_macro
cargo new --lib hello_macro_derive
cargo new shop

Voici l’arborescence finale :

procmacro-demo/
├── Cargo.toml (le workspace)
├── hello_macro/
│ ├── Cargo.toml
│ └── src/lib.rs (les traits et la réexportation des derive)
├── hello_macro_derive/
│ ├── Cargo.toml (proc-macro = true)
│ └── src/lib.rs (les macros elles-mêmes)
└── shop/
├── Cargo.toml
└── src/main.rs (un programme qui utilise #[derive(...)])

Le Cargo.toml du dossier principal réunit les trois crates dans un seul workspace (espace de travail) :

[workspace]
members = ["hello_macro", "hello_macro_derive", "shop"]
resolver = "2"

La crate de la macro est la seule à avoir quelque chose de spécial : proc-macro = true. Il dépend aussi de syn, quote et proc-macro2 :

hello_macro_derive/Cargo.toml
[package]
name = "hello_macro_derive"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = "2"

Les deux autres crates sont normales. hello_macro dépend de la crate du derive, et shop n’a besoin que de hello_macro :

hello_macro/Cargo.toml
[dependencies]
hello_macro_derive = { version = "0.1.0", path = "../hello_macro_derive" }
shop/Cargo.toml
[dependencies]
hello_macro = { version = "0.1.0", path = "../hello_macro" }

Commençons tout petit. Nous voulons que tout type marqué #[derive(HelloMacro)] reçoive une fonction hello_macro() qui affiche le nom du type. D’abord le trait, dans hello_macro/src/lib.rs. La dernière ligne réexporte les macros derive : ainsi, les utilisateurs n’ont besoin que de cette seule crate. C’est exactement ce que fait serde.

hello_macro/src/lib.rs
pub trait HelloMacro {
fn hello_macro();
}
pub trait Describe {
fn type_name() -> &'static str;
fn field_names() -> &'static [&'static str];
}
// Réexporte les macros derive : les utilisateurs n'ont besoin que de cette crate.
pub use hello_macro_derive::{Describe, HelloMacro};

(Describe sert à l’étape 2.) Maintenant, la macro elle-même, dans hello_macro_derive/src/lib.rs :

hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields}; // Data et Fields : étape 2
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// 1. Transforme les tokens en un arbre syntaxique que l'on peut examiner.
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
// 2. Construit le nouveau code avec quote!.
let generated = quote! {
impl hello_macro::HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
// 3. Rend les nouveaux tokens au compilateur.
generated.into()
}

Trois étapes, que vous retrouverez dans presque toutes les macros derive :

  1. parse_macro_input! transforme les tokens en un DeriveInput. ast.ident est le nom du type, par exemple Product. Si l’analyse échoue, cette macro renvoie pour vous une erreur de compilation en bonne et due forme.
  2. quote! construit le bloc impl. #name y colle le nom. stringify!(#name) transforme le nom en texte : "Product".
  3. .into() convertit le proc_macro2::TokenStream de quote en proc_macro::TokenStream du compilateur.

Remarquez le chemin complet hello_macro::HelloMacro. Le code généré est collé dans le fichier de l’utilisateur : il ne peut donc pas compter sur les lignes use de celui-ci.

Quand quelqu’un écrit #[derive(HelloMacro)] struct Product, le compilateur ajoute du code qui fonctionne exactement comme cette version écrite à la main. Celle-ci s’exécute dans le Playground, puisqu’aucune crate de macros n’entre en jeu (nous gardons le texte en anglais pour qu’il corresponde au projet d’exemple) :

trait HelloMacro {
fn hello_macro();
}
struct Product;
// Voici ce que #[derive(HelloMacro)] écrit pour vous :
impl HelloMacro for Product {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", "Product");
}
}
fn main() {
Product::hello_macro();
}

Sortie :

Hello, Macro! My name is Product!
▶ Essayez par vous-même

L’écrire une fois à la main, ça va. L’écrire pour 40 types, c’est là qu’un derive commence à être rentable.

HelloMacro n’utilisait que le nom du type. Les vrais derive, comme ceux de serde, regardent les champs. Construisons Describe : il renvoie les noms des champs d’une struct, ce qui est bien pratique pour l’en-tête d’un fichier CSV. syn vous donne les champs via ast.data :

  • Data::Struct, Data::Enum ou Data::Union : la sorte de type dont il s’agit.
  • Pour une struct, Fields::Named (struct Product { id: u32 }), Fields::Unnamed (struct Meters(f64)) ou Fields::Unit (struct Marker;).

Ajoutez ceci sous la première macro, dans hello_macro_derive/src/lib.rs :

#[proc_macro_derive(Describe)]
pub fn describe_derive(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
expand_describe(&ast)
.unwrap_or_else(|err| err.to_compile_error())
.into()
}
fn expand_describe(ast: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let name = &ast.ident;
let Data::Struct(data) = &ast.data else {
return Err(syn::Error::new_spanned(
name,
"Describe can only be derived for structs",
));
};
let field_names: Vec<String> = match &data.fields {
Fields::Named(fields) => fields
.named
.iter()
.map(|field| field.ident.as_ref().unwrap().to_string())
.collect(),
Fields::Unit => Vec::new(),
Fields::Unnamed(_) => {
return Err(syn::Error::new_spanned(
name,
"Describe needs named fields, like `struct Point { x: i32 }`",
));
}
};
Ok(quote! {
impl hello_macro::Describe for #name {
fn type_name() -> &'static str {
stringify!(#name)
}
fn field_names() -> &'static [&'static str] {
&[#(#field_names),*]
}
}
})
}

Les nouveautés :

  • Le vrai travail se fait dans une fonction d’aide normale qui renvoie un syn::Result. Vous pouvez ainsi utiliser ? et return Err(...) comme dans n’importe quel autre code de gestion des erreurs. La fonction de la macro, tout en haut, se contente de transformer une erreur en erreur de compilation.
  • let ... else règle tout de suite le cas « ce n’est pas une struct » et garde le chemin normal bien à plat.
  • #(#field_names),* est la répétition de quote. Elle colle chaque chaîne du Vec, séparées par des virgules. C’est la même idée que $(...),* dans macro_rules!. Chaque String devient un littéral de chaîne, donc on obtient &["id", "name", "price_cents"].
  • field.ident.as_ref().unwrap() est sans danger ici : dans Fields::Named, tous les champs ont un nom.

Utilisez maintenant les deux derive dans shop/src/main.rs. Remarquez que use hello_macro::{Describe, HelloMacro} importe les traits et les macros derive. Ils portent le même nom, mais vivent dans des espaces de noms différents : c’est exactement comme ça que fonctionne serde::Serialize.

shop/src/main.rs
use hello_macro::{Describe, HelloMacro};
#[derive(HelloMacro, Describe)]
struct Product {
id: u32,
name: String,
price_cents: u64,
}
#[derive(HelloMacro, Describe)]
struct Customer {
email: String,
city: String,
}
fn main() {
Product::hello_macro();
Customer::hello_macro();
println!("{} has fields {:?}", Product::type_name(), Product::field_names());
// Un fichier CSV : l'en-tête vient du derive, les lignes de vraies valeurs.
let ana = Customer { email: "[email protected]".to_string(), city: "Lisbon".to_string() };
println!("{}", Customer::field_names().join(","));
println!("{},{}", ana.email, ana.city);
let teapot = Product { id: 7, name: "Teapot".to_string(), price_cents: 1999 };
println!("{} #{}: {} costs {} cents", Product::type_name(), teapot.id, teapot.name, teapot.price_cents);
}

Lancez-le depuis le dossier du workspace :

Terminal window
cargo run -p shop

Sortie (lors d’une première compilation) :

Compiling proc-macro2 v1.0.107
Compiling unicode-ident v1.0.27
Compiling quote v1.0.47
Compiling syn v2.0.119
Compiling hello_macro_derive v0.1.0 (/home/you/procmacro-demo/hello_macro_derive)
Compiling hello_macro v0.1.0 (/home/you/procmacro-demo/hello_macro)
Compiling shop v0.1.0 (/home/you/procmacro-demo/shop)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.45s
Running `target/debug/shop`
Hello, Macro! My name is Product!
Hello, Macro! My name is Customer!
Product has fields ["id", "name", "price_cents"]
email,city
Product #7: Teapot costs 1999 cents

Les lignes Compiling, Finished et Running sont affichées par Cargo, toujours en anglais. Regardez l’ordre : Cargo construit syn et hello_macro_derive avant shop, parce que le compilateur doit exécuter votre macro pendant qu’il compile shop. Si vous ajoutez un champ à Customer, l’en-tête du CSV se met à jour tout seul à la compilation suivante.

Que se passe-t-il si quelqu’un place #[derive(Describe)] sur un enum ? Comme expand_describe renvoie une syn::Error, l’utilisateur obtient une erreur du compilateur d’apparence tout à fait normale. (Nous avons mis les exemples cassés de cette page dans shop/src/bin/broken.rs, d’où ce chemin.)

use hello_macro::Describe;
#[derive(Describe)]
enum OrderStatus {
Pending,
Shipped,
}
error: Describe can only be derived for structs
--> shop/src/bin/broken.rs:4:6
|
4 | enum OrderStatus {
| ^^^^^^^^^^^

Le message est le texte que nous avons écrit dans la macro (« Describe ne peut être dérivé que pour des structs »). Deux choses rendent cette erreur réussie :

  • syn::Error::new_spanned(name, ...) retient où se trouve le nom dans le fichier de l’utilisateur (son span, sa position dans le code). C’est pour ça que les ^^^^^ pointent sur OrderStatus, et non sur une ligne quelconque de votre macro.
  • to_compile_error() transforme l’erreur en un appel compile_error!("..."). Le compilateur la signale ensuite comme n’importe quelle autre erreur. Vous pouvez d’ailleurs utiliser cette même macro compile_error! dans du code normal.

Une tuple struct reçoit l’autre message (« Describe a besoin de champs nommés »), qui pointe lui aussi au bon endroit :

error: Describe needs named fields, like `struct Point { x: i32 }`
--> shop/src/bin/broken.rs:4:8
|
4 | struct Meters(f64);
| ^^^^^^

Écrivez vos messages pour la personne qui utilise la macro. Dites-lui ce qui ne va pas, et ce qui marcherait à la place.

Les macros peuvent donner l’impression d’être magiques, et la magie, c’est difficile à déboguer. cargo-expand vous montre le code une fois que toutes les macros se sont exécutées. Installez-le une fois pour toutes (voir Les outils de Rust pour d’autres outils pratiques) :

Terminal window
cargo install cargo-expand
cargo expand -p shop

Une partie de ce qu’il affiche pour notre projet :

impl hello_macro::Describe for Product {
fn type_name() -> &'static str {
"Product"
}
fn field_names() -> &'static [&'static str] {
&["id", "name", "price_cents"]
}
}
impl hello_macro::Describe for Customer {
fn type_name() -> &'static str {
"Customer"
}
fn field_names() -> &'static [&'static str] {
&["email", "city"]
}
}

Voilà votre bloc impl, avec les noms des champs déjà remplis et stringify!(#name) déjà devenu "Product". Quand un derive ne fait pas ce que vous attendez, lancez d’abord cargo expand. Il développe aussi println! et les autres macros, alors cherchez vos propres blocs impl.

Une crate de macros a du mal à se tester lui-même, puisqu’il ne peut exporter que des macros. La solution simple : écrire des tests normaux dans une crate qui utilise la macro. Mettez ceci dans hello_macro/tests/describe.rs et lancez cargo test -p hello_macro :

use hello_macro::Describe;
#[allow(dead_code)]
#[derive(Describe)]
struct Product {
id: u32,
name: String,
}
#[test]
fn lists_field_names_in_order() {
assert_eq!(Product::type_name(), "Product");
assert_eq!(Product::field_names(), ["id", "name"]);
}
running 1 test
test lists_field_names_in_order ... ok

Mais comment vérifier que le cas de l’enum donne une bonne erreur ? Un test normal n’y arrive pas, parce que le fichier de test ne compilerait pas. C’est à ça que sert la crate trybuild. Vous placez de petits fichiers « qui doivent échouer » dans un dossier ; trybuild compile chacun d’eux et compare l’erreur du compilateur à un fichier .stderr enregistré :

#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/*.rs");
}

La plupart des crates de macros populaires, dont serde et thiserror, testent leurs messages d’erreur de cette façon.

Les macros procédurales sont puissantes, mais elles ont un coût. Soyez honnête avec vous-même avant de vous lancer :

  • Temps de compilation. syn est une grosse crate. La première compilation de chaque projet qui utilise votre macro doit la compiler. (Les features par défaut de syn suffisent pour les derive. Vous n’avez besoin de features = ["full"] que pour analyser des choses comme le corps d’une fonction, et cela ralentit la compilation.)
  • Complexité. Vous avez maintenant une crate de plus, du code qui écrit du code, et des erreurs plus difficiles à suivre.
  • Outils. Les éditeurs et rust-analyzer comprennent mieux du code ordinaire que du code produit par une macro.

Un bon ordre pour essayer :

  1. Une fonction, un trait avec une méthode par défaut, ou des génériques. La plupart des problèmes de « code répétitif » s’arrêtent là.
  2. Une macro macro_rules! (voir Les macros). Elle ne demande aucune crate supplémentaire.
  3. Une macro procédurale, quand le code dépend de la structure d’un type (son nom, ses champs ou ses variantes), que beaucoup de types en ont besoin et que d’autres personnes vont l’utiliser. C’est pour ça que serde, thiserror et clap utilisent des derive.

Pour comparer, voici Describe écrit avec macro_rules!. Ça fonctionne et ça ne demande aucune crate supplémentaire, mais la struct doit être écrite à l’intérieur de l’appel de macro. Ça devient vite peu pratique, par exemple si vous voulez aussi des commentaires de documentation, des champs pub ou d’autres derive.

trait Describe {
fn type_name() -> &'static str;
fn field_names() -> &'static [&'static str];
}
macro_rules! describe_struct {
(struct $name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
struct $name {
$($field: $ty),*
}
impl Describe for $name {
fn type_name() -> &'static str {
stringify!($name)
}
fn field_names() -> &'static [&'static str] {
&[$(stringify!($field)),*]
}
}
};
}
describe_struct!(struct Customer { email: String, city: String });
fn main() {
let ana = Customer { email: "[email protected]".to_string(), city: "Lisbon".to_string() };
println!("{} has fields {:?}", Customer::type_name(), Customer::field_names());
println!("{},{}", ana.email, ana.city);
}

Sortie :

Customer has fields ["email", "city"]
▶ Essayez par vous-même

Pour un ou deux types dans votre propre programme, cette solution (ou tout simplement écrire l’impl à la main) est souvent le meilleur choix. Un derive est plus agréable pour ceux qui l’utilisent : ils gardent leur struct normale et n’ajoutent qu’une ligne.

Oublier proc-macro = true. Sans la section [lib] dans le Cargo.toml de la crate de la macro, cette crate est une bibliothèque normale, et le compilateur refuse :

error: the `#[proc_macro_derive]` attribute is only usable with crates of the `proc-macro` crate type
--> hello_macro_derive/src/lib.rs:5:1
|
5 | #[proc_macro_derive(HelloMacro)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0432]: unresolved import `proc_macro`

Le compilateur vous dit que #[proc_macro_derive] ne s’utilise que dans les crates de type proc-macro, et c’est aussi pour ça qu’il ne trouve pas la crate proc_macro.

Utiliser panic! ou unwrap() pour les erreurs de l’utilisateur. Ça « marche », mais l’erreur est floue et pointe sur tout le #[derive], pas sur le problème :

error: proc-macro derive panicked
--> shop/src/bin/broken.rs:3:10
|
3 | #[derive(Describe)]
| ^^^^^^^^
|
= help: message: Describe can only be derived for structs

Ici, le compilateur dit seulement que la macro derive a fait un panic, et affiche le message en guise d’aide. Renvoyez plutôt une syn::Error avec new_spanned, comme à l’étape 2.

Des chemins courts dans le code généré. Si vous écrivez impl Describe for #name au lieu de impl hello_macro::Describe for #name, cela ne marche que si l’utilisateur a importé Describe par hasard. Quelqu’un qui écrit #[derive(hello_macro::Describe)] obtient ceci :

error[E0405]: cannot find trait `Describe` in this scope
--> shop/src/bin/broken.rs:1:10
|
1 | #[derive(hello_macro::Describe)]
| ^^^^^^^^^^^^^^^^^^^^^ not found in this scope
|
= note: this error originates in the derive macro `hello_macro::Describe`

Autrement dit : le trait Describe est introuvable dans cette portée (scope). Utilisez toujours des chemins complets dans quote! : hello_macro::Describe, ::std::fmt::Display, ::core::option::Option.

Ces exercices modifient le vrai projet, il n’y a donc pas de bouton « Essayez par vous-même ». Construisez le projet de cette page sur votre propre ordinateur et lancez cargo run -p shop.

Ajoutez une troisième méthode, fn field_count() -> usize, au trait Describe et au derive. Product::field_count() doit renvoyer 3. Indice : vous avez déjà le vecteur field_names. Enregistrez field_names.len() dans une variable avant quote!, puis collez-la avec #.

Voir la solution

Dans hello_macro/src/lib.rs, ajoutez la méthode au trait :

pub trait Describe {
fn type_name() -> &'static str;
fn field_names() -> &'static [&'static str];
fn field_count() -> usize;
}

Dans expand_describe, comptez les champs et ajoutez la méthode à l’impl généré :

let count = field_names.len();
Ok(quote! {
impl hello_macro::Describe for #name {
fn type_name() -> &'static str {
stringify!(#name)
}
fn field_names() -> &'static [&'static str] {
&[#(#field_names),*]
}
fn field_count() -> usize {
#count
}
}
})

Ensuite, println!("{} has {} fields", Product::type_name(), Product::field_count()); dans main affiche :

Product has 3 fields

Le comptage se fait dans votre macro, à la compilation. cargo expand montre que le résultat est simplement fn field_count() -> usize { 3usize }, une constante : il ne reste plus aucun travail à faire à l’exécution.

Essayez #[derive(Describe)] sur une struct générique comme struct Page<T> { items: Vec<T>, number: u32 }. Ça échoue avec error[E0107]: missing generics for struct Page (il manque les génériques), parce que le code généré dit impl hello_macro::Describe for Page et oublie le <T>. Corrigez le derive pour que les structs génériques fonctionnent. Indice : ast.generics.split_for_impl() vous donne trois morceaux à coller dans la ligne de l’impl. Voir Les génériques pour vous rappeler à quoi ressemble impl<T>.

Voir la solution

Dans expand_describe, séparez les génériques et utilisez les trois morceaux dans la ligne de l’impl :

let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();
Ok(quote! {
impl #impl_generics hello_macro::Describe for #name #ty_generics #where_clause {
fn type_name() -> &'static str {
stringify!(#name)
}
fn field_names() -> &'static [&'static str] {
&[#(#field_names),*]
}
}
})

Maintenant, ceci fonctionne dans shop :

#[derive(Describe)]
struct Page<T> {
items: Vec<T>,
number: u32,
}
fn main() {
let page = Page { items: vec!["a", "b"], number: 1 };
println!("{} {} {:?}", page.items.len(), page.number, Page::<String>::field_names());
}
2 1 ["items", "number"]

impl_generics vaut <T> (avec ses contraintes éventuelles), ty_generics vaut <T>, et where_clause est la partie where ..., s’il y en a une. Tout derive sérieux fait cela, alors retenez split_for_impl.

  • Une macro procédurale est une fonction Rust qui s’exécute à la compilation : un TokenStream entre, un TokenStream sort.
  • Il en existe trois sortes : derive (#[derive(Serialize)]), d’attribut (#[tokio::main]) et de type fonction (sql!(...)).
  • Elles vivent dans leur propre crate avec proc-macro = true. Une crate principale les réexporte en général à côté des traits.
  • syn analyse l’entrée (DeriveInput, Data, Fields) et quote écrit la sortie (#name, #(...),*).
  • Signalez les erreurs de l’utilisateur avec syn::Error::new_spanned(...).to_compile_error(), vérifiez le résultat avec cargo expand et testez les erreurs avec trybuild.
  • Essayez d’abord une fonction, un trait ou macro_rules!. Une macro procédurale en vaut la peine quand beaucoup de types ont besoin de code basé sur leur structure.

Petit quiz

1. Quand le code d'une macro procédurale s'exécute-t-il ?

2. Quelle sorte de macro procédurale est #[tokio::main] ?

3. À quoi servent syn et quote ?

4. Votre derive doit refuser les enums. Quelle est la meilleure façon de faire ?

Score : 0 / 4

  • Procedural Macros — The Rust Book (en anglais) : la présentation officielle d’un derive HelloMacro, ainsi que des macros d’attribut et de type fonction.
  • Procedural Macros — The Rust Reference (en anglais) : les règles exactes des trois sortes, les types de crate, les spans et l’hygiène.
  • Documentation de syn (en anglais) : DeriveInput, Data, Fields, Error et les fonctions d’analyse utilisées sur cette page.
  • Documentation de quote (en anglais) : comment fonctionnent l’interpolation avec #var et la répétition avec #(...),* dans quote!.
  • proc-macro-workshop (en anglais) : des projets pratiques et gratuits de David Tolnay (un derive builder, un derive de debug et d’autres), avec des tests qui vous guident pas à pas.