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
.
Nous pouvons remplacer ce code par une macro qui va l'écrire à notre place.
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.
flowchart LR a(Code avec macro) b(Code précompilé) a-->|pré compilation|b b-->|compilation|Executable
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 ?
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 | 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
Ceci compile mais n'est pas très souple car:
hello!
Ne compilera pas.
Si on veut que les 3 appels compilent
hello!;
hello!;
hello!;
Nous pouvons faire quelque chose comme ceci:
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 😀
Grâce à ça on peut utiliser notre macro avec nos 3 hello
mais aussi des truc plus exotique:
hello!;
hello!;
hello!;
hello!;
Nous n'avons pas de notion de typage, on match une expression pas une variable.
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.
Expliquons un peu ce code,
Tout d'abord
=> ;
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:
=>
Elle prend deux paramètres $x
et $y
en entrées.
Ce qui signifie que la macro est capable de comprendre des entrées mutiples.
Et pour la somme de 3 nombres ?
Code Rust
Facile non ?
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!;
alors l'appel de la macro doit produire:
vec!;
Comment écrire la macro pour que l'on puisse avoir un nombre arbitraire d'élements dans le tableau ?
Nous pouvons écrire la macro ainsi:
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!
Décomposons ce qu'il se passe:
vec![
: on écrit les caractères tels quels- on rentre dans la répétition
- le
$x
match l'expression1
: on écritvec![1 + 2
- un séparateur
,
est rajoutévec![1 + 2,
- le
$x
match l'expression3
: on écritvec![1 + 2, 3 + 2
- un séparateur
,
est rajoutévec![1 + 2, 3 + 2,
- le
$x
match l'expression5
: on écritvec![1 + 2, 3 + 2, 5 + 2
- il n'y plus d'expression à matcher, on sort de la répétition
- 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!
Mais il faut bien garder en tête que le code compilé sera
println!
et non
println!
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.
item_match!
Une fois étendu le code donnera:
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:
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:
Cela va créer une fonction wrapper
qui lorsqu'elle est appelée affichera:
tata
titi
tutu
x = 2
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.
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.
Vient écrire une fois étendue:
On peut aussi faire un exemple sans affectation de variable.
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, ...).
Ce code créé une fonction answer_to_everything
qui affiche fin answer_to_everything
et renvoie 42
;
On capture le nom de la fonction pour l'utiliser dans la suite de la macro.
La macro
stringify!
permet de transformer unident
en string et ainsi pouvoir l'afficher dans unprintln!
Pattern
Les patterns sont représentés par l'identifieur $x:pat
.
Ils peuvent matcher toutes les ranges possibles.
Exemple:
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:
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
;
}
Affiche:
u8
f32
A
Et comme maintenant vous êtes aussi capables de comprendre les ident
, on peut s'amuser avec les champs des structures.
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 ::
.
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 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.
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.
;
}
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.
;
}
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:
;
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.
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:
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;
inc!;
println!
Ce code affiche 42
, car le x est déclaré dans le même scope que la macro inc!
.
Par contre:
let mut x = 41;
inc!;
println!
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
.
let mut x = 41;
inc!;
println!
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;
devil!;
println!
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
.
let mut x = 41;
devil!;
println!
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:
Affiche
2
Que se passe-t-il ?
Si on décompose, nous allons avoir:
- initialement nous avons
c!(b!(a!()))
- qui s'étend en
println!("{}", b!(a!()))
- qui s'étend en
println!("{}", a!() + 1)
- 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.
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. 🙂
c!
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! 😀
Si vous éxécutez ce code vous afficherez 22
.
Que se passe-t-il ?
- initialement nous avons
sum!(6, 7, 4, 5)
- On match les patterns:
- ce n'est pas une parenthèse vide donc pas
()
- ce n'est pas non plus une expressions donc pas
($x:expr)
- ça commence par une expression suivi d'une virgule et optionnellement quelque chose après donc le pattern
($x0:expr, $($x:expr),*)
match !
- ce n'est pas une parenthèse vide donc pas
- on remplace l'expression par
6 + sum!(7, 4, 5)
- Même système le seul pattern qui match est
($x0:expr, $($x:expr),*)
- on remplace l'expression par
6 + (7 + sum!(4, 5))
- le seul pattern qui match est
($x0:expr, $($x:expr),*)
- on remplace l'expression par
6 + (7 + (4 + sum!(5))
- cette fois ci le seul pattern qui match est
($x:expr)
- 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 ! 😍
Ce travail est sous licence CC BY-NC-SA 4.0.