Acheter version papier

Rust

Fork me on GitHub

III. Aller plus loin

1. Les macros procédurales (ou proc-macros)

Je vous avais présenté les macros dans un chapitre précédent. Cependant, elles sont vite limitées et compliquées dès que la complexité de ce qu'on souhaite faire augmente. Pour pallier à ce problème, les proc-macros ont été créées. D'ailleurs, vous vous en êtes déjà servies :

Run#[derive(Debug)]
pub struct S;

Dans ce code, #[derive(Debug)] est en fait une proc-macro. Il en existe plusieurs types différents :

  • Les proc-macros similaires aux macros (dans leur appel) appelées function-like macros.
  • Les derive macros comme dans l'exemple au-dessus.
  • Les macros attributs :
    Run#[une_proc_macro]
    fn une_fonction() {}

Elles fonctionnent toutes les 3 sur le même principe : elles reçoivent un flux de tokens en argument qui représentent le code source et renvoient un autre flux de tokens (le plus souvent modifié par la proc-macro).

Avant d'aller plus loin, il faut déclarer certaines choses dans son Cargo.toml. En effet : une proc-macro ne peut être créée que dans une crate de type bibliothèque, pas dans un binaire. Donc si vous avez besoin de créer une proc-macro pour les besoins d'un projet, il faudra créer une bibliothèque qui contiendra spécifiquement cette proc-macro. La raison en est toute simple : le compilateur ne compile pas le code pour une proc-macro de la même façon.

Déclarons maintenant notre projet "proc_test" dans notre Cargo.toml :

[package]
name = "proc_test"
version = "0.1.0"
edition = "2021"

[lib]
proc-macro = true

Au final, la seule chose qui change est l'ajout de proc-macro = true au final. Cependant, en ajoutant cette option, votre code aura maintenant accès à la crate proc_macro qui fournit des types qui seront nécesaires pour leur écriture.

function-like macro

Écrivons maintenant un petit exemple avec une function-like macro :

Runuse proc_macro::TokenStream;

#[proc_macro]
pub fn genere_dit_bonjour(_item: TokenStream) -> TokenStream {
    "fn dit_bonjour() { println!(\"bonjour\"); }".parse().unwrap()
}

Expliquons ce code maintenant.

TokenStream représente le flux des tokens fournit par le compilateur. C'est dans ce flux que les arguments qui seront passés dans notre macro seront stockés.

#[proc_macro] est un attribut qui indique le type de notre proc-macro. Il y a un attribut différent pour chaque type de proc-macro, nous y reviendrons plus tard.

La fonction genere_dit_bonjour reçoit donc en argument le TokenStream qui contient ce qui est écrit dans l'appel de macro et renvoie un autre TokenStream qui contient ce qui doit être mis à la place de l'appel de cette macro.

Enfin, nous générons donc la fonction dit_bonjour qui appelle println et se termine. La partie intéressante étant .parse().unwrap(). Il est possible de convertir une String en TokenStream de cette façon. Le compilateur va parser la String comme il le ferait avec du code Rust puis générer le flux de tokens.

Donc maintenant il on appelle cette proc-macro dans un autre code :

Runuse proc_test::genere_dit_bonjour;

genere_dit_bonjour!();

fn main() {
    dit_bonjour();
}

Si on compile ce code et qu'on l'exécute, on va obtenir :

bonjour

C'est bien évidemment un test très basique mais je pense que vous commencez à en voir les possibilités. On va maintenant regarder un autre exemple avec une derive macro.

derive macro

Pour nous faciliter la vie, on va utiliser les crates syn pour parser le TokenStream, et quote pour générer le TokenStream. Ces deux crates sont parmi les plus téléchargées de tout l'écosystème de Rust, et pour cause : elles facilitent énormément l'écriture des proc-macros.

Le but de notre derive macro va être de générer des getters et des setters pour chaque champs du type sur lequel elles seront utilisées. Pour nous faciliter la vie, si le type en question est une enum, on va juste renvoyer une erreur de compilation.

Donc avant d'aller plus loin, il faut que l'on tienne compte de plusieurs choses :

  • Est-ce que le champs est visible ou non ? Les méthodes que l'on va générer doivent avoir la même visibilité.
  • Est-ce que le type a des génériques ? Si oui il ne faut pas oublier de les ajouter dans le bloc d'impl sinon ça ne va pas compiler.

Et c'est plus ou moins tout. Commençons par la création de notre fonction de derive :

Run#[proc_macro_derive(GetSet)]
pub fn derive_get_set(input: TokenStream) -> TokenStream {
    // le code
}

Notre derive-macro sera donc appelée de cette façon :

Run#[derive(GetSet)]
pub struct S {
    a: u8,
}

// Les getters et setters pour `S::a` seront donc générés.

Maintenant commençons son implémentation :

Runuse proc_macro::TokenStream;
use syn::{DeriveInput, parse_macro_input};

#[proc_macro_derive(GetSet)]
pub fn derive_get_set(input: TokenStream) -> TokenStream {
    // On parse le contenu de `TokenStream` avec `syn`.
    let input = parse_macro_input!(input as DeriveInput);

    // On peut maintenant gérer chaque type facilement.
    match input.data {
        Data::Enum(_) => {
            return "compile_error!(\"Enum types are not supported\")"
                .parse()
                .unwrap()
        }
        Data::Struct(s) => {
            // Générer getters et setters pour les structs.
        }
        Data::Union(u) => {
            // Générer getters et setters pour les union.
        }
    }
}

Comme vous pouvez le voir, on génère une erreur si jamais le type sur lequel notre proc-macro est utilisée est une enum.

Il reste maintenant à gérer le type union et les différents genres du type struct. Pour chacun de ces types, nous devons récupérer pour chaque champ : son nom, sa visibilité et son type. Nous aurons aussi besoin du nom du type sur lequel notre proc-macro est utilisée, ses génériques ainsi qu'une information importante : est-ce que le type est une union (pour savoir si on doit déclarer les méthodes comme unsafe ou non). Nous enverrons ensuite ces informations dans une fonction qui se chargera de générer les getters et les setters :

Runuse syn::{Data, DeriveInput, Fields, parse_macro_input};
use proc_macro::TokenStream;

#[proc_macro_derive(GetSet)]
pub fn derive_get_set(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);

    // On récupère le nom (ident), la visibilité (vis) et le type (ty) de
    // chaque champ.
    match input.data {
        Data::Enum(_) => {
            return "compile_error!(\"Enum types are not supported\")"
                .parse()
                .unwrap()
        }
        Data::Struct(s) => match s.fields {
            Fields::Named(fields) => {
                let iterateur = fields.named
                    .iter()
                    .map(|champ| {
                        (champ.ident.as_ref().unwrap(), &champ.vis, &champ.ty)
                    });
                implementer_getters_setters(
                    input.ident, input.generics, iterateur, false
                )
            }
            Fields::Unnamed(fields) => {
                // Si jamais on a `struct Foo(u32, pub u8, char)`, il vaut gérer
                // le nom de chaque champ différemment. `u32` sera donc 0 et
                // ainsi de suite.
                let iterateur = fields.unnamed
                    .iter()
                    .enumerate()
                    .map(|(position, champ)| (position, &champ.vis, &champ.ty));
                implementer_getters_setters(
                    input.ident, input.generics, iterateur, false,
                )
            }
            // S'il n'y a pas de champ, on retourne un flux de tokens vide car
            // il n'y a rien à faire.
            Fields::Unit => return TokenStream::new(),
        },
        Data::Union(u) => {
            let iterateur = u.fields
                .named
                .iter()
                .map(|champ| {
                    (champ.ident.as_ref().unwrap(), &champ.vis, &champ.ty)
                });
            implementer_getters_setters(
                input.ident, input.generics, iterateur, true,
            )
        }
    }
}

Notre première fonction est terminée. Implémentons donc maintenant implementer_getters_setters dans laquelle nous allons notamment nous servir de la crate quote :

Runuse syn::{Generics, Ident, Type, Visibility};
use proc_macro::TokenStream;
use quote::{format_ident, quote};

fn implementer_getters_setters<'a, S: ToString, I: Iterator<Item = (S, &'a Visibility, &'a Type)>>(
    nom_du_type: Ident,
    generiques: Generics,
    champs: I,
    est_une_union: bool,
) -> TokenStream {
    // Dans un premier tempsm on convertit l'itérateur de champs en une liste de
    // `TokenStream`.
    let getters_setters = champs
        .map(|(nom, visibilite, type_)| {
            // On convertit le nom (qui est un `ToString`) en `Ident` pour pouvoir
            // l'utiliser dans `format_ident`.
            let nom = format_ident!("{}", nom.to_string());
            // On génère le nom du getter.
            let getter = format_ident!("get_{}", nom);
            // On génère le nom du setter.
            let setter = format_ident!("set_{}", nom);
            // Si le type est une union, il faut un bloc `unsafe` pour pouvoir
            // avoir accès à ses champs.
            let unsafe_ident = if est_une_union {
                Some(format_ident!("unsafe"))
            } else {
                None
            };

            // On génère le getter et le setter pour ce champ. Chaque `#` est
            // par `quote` pour qu'il génère le code de la variable qui suit
            // et pas simplement écrire le nom tel quel.
            quote! {
#visibilite #unsafe_ident fn #getter(&self) -> &#type_ {
    &self.#nom
}

#visibilite #unsafe_ident fn #setter(&mut self, value: #type_) {
    self.#nom = value;
}
            }
        })
        .collect::<Vec<_>>();

    // Si jamais il n'y avait pas de champs, inutile de faire quoi que ce soit
    // de plus.
    if getters_setters.is_empty() {
        return TokenStream::new();
    }
    // On sépare les génériques por pouvoir les déclarer correctement dans le
    // block de l'impl.
    let (generiques_pour_impl, generiques_pour_type, where_clause) =
        generiques.split_for_impl();
    // Dernière partie, on génère le block d'impl avec le nom du type ainsi que
    // ses génériques.
    TokenStream::from(quote! {
impl #generiques_pour_impl #nom_du_type #generiques_pour_type #where_clause {
    #(#getters_setters)*
}
    })
}

Et voilà ! Pour tester le résultat :

Runuse proc_test::GetSet;

#[derive(Default, GetSet)]
pub struct A<T> {
    foo: u32,
    pub bar: f64,
    pub(crate) gen: T,
}

#[derive(GetSet)]
pub union B {
    x: u16,
    pub y: u8,
}

fn main() {
    let mut a = A {
        foo: 0,
        bar: 1.,
        gen: String::from("a"),
    };

    a.set_gen(String::from("une autre string"));
    println!("=> {}", a.get_gen());

    let mut b = B {
        x: 0,
    };

    unsafe {
        b.set_y(5);
        println!("=> {}", b.get_y());
    }
}

Une autre façon serait de générer la documentation avec cargo doc et de vérifier que les méthodes sont bien générées.

Si jamais vous souhaitez utiliser des attributs qui n'existent pas dans votre proc-macro (par-exemple en disant qu'on ne souhaite pas qu'un champ ait un getter, un setter ou aucun des deux), vous devez les déclarer dans proc_macro_derive. Par-exemple :

Run#[proc_macro_derive(GetSet, attributes(no_getter, no_setter))]

Après il suffira de regarder si l'attribut est présent dans les champs attrs des différents types de syn et d'ajouter l'information dans l'itérateur. Regardons à présent les macros attributs.

macro attribut

Contrairement aux deux précédentes, celle-ci permet de modifier l'item sur lequel elle est utilisée. Sa signature est aussi un peu différente :

Run#[proc_macro_attribute]
pub fn modifier_item(attribut: TokenStream, item: TokenStream) -> TokenStream {
    item
}

En premier paramètre, elle prend les arguments de l'attribut et en second elle prend tout l'item sur lequel elle est utilisée (toujours sous forme de TokenStream, bien évidemment). Modifions un peu la fonction pour qu'elle affiche ce qu'elle reçoit :

Run#[proc_macro_attribute]
pub fn modifier_item(attribut: TokenStream, item: TokenStream) -> TokenStream {
    println!("attribut: \"{}\"", attribut.to_string());
    println!("item: \"{}\"", item.to_string());
    item
}

Et maintenant regardons ce que ça affiche quand on utilise cet attribut :

Runuse proc_test::modifier_item;

#[modifier_item]
pub fn foo() {}

#[modifier_item(bonjour)]
pub struct Bonjour;

#[modifier_item { bonjour }]
pub type BonjourType = Bonjour;

#[modifier_item(bonjour >>> 2)]
pub fn foo2() {}

Ce qui affichera (à la compilation) :

attribut: ""
item: "pub fn foo() {}"
attribut: "bonjour"
item: "pub struct Bonjour ;"
attribut: "bonjour"
item: "pub type BonjourType = Bonjour ;"
attribut: "bonjour >> > 2"
item: "pub fn foo2() {}"

Elle est donc beaucoup plus puissante et permissive que les deux précédentes. Comme je vous ai déjà montré un exemple avec une derive macro, je pense que vous avez les bases pour vous en sortir.