Les macros en Rust

Bonjour à toutes et tous 😃

Le rust est un formidable langage mais du fait de son besoin d'explicité, il est parfois fastidueux d'écrire tout le code nécessaire.

Les macros sont un système qui se place au-dessus de la compilation et vient générer du code pour éviter au développeur de l'écrire.

Il existe 2 types de macros:

  • les macro_rules
  • proc_macro

L'article d'aujourd'hui parlera exclusivement des macro_rules.

J'écris cet article sous la forme d'un pense-bête pour mon futur, mais je vous en fait aussi cadeau ! 🤗

# Les bases des macros

Avant de se lancer à corps perdu dans la bataille ! ⚔

Quelques explications s'imposent.

D'abord, comment marche une macro ?

Une macro est une routine qui a pour rôle d'écrire du code qui sera ensuite compilé.

Prenons un exemple classique, celui d'un hello world.

fn main() {
    println!("hello world");
}

Nous pouvons remplacer ce code par une macro qui va l'écrire à notre place.

macro_rules! hello {
    () => {
        println!("hello world");
    };
}

fn main() {
    hello!();
}

Ces deux codes réalisent exactement la même chose.

La différence fondamentale est que le deuxième code génère le premier.

L'appel de la macro se fait au moyen du hello()! le ! est ici capital, il permet de distinguer l'appel d'une fonction classique de celui d'une macro.

Remarque

println!("hello world") est également une macro. 😁

Lorsque vous réalisé un cargo build ou cargo run, une étape de pré-compilation est réalisée, celle-ci vient remplacer le code de la macro par son résultat étendu.

Ici, la macro hello()! s'étend en println!("hello world") qui lui même sera étendu vers du code qui peut-être compilé.

Ce n'est qu'une fois toutes les macros étendues que le compilateur va réellement construire notre éxécutable.

# Les macros peuvent avoir des paramètres

Remarquez à la ligne 2, le () => {, ça ressemblerait presque de loin à une alternative de match Rust non ?


 




macro_rules! hello {
    () => {
        println!("hello world");
    };
}

Et pour cause c'est un match, il match la signature de ce qui lui est transmis.

Cette ligne compilera

hello!()

Pas celle-là

hello!(1)
1 | macro_rules! hello {
  | ------------------ when calling this macro
...
9 |     hello!(1);
  |            ^ no rules expected this token in macro call

Pour le refaire fonctionner on a plusieurs choix.

Le premier est de faire coincider le pattern avec l'entrée

 





macro_rules! hello {
    (1) => {
        println!("hello world");
    };
}

Ceci compile mais n'est pas très souple car:

hello!(2)

Ne compilera pas.

Si on veut que les 3 appels compilent

hello!();
hello!(1);
hello!(2);

Nous pouvons faire quelque chose comme ceci:

macro_rules! hello {
    () => {
        println!("hello world");
    };
    (1) => {
        println!("hello world");
    };
    (2) => {
        println!("hello world");
    };
}

Bon c'est cool mais on a pas un truc un peux plus souple que ça ?? Je vais pas rajouter tous les nombres de la Terre non plus... 😡

Biensûr qu'il existe un moyen. Il est possible varibiliser 😀

macro_rules! hello {
    () => {
        println!("hello world");
    };
    ($nb:expr) => {
        println!("hello world");
    };
}

Grâce à ça on peut utiliser notre macro avec nos 3 hello mais aussi des truc plus exotique:

hello!();
hello!(1);
hello!(-45258928);
hello!("je suis là");

Nous n'avons pas de notion de typage, on match une expression pas une variable.

Information

C'est un peu compliqué à comprendre au début mais il faut non pas se mettre du point de vue du développeur mais bien du compilateur !

Ici notre macro ne fait que vérifier s'il y a bien une expression entre parenthèses. Et en Rust une expression peut-être bien des choses. 😅

Maintenant un exemple un peu plus intéressant, faire la somme de plusieurs nombres.

macro_rules! sum {
    () => {
        0
    };
    ($x:expr) => {
      $x
    };
    ($x:expr, $y:expr) => {
        $x + $y
    }
}

fn main() {
    println!("{}", sum!());
    println!("{}", sum!(6));
    println!("{}", sum!(6, 7));
}

Expliquons un peu ce code,

Tout d'abord

    ($x:expr) => {
      $x
    };

Cette variante de notre macro, prend en paramètre une entrée $x et vient recopier ce contenu dans sa sortie. Le code étendu sera donc notre entrée.

La deuxième variant de notre macro est:

    ($x:expr, $y:expr) => {
        $x + $y
    }

Elle prend deux paramètres $x et $y en entrées.

Information

Ce qui signifie que la macro est capable de comprendre des entrées mutiples.

Et pour la somme de 3 nombres ?

Facile non ?
macro_rules! sum {
    () => {
        0
    };
    ($x:expr) => {
      $x
    };
    ($x:expr, $y:expr) => {
        $x + $y
    };
    ($x:expr, $y:expr , $z:expr) => {
        $x + $y + $z
    }
}

fn main() {
    println!("{}", sum!());
    println!("{}", sum!(6));
    println!("{}", sum!(6 , 7));
    println!("{}", sum!(6 , 7 , 4));
}

Ok et pour 5, 10, 20, n opérandes dans ta somme ?

Pour le moment on ne peut pas, il nous manque certains concepts.

Mais ne vous inquiétez pas, nous allons les passer en revue. 😀

# Répétition

Imaginez que vous vous vouliez rajouter 2 à chacun des éléments d'un Vec<u8>.

Ce qui signifie que si initialement nous avons

vec![1, 3, 5];

alors l'appel de la macro doit produire:

vec![3, 5, 7];

Comment écrire la macro pour que l'on puisse avoir un nombre arbitraire d'élements dans le tableau ?

Nous pouvons écrire la macro ainsi:

macro_rules! vec_plus_2 {
    (vec![$($x:expr),*]) => {
        vec![$($x + 2),*]
    };
}

fn main() {

    println!("{:?}", vec_plus_2!(vec![1, 3, 5]));
}

Nous affiche:

[3, 5, 7]

Analysons un peu ce qui se passe lors de l'éxécution de la macro.

Tout d'abord notre pattern est composé de (vec![...]), ce qu'il signifie qu'il s'attend à avoir un vec! de quelque chose en entrée.

Ensuite nous avons une syntaxe un peu spéciale, $(...),*, celle ci s'appelle une répétition.

Si on décompose cela nous donne $(...) sep rep

Le séparateur peut-être , ou ;.

Il existe 3 types de répétitions rep différentes:

  • $(...)* : zéro ou plusieurs fois
  • $(...)+ : une ou plusieurs fois
  • $(...)? : une ou zéro fois

Ainsi, le pattern $($x:expr),* match zéro ou plusieurs fois des expressions séparées par un virgule ,.

Notre macro va avoir pour entrée vec![1, 3, 5].

Le corps de notre macro est:

vec![$($x + 2),*]

Décomposons ce qu'il se passe:

  1. vec![ : on écrit les caractères tels quels
  2. on rentre dans la répétition
  3. le $x match l'expression 1 : on écrit vec![1 + 2
  4. un séparateur , est rajouté vec![1 + 2,
  5. le $x match l'expression 3 : on écrit vec![1 + 2, 3 + 2
  6. un séparateur , est rajouté vec![1 + 2, 3 + 2,
  7. le $x match l'expression 5 : on écrit vec![1 + 2, 3 + 2, 5 + 2
  8. il n'y plus d'expression à matcher, on sort de la répétition
  9. le ] est rajouté : vec![1 + 2, 3 + 2, 5 + 2]

Finalement nous avons notre sortie, qui une fois éxécutée donnera notre résultat du println!

Précision

Mais il faut bien garder en tête que le code compilé sera

println!(vec![1 + 2, 3 + 2, 5 + 2]) 

et non

println!(vec![3, 5, 7])

# Attraper les tous !

Les macros peuvent capturer tout un tas de choses, à vrai dire tout peut être capturé.

Non, ce ne sont pas des pokémons comme le titre le laisse présager, il s'agit de ce que l'on appelle des métavariables.

Il en existe un total de 11 différentes.

# Item

Les items sont représentés par l'identifieur $x:item.

Contrairement à ce que l'on pourrait penser, il ne match pas un item de vec! par exemple, mais en fait tout ce qui existe dans un module, les modules interne compris.

La liste complète des choses qui peuvent-être capturé est ici (opens new window).

macro_rules! item_match {
    ($mod:item) => {
        fn toto() {
            println!("Hello");
        }
    };
}

item_match! {
    mod toto {
        fn tata() {
            println!("Bye!")
        }
    }
}

Une fois étendu le code donnera:

fn toto() {
    println!("Hello");
}

On utilise le type item pour effectuer des remplacements brutaux dans le code. Et on peut littéralement tout matcher avec! 😁

# Block

Les blocks sont représentés par l'identifieur $x:block.

On se spécialise un peu plus, au lieu de tout matcher on va uniquement pouvoir matcher des blocs de code.

Ceux-ci sont délimités par des { et }.

Exemple:

macro_rules! block_match {
    ($block:block) => {
        println!("666")
    };
}

fn main() {
    block_match!{
        {
            println!("42");
        }
    }
}

Au lieu d'afficher 42, nous allons remplacer le bloc de code par println!("666") et donc afficher à l'éxécution 666.

# Statement

Les statements sont représentés par l'identifieur $x:stmt.

Ils matchent toutes les expressions se terminant par un point-virgule.

Exemple:

macro_rules! statements_match {
    ({$($statement:stmt);*$(;)*}) => {
        fn wrapper() {
            $($statement);*;
        }
    };
}

fn main() {
    statements_match!({
        println!("tata");
        println!("titi");
        println!("tutu");
        let x = 2;
        println!("x = {}", x);
    });

    wrapper();
}

Cela va créer une fonction wrapper qui lorsqu'elle est appelée affichera:

tata
titi
tutu
x = 2

Remarque

Ici on bénéficie aussi de la répétition pour pouvoir matcher tous les statements du bloc.

# Expression

Les expressions sont représentées par l'identifieur $x:expr.

Les expressions sont tout les éléments qui produisent un résultat, la liste complète est disponible ici (opens new window).

Les expressions rassemblent ce qui peut-être affecté à une variable via la syntaxe let ou let mut.

Mais il est aussi possible de matcher des appels de fonctions sans pour autant devoir affecter le résultat à une variable.

macro_rules! expression_match {
    ({$(let $x:ident = $expression:expr;)*}) => {
        fn block_wrapper() {
            $(let $x = $expression + 1);*;
        }
    };
}

fn main() {
    expression_match! {
        {
            let x = 1;
            let y = {
              5
            };
        }
    }
}

Vient écrire une fois étendue:

fn block_wrapper() {
    let x = 1 + 1;
    let y = ({
        5
    }) + 1;
}

On peut aussi faire un exemple sans affectation de variable.

macro_rules! expression_match {
    ({$($expression:expr);*}) => {
        fn block_wrapper2() {
            $($expression);*;
        }
    };
}

fn main() {

    fn hello3() {
        println!("hello3")
    }

    expression_match! {
        {
            hello3()
        }
    }
    block_wrapper2()
}

Ce code affichera hello3.

# Identifier

Les identifiers sont représentés par l'identifieur $x:ident.

Ceux-ci correspondent principalement aux noms donnés aux choses manipulées dans le code (variable, structure, enum, champ de structure, ...).

macro_rules! identifier_match {
    (fn $name:ident {
        $($line:stmt)*
    }) => {
        fn $name () -> u8 {
            $($line)*
            println!("fin {}", stringify!($name));
            42
        }
    };
}

fn main() {
        identifier_match!(fn answer_to_everything {
        let x = 3;
        let y = 42;
    });
    println!("answer: {}", answer_to_everything());
}

Ce code créé une fonction answer_to_everything qui affiche fin answer_to_everything et renvoie 42;

fn answer_to_everything() -> u8 {
        let x = 3;
        let y = 42;

        println!("fin {}", stringify!(answer_to_everything));
        42
}

On capture le nom de la fonction pour l'utiliser dans la suite de la macro.

Remarque

La macro stringify! permet de transformer un ident en string et ainsi pouvoir l'afficher dans un println!

# Pattern

Les patterns sont représentés par l'identifieur $x:pat.

Ils peuvent matcher toutes les ranges possibles.

Exemple:

macro_rules! pattern_match {
    ($pattern:pat) => {
        0..=7
    };
}

fn main() {
    for i in pattern_match!(0..666) {
        println!("{}", i)
    }
}

Au lieu d'afficher les nombres de 0 à 665, on va afficher les nombres de 0 à 7 compris.

On peut aussi venir matcher des pattern dans des opérations if let ou des match.

Par exemple:

macro_rules! if_let_match {
    (if let $pat:pat = $ident:ident {}) => {
        println!("Le pattern du if let est {}", stringify!($pat))
    };
}

fn main() {

    if_let_match! {
        if let Ok(Some(x)) = toto {}
    }

    if_let_match! {
        if let Err(None) = toto {}
    }
}

Affiche:

Le pattern du if let est Ok(Some(x))
Le pattern du if let est Err(None)

# Type

Les types sont représentés par l'identifieur $x:ty.

Comme son nom l'indique cette métavariable vient matcher les types des expressions.

Par exemple

macro_rules! type_match {
    (($($type:ty),*)) => {
        vec![$(stringify!($type)),*]
    };
}

fn main() {

    struct A;

    let types = type_match! {
        (u8, f32, A)
    };
    for t in types {
        println!("{}", t)
    }
}

Affiche:

u8
f32
A

Et comme maintenant vous êtes aussi capables de comprendre les ident, on peut s'amuser avec les champs des structures.

macro_rules! struct_field_name_type_match {
    (struct $name:ident  {
        $($field_name:ident:$field_type:ty),*
    }
    ) => {
        println!("Le nom de la struct est {}", stringify!($name));
        $(println!("\ta pour champ {} de type {}", stringify!($field_name), stringify!($field_type)));*;
    };
}

fn main() {
    
    struct A;
    
    struct_field_name_type_match! {
        struct Person {
            name: String,
            age: u8,
            address: A
        }
    }
}

Affiche

Le nom de la struct est Person
	a pour champ name de type String
	a pour champ age de type u8
	a pour champ address de type A

# Chemin

Les paths sont représentés par l'identifieur $x:path.

Correspondent au chaines de caractères représentant les chemins vers les modules ou les objets importés.

Ceux-ci sont séparés par des ::.

macro_rules! path_match {
    (mod $path:path) => {
        println!("Le nom du module est {}", stringify!($path))
    };
}

fn main() {
    path_match! {
        mod path::test::toto::tutu
    }
}

Affiche:

Le nom du module est path::test::toto::tutu

# Token Tree

Les token tree sont représentés par l'identifieur $x:tt.

Ils permettent de matcher tout ce qui peut se trouver entre:

  • ( et )
  • [ et ]
  • { et }

Je dois vous avouer que j'ai essayé de comprendre comment ça marche mais je n'ai pas réussi à comprendre. 😅

La doc (opens new window) est plutôt énigmatique.

Elle parle de matcher des tokens séparés par des délimiteurs, mais avec mes tests je n'ai pas d'exemple probant à vous montrer pour une fois. 😛

# Litteral

Les literals sont représentés par l'identifieur $x:literal.

Cela correspond aux valeurs primitives affectées aux variables et constantes.

macro_rules! literal_match {
    ($lit:literal) => {
        println!("{} est un literal", $lit)
    };
    ($x:ident) => {
       println!("{} est un ident", stringify!($x))
    }
}

fn main() {
   literal_match! {
        5
    }

    literal_match! {
        5.2_f32
    }

    literal_match! {
        "chat"
    }

    literal_match! {
        bool
    }

    literal_match! {
        x
    } 
} 

Ce code affichera:

5 est un literal
5.2 est un literal
chat est un literal
bool est un ident
x est un ident

# Lifetime

Les lifetimes sont représentés par l'identifieur $x:lifetime.

Il permettent de matcher les lifetime utilisé par le borrow checker.

macro_rules! lifetime_match {
    (fn $name:ident<$($lifetime:lifetime),*>($($_param:ident : $_type:ty),*){$($_line:stmt);*}) => {
        println!("la fonction {}", stringify!($name));
        $(println!("\ta pour lifetime {}", stringify!($lifetime)));*
    };
}

fn main() {
    lifetime_match! {
        fn test<'a, 'b>(x: u8, y : bool) {
            println!("coucou")
        }
    }
}

Affiche

la fonction test
	a pour lifetime 'a
	a pour lifetime 'b

# Visibilité

Les visilities sont représentées par l'identifieur $x:vis.

Les visibilités sont les mots-clef permettant de spécifier la portée des éléments exportables d'un module à l'autre.

Il existe 5 types de visibilités différentes.

  • pub
  • pub (crate)
  • pub (self)
  • pub (super)
  • pub (in path)

Il est possible de matcher toutes ces visibilités.

macro_rules! visibility_match {
    ($visibility:vis fn $name:ident($($_param:ident : $_type:ty),*){$($_line:stmt);*}) => {
        println!("la fonction {} a pour visibilité {}", stringify!($name), stringify!($visibility))
    };
}

fn main() {
    
    visibility_match! {
        fn test_private() {}
    }

    visibility_match! {
        pub fn test_public() {}
    }

    visibility_match! {
        pub (self) fn test_self() {}
    }

    visibility_match! {
        pub (super) fn test_super() {}
    }

    visibility_match! {
        pub (crate) fn test_crate() {}
    }

    visibility_match! {
        pub (in module::mod2::test) fn test_in_path() {}
    }
}

Affiche:

la fonction test_private a pour visibilité 
la fonction test_public a pour visibilité pub 
la fonction test_self a pour visibilité pub(self) 
la fonction test_super a pour visibilité pub(super) 
la fonction test_crate a pour visibilité pub(crate) 
la fonction test_in_path a pour visibilité pub(in module::mod2::test)

Dans le cas de test_private, l'absence de visibilité implique que la méthode est privée. Et donc notre macro n'a rien a afficher.

# Attributs

Les attributes sont représentées par l'identifieur $x:meta.

Les attributs permettent de spécifier des comportements avancés sur des items.

Par exemple:

#[derive(Debug)]
struct A;

L'attribut #[derive(Debug)] rajoute le trait Debug à notre structure A.

Il existe des attributs internes ou externes

Les externes sont par exemple les #[derive(...)].

Et les internes.

fn with_unused_variables() {
    #![allow(unused_variables)]
    let x = 45;
}

Ici l'attribut se rapporte à l'intérieur de la fonction, permettant de pas utiliser la variable x sans que le compilateur ne déclenche de warning.

Si on avait utilisé la version externe, ce serait la non-utilisation de la fonction with_unused_variables qui serait permise, mais la varible x déclencherait toujours des warnings.

Un exemple de macro pourrait être:

macro_rules! attribute_match {
    (#![$attr:meta]) => {
        println!("#[{}] is an outer attribute", stringify!($attr))
    };
    (#[$attr:meta]) => {
        println!("!#[{}] is an inner attribute", stringify!($attr))
    };
}

fn main() {
    attribute_match! {
        #[derive(Debug)]
    }

    attribute_match! {
        #![derive]
    }
}

Affiche:

!#[derive(Debug)] is an inner attribute
#[derive] is an outer attribute

Et voilà fini on peut tout matcher dans le code !! 🤩

Parlons de concepts un plus évolué maintenant.

# Hygiène

Contrairement aux macros en langage C qui sont littéralement du remplacement de chaîne de caractères.

Les macros sont conscientes des variables qu'elles manipulent.

Ainsi pour des raisons de sécurité, les variables déclarées à l'extérieur des macros ne peuvent pas être manipulées par celles-ci. Sauf si elles sont dans le même scope.

Prenons un exemple:

let mut x = 41;

macro_rules! inc {
    () => {
        x+=1
    };
}
inc!();
println!("{}", x)

Ce code affiche 42, car le x est déclaré dans le même scope que la macro inc!.

Par contre:

macro_rules! inc {
    () => {
        x+=1
    };
}

let mut x = 41;

inc!();
println!("{}", x)

Ce code ne compile pas, car x n'est pas connu au moment de l'expansion de la macro inc!.

Pour "réparer" cette macro nous devons lui indiquer la référence vers la variable x.

macro_rules! inc {
    ($x:ident) => {
        $x+=1
    };
}

let mut x = 41;

inc!(x);
println!("{}", x)

Et maintenant cela remarche. 😃

Les macros peuvent venir de n'importe où, et parfois on ne regarde pas ce qu'il y a dedans (toujours, en fait, ne mentez pas, je vous vois 👀).

Prenons un exemple:

let mut x = 41;

macro_rules! devil {
    () => {
        let mut x = 666;
        x+=1;
    };
}
devil!();
println!("{}", x)

Imaginons que l'on appelle notre macro devil!(). En l'absence du système d'hygiène, le code devrait afficher 667.

Mais grâce à ce système on vient en quelque sorte encapsuler ce let x dans un scope interne. L'incrémentation elle même change de comportement et au lieu de venir incrémenter le x de l'extérieur de la macro, on incrément le x interne.

Ce qui nous permet de préserver la valeur afficher à 41.

Cette macro peut alors être déplacée au dessus de la déclaration de x.

macro_rules! devil {
    () => {
        let mut x = 666;
        x+=1;
    };
}


let mut x = 41;
devil!();
println!("{}", x)

La macro devil!() peut maintenant être appelée avant la déclaration de x car elle définit son propre x qui n'a rien à voir (n'est pas la même case mémoire) par rapport à celui affiché dans le println!.

# Expansion en cascade

L'expansion imbriquée est la capacité d'une macro d'en contenir une autre.

Exemple:

macro_rules! a {
    () => {
        1
    };
}

macro_rules! b {
    ($x:expr) => {
        $x + 1
    };
}

macro_rules! c {
    ($x:expr) => {
        println!("{}", $x)
    };
}

fn main() {
    c!(b!(a!()))
}

Affiche

2

Que se passe-t-il ?

Si on décompose, nous allons avoir:

  1. initialement nous avons c!(b!(a!()))
  2. qui s'étend en println!("{}", b!(a!()))
  3. qui s'étend en println!("{}", a!() + 1)
  4. qui s'étend en println!("{}", 1 + 1)

On voit donc que l'expansion des macros se fait de l'extérieur vers l'intérieur.

Attention

Il faut donc faire attention à l'ordre d'appel des macros pour obtenir le résultat souhaité !

Une autre remarque est à toute étape de l'expansion, le code doit rester valide.

Une macro peut bien s'appeler elle-même tant qu'une de ses variantes match et nous allons nous en servir dans la partie suivante. 🙂

Remarque

c!(b!(b!(a!())))

est tout aussi valide et affichera 3.

Je vous laisse faire l'expansion pour vous en convaincre ^^

# On met en pratique

Je sais pas si vous vous en souvenez mais à la base on voulait réaliser la somme d'un nombre arbitraire d'opérandes.

C'est ce que l'on va faire tout de suite ! Je vous indique une solution et on la décortique! 😀

macro_rules! sum {
    () => {
        0
    };
    ($x:expr) => {
        $x
    };
    ($x0:expr, $($x:expr),*) => {
       $x0 + sum!($($x),*)
    };
}

fn main() {
    println!("{}", sum!(6, 7, 4, 5));
}

Si vous éxécutez ce code vous afficherez 22.

Que se passe-t-il ?

  1. initialement nous avons sum!(6, 7, 4, 5)
  2. On match les patterns:
    1. ce n'est pas une parenthèse vide donc pas ()
    2. ce n'est pas non plus une expressions donc pas ($x:expr)
    3. ça commence par une expression suivi d'une virgule et optionnellement quelque chose après donc le pattern ($x0:expr, $($x:expr),*) match !
  3. on remplace l'expression par 6 + sum!(7, 4, 5)
  4. Même système le seul pattern qui match est ($x0:expr, $($x:expr),*)
  5. on remplace l'expression par 6 + (7 + sum!(4, 5))
  6. le seul pattern qui match est ($x0:expr, $($x:expr),*)
  7. on remplace l'expression par 6 + (7 + (4 + sum!(5))
  8. cette fois ci le seul pattern qui match est ($x:expr)
  9. on remplace par 6 + (7 + (4 + 5))

En nettoyant les parenthèses qui n'ont pas d'incidence, on retrouve bien 6 + 7 + 4 + 5 = 22. 🎉

Ouf !! On a réussi! 😄

Maintenant on a tous les outils pour bosser efficacement avec les macros !

# Conclusion

J'espère que ce petit tour des macro_rules en Rust vous plu! 😎

Nous allons dans le prochaine à paraître pour construire un système très automatisé qui nous épargenera l'écriture de beaucoup de lignes de codes. 💪

Je vous remercie de votre lecture et vous dis à la prochaine pour plus de Rust ! 😍