Les macros en Rust
Les macros en Rust sont loin d’être simple à prendre en main, et l’aide sur le sujet est assez limité (à l’heure où j’écris ces lignes). L’un des cas où j’ai eu à les utiliser intensivement, est lors de l’écriture de longs tests unitaires très répétitifs.
Macro avancé pour des cas complexes
Disons que nous voulons répéter un pattern, comme ceci:
cafe "with" 1 "checkins"
=> [(&cafe, 1)]
cafe "with" 1 "checkins", school "with" 3 "checkins"
=> [(&cafe, 1), (&school, 3)]
cafe "with" 1 "checkins", school "with" 3 "checkins", cinema "with" 8 "checkins"
=> [(&cafe, 1), (&school, 3), (&cinema, 8)]
"nothing"
=> []
Il nous faudra une macro similaire à celle-ci:
macro_rules! gen_list {
// Converts.
(@as_expr $e:expr) => {$e};
// Inner.
(@accum( "nothing" ) -> ($($body:tt)*)) => {
Vec::new()
};
(@accum( $poi:ident "with" $checkin:literal "checkins" , $($tail:tt)+ ) -> ($($body:tt)*)) => {
gen_list!(@accum( $($tail)+) -> ($($body)* (&$poi, $checkin),) )
};
(@accum( $poi:ident "with" $checkin:literal "checkins" ) -> ($($body:tt)*)) => {
gen_list!(@as_expr vec![ $($body)* (&$poi, $checkin) ] )
};
// Error handling.
(@accum( $($tail:tt)+ ) -> ()) => {
compile_error!(concat!("gen_list: Issue while recurring, state is ", stringify!([ $($tail)* ])))
};
// Public rules.
["nothing"] => {
gen_list!(@accum( "nothing" ) -> ())
};
[$($tail:tt)+] => {
gen_list!(@accum( $($tail)+ ) -> ())
};
}
Vu comme cela ça parait un peu complexe, et ça fait immédiatemment peur. Mais creusons progressivement pour voir que ce n’est au final pas si compliqué.
Répétition de pattern
Généralement, quand on traite une répétition, on utilise un pattern de capture, comme ceci:
// gen_list!(u1, u2, u3)
// => u1
// u2
// u3
macro_rules! gen_list {
// stop case
($x:expr) => {
println!("{}", $x)
};
// main loop
($x:expr, $($y:expr),+) => {
{
println!("{}", $x);
gen_list!($($y),+)
}
};
}
ℹ️ Rappel
$x:literal
signifie matcher exactement un seul élément, comme: 42
$x
signifie utiliser la valeur de x, qui sera ici transformé en: 42
$($x:literal)+
signifie matcher n’importe quel élément séparé par un espace, comme: 1 2 3 4
$($x:literal)*
signifie la même chose que la définition précédente, mais une expression vide est ausi acceptée.
$($x:literal),+
signifie matcher n’importe quel élément séparé par une virgule, comme: 1,2,3,4
$($x:literal);+
signifie matcher n’importe quel élément séparé par un point-virgule, comme: 1;2;3;4
(Tout caractère peut être utilisé comme séparateur)
$($x)+
signifie transformer les valeurs, comme ceci: 1 2 3 4
$($x),+
signifie transformer les valeurs, comme ceci: 1,2,3,4
Il est possible de matcher une expression comme 1,2,3,4
et la transformer comme suit 1;2;3;4
en faisant:
( $($x),+) ) => { $($x);+ }
ℹ️ Expression générique
Le souci, quand on travaille avec différents types de token, c’est que expr
ne
fonctionne pas. Par exemple ceci échouera:
gen_list!(u1 "has" cafe "with" 45, u1 "has" cafe "with" 45);
.
Il nous faut le type de token le plus générique possible: le tt
. Le tt est un
token spécial utilisé dans la définition arbitraire de token. Il diffère des
autres types puisque c’est le seul type de token qui ne requière pas d’être
syntaxiquement correct. Il peut capturer tous les types.
Un pattern très utilisé est le pattern TT munching:
// gen_list![u1 "has" cafe "with" 45, u2 "has" cinema "with" 36,];
// => "&u1" "&cafe" 45
// "&u2" "&cinema" 36
macro_rules! gen_list {
// stop case
() => {()};
// main loop
[$user:ident "has" $poi:ident "with" $checkins:literal, $($body:tt)*] => {
{
println!("{:?} {:?} {:?}", $user, $poi, $checkins);
gen_list!($($body)*)
}
};
}
Ça fonctionne mais il faut finir chacun des pattern par un ,
, ce qui est vite
pénible. Réécrivons un peu cette macro pour se passer du délimiteur final.
// gen_list![u1 "has" cafe "with" 45, u2 "has" cinema "with" 36];
// => "&u1" "&cafe" 45
// "&u2" "&cinema" 36
macro_rules! gen_list {
// stop case
[$user:ident "has" $poi:ident "with" $checkins:literal] => {
{
println!("{:?} {:?} {:?}", $user, $poi, $checkins)
}
};
// main loop
[$user:ident "has" $poi:ident "with" $checkins:literal, $($body:tt)*] => {
{
println!("{:?} {:?} {:?}", $user, $poi, $checkins);
gen_list!($($body)*)
}
};
}
Accumulation
Parce que Les macros rust ont l’obligation de produire un AST valide, il est impossible de générer un état intermédiaire. Ce qui veut dire que ceci ne fonctionnera pas:
// gen_list![u1 "has" cafe "with" 45, u2 "has" cinema "with" 36];
// => NOT COMPILING
macro_rules! gen_list {
// stop case
[@ $user:ident "has" $poi:ident "with" $checkins:literal] => {
{
($user, $poi, $checkins)
}
};
// main loop
[@ $user:ident "has" $poi:ident "with" $checkins:literal, $($body:tt)*] => {
{
($user, $poi, $checkins), gen_list!(@ $($body)*)
}
};
// Entry
[$($body:tt)*] => {
vec![gen_list!(@ $($body)*)]
}
}
La macro commencera à générer vec![(u1, cafe, 45),
mais comme ce n’est pas
une expression AST valide, elle échouera. Le morceau vec![
and ]
doit être
générer d’un seul coup!
Il n’est pas possible de stocker des variables lors de la production de macro, donc nous allons devoir feinter le system de macro. Pour cela, on va utiliser ce qu’on appelle un Push-down Accumulation. En gros, on exploite les pattern textuels pour sauvegarder du texte pendant qu’on avance sur la prochaine expression.
macro_rules! gen_list {
// stop case
[@accum( $value:literal) -> ($($body:tt)*) ] => {
{
vec![ $($body)* $value ]
}
};
// main loop
[@accum( $value:literal, $($tail:tt)*) -> ($($body:tt)*)] => {
{
gen_list!(@accum( $($tail)* ) -> ($($body)* $value, ))
}
};
// entry
[$($tail:tt)+] => {
gen_list!(@accum( $($tail)+ ) -> ())
}
}
Ça générera le résultat suivant:
gen_list! [1, 2, 3, 4, 5, 6 ] // entry
gen_list! (@ accum(1, 2, 3, 4, 5, 6) -> ()) // main loop
gen_list! (@ accum(2, 3, 4, 5, 6) -> (1,)) // main loop
gen_list! (@ accum(3, 4, 5, 6) -> (1, 2,)) // main loop
gen_list! (@ accum(4, 5, 6) -> (1, 2, 3,)) // main loop
gen_list! (@ accum(5, 6) -> (1, 2, 3, 4,)) // main loop
gen_list! (@ accum(6) -> (1, 2, 3, 4, 5,)) // stop case
vec! [ 1, 2, 3, 4, 5, 6 ]
🤯 Si nous avions écris ($value, $($body)*)
, nous aurions pu aisément retourner la liste !
=> [7, 6, 5, 4, 3, 2, 1]
Quelques petites améliorations
Pour rendre la macro un peu plus saine, appliquons quelques astuces.
Visibilité privée
Par convention, on prefix par @
les règles que l’on ne veut matcher qu’en
interne. La raison pour l’utilisation de @
est simplement que ce token n’est
jamais utilisé à cette position, et qu’il ne peut donc pas y avoir de
conflits.
Nommage de règle
On donne un nom à une règle comme suit:
Pattern:
(@rule_name( rules )) => {}
Example
(@my_rule( $checkin:literal "checkins" )) => {}
Si on nécessite d’utiliser une accumulation, la convention est:
Pattern:
(@rule_name( rules ) -> ( stack ) ) => {}
Example
(@accum( $checkin:literal "checkins" , $($tail:tt)+ ) -> ($($body:tt)*)) => {}
Gestion d’erreur
Lorsqu’on travaille avec des macro, il est nécessaire de gérer les erreurs d’entrées. Si l’utilisateur de la macro rentre quelque chose d’incorrect, on pourrait facilement se retrouver dans le cas d’une récursion infinie. Pour éviter cela, on peut créer une règle “catch all” qui représente toute règle qui ne matcherais pas les autres, et ainsi générer une erreur de compilation.
(@accum( $($tail:tt)+ ) -> ()) => {
compile_error!(concat!("gen_list: Issue while recurring, state is ",
stringify!([ $($tail)* ])))
};
À noter que les macros étant interprétés à la compilation, les seuls macros
disponible sont: compile_error!
, concat!
et stringify!
. On ne peut pas
utiliser format!
.
Caster une expression
Parfois, quand on travaille avec un type très générique comme tt
, il est
pratique de pouvoir caster des tokens. Le parser Rust n’est pas très robuste
vis-à-vis de substitutions de tt
. Le souci arrive quand le parseur s’attendait
à une construction grammaticale particulière et trouve “à la place” des morceaux
de tokens. Au lieu des les parser, Rust abandonnera.
Un cast peut aisément se faire comme ceci:
macro_rules! gen_list {
(@as_expr $e:expr) => {$e}
(@as_expr $i:item) => {$i}
(@as_expr $p:pat) => {$p}
(@as_expr $s:stmt) => {$s}
(my_complicated_rule $($body)* $poi:ident $checkin:literal) => {
gen_list!(@as_expr vec![ $($body)* ($poi, $checkin) ] )
};
}
Debugger les macros
Il est possible “d’étendre” une macro, c’est-à-dire de voir par quoi la macro serait remplacée après interprétation. Il existe deux outils pour faire cela:
cargo expand
(cargo install cargo-expand)cargo rustc -- -Zunpretty=expanded
(uniquement disponible en rust nightly)
Exemple
let l = gen_list![1, 2, 3, 4, 5, 6];
let l =
{
{
{
{
{
{
<[_]>::into_vec(box [1, 2, 3, 4, 5, 6])
}
}
}
}
}
};
();
Tracer l’exécution d’une macro
La directive “trace macro”, aide à tracer une macro en montrant tous les états intermédiaires que la macro va matcher, ainsi que toutes les étapes de générations.
Pour l’utiliser, il suffit de mettre un #![feature(trace_macros)]
en haut du
fichier. Ensuite, il devient possible d’entourer la macro à tester entre un
trace_macros!(true);
et un trace_macros!(false);
.
Exemple
#![feature(trace_macros)]
fn main() {
trace_macros!(true);
let l = gen_list![1, 2, 3, 4, 5, 6];
trace_macros!(false);
}
// Will generate:
note: trace_macro
--> src/main.rs:32:13
|
32 | let l = gen_list![1, 2, 3, 4, 5, 6];
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: expanding `gen_list! { 1, 2, 3, 4, 5, 6 }`
= note: to `gen_list! (@ accum(1, 2, 3, 4, 5, 6) -> ())`
= note: expanding `gen_list! { @ accum(1, 2, 3, 4, 5, 6) -> () }`
= note: to `{ gen_list! (@ accum(2, 3, 4, 5, 6) -> (1,)) }`
= note: expanding `gen_list! { @ accum(2, 3, 4, 5, 6) -> (1,) }`
= note: to `{ gen_list! (@ accum(3, 4, 5, 6) -> (1, 2,)) }`
= note: expanding `gen_list! { @ accum(3, 4, 5, 6) -> (1, 2,) }`
= note: to `{ gen_list! (@ accum(4, 5, 6) -> (1, 2, 3,)) }`
= note: expanding `gen_list! { @ accum(4, 5, 6) -> (1, 2, 3,) }`
= note: to `{ gen_list! (@ accum(5, 6) -> (1, 2, 3, 4,)) }`
= note: expanding `gen_list! { @ accum(5, 6) -> (1, 2, 3, 4,) }`
= note: to `{ gen_list! (@ accum(6) -> (1, 2, 3, 4, 5,)) }`
= note: expanding `gen_list! { @ accum(6) -> (1, 2, 3, 4, 5,) }`
= note: to `{ vec! [1, 2, 3, 4, 5, 6] }`
= note: expanding `vec! { 1, 2, 3, 4, 5, 6 }`
= note: to `$crate :: __rust_force_expr! (< [_] > :: into_vec(box [1, 2, 3, 4, 5, 6]))`
= note: expanding `__rust_force_expr! { < [_] > :: into_vec(box [1, 2, 3, 4, 5, 6]) }`
= note: to `<[_]>::into_vec(box [1, 2, 3, 4, 5, 6])`
Les pièges courants
L’ordre a de l’importance
Les règles de macros en Rust sont exécuter du haut vers la bas (top down manner). L’ordre compte ! Ça implique donc quelques règles à connaître.
Les règles ne peuvent pas s’écraser. C’est la première règles qui prend la priorité.
[$($tail:tt)+] => {
println("first");
};
[$($tail:tt)+] => {
println("second");
}
// Will display "first".
Si deux règles matchent la même expression, c’est la première règle qui “gagne”.
[$($tail:literal)+] => {
println!("literal match");
};
[$($tail:tt)+] => {
println!("tt match");
}
// Given [1], will display "literal match".
[$($tail:tt)+] => {
println!("tt match");
}
[$($tail:literal)+] => {
println!("literal match");
};
// Given [1], will display "tt match".
Cette règle est important à comprendre, c’est une erreur commune qui arrive
souvent quand on créé un “trou noir” (c’est-à-dire un $($tail:tt)*
) en toute
première règle.
Limitation dans les règles de parsing
Certaines règles ne sont pas parsées de manière intuitive ☹️
Exemples
macro_rules! gen_list {
[$($tail:tt);+ ; $x:literal] => {
println!("{}", $x);
};
}
gen_list![a; b; c ; 5]; // Error: local ambiguity
Ici, on s’attendait à ce que l’expression tail
match a; b; c
et l’expression
x
matchent 5
. Mais comme Rust ne veut pas gérer des règles de greediness,
une erreur est levée, clamant la règle ambigüe.
De même, avoir plusieurs règles tt
provquera aussi ce type d’erreur:
macro_rules! gen_list {
[$($tail:tt)+ $any:tt] => { // $any:literal will work
println!("{}", $x);
};
}
let l = gen_list![a b c 5]; // Error: local ambiguity
Comment corriger cela
Comme aucune règle ne doit être ambigüe, la seule manière de contourner cela, est de mettre un délimiteur qui fonctionne uniquement par pair (parenthèses, crochets, accolades, …). De cette manière, le tt*
ne pourra pas “manger” le délimiteur.
macro_rules! gen_list {
[$($tail:tt)+ | $any:tt] => { // trying to put a delimiter, NOT working
println!("{}", $x);
};
}
let l = gen_list![a b c | 5]; // Error: local ambiguity
// (the | delimiter is eaten by $tail)
macro_rules! gen_list {
[ ( $($tail:tt)+ ) $any:tt] => { // working
println!("{}", $x);
};
}
let l = gen_list![ ( a b c ) 5]; // Yay
Accepter un délimiteur en fin de liste
Les règles que nous avons écrites, nous forcent à ne pas mettre un délimiteur en
fin de liste. On pourrait l’autoriser en utilisant cette expression: $(,)?
.
macro_rules! gen_list {
($($x:expr),* $(,)?) => {
println!("test");
}
}
// Both accepted
let l = gen_list![a, b, c];
let l = gen_list![a, b, c, ];
// In the unit test example above, we could have:
(@accum( $poi:ident "with" $checkin:literal "checkins" $(,)? ) -> ($($body:tt)*)) => {
gen_list!(@as_expr vec![ $($body)* (&$poi, $checkin) ] )
};
Macros avancées complexes
Disons que nous voudrions gérer un test unitaires complexe avec une macro, qui parserait des patterns répétés imbriqués.
let l = gen_user_pois![
u1 "has" ( cafe "with" 40 "checkins", cinema "with" 9 "checkins" );
u2 "has" ( cafe "with" 3 "checkins", cinema "with" 12 "checkins" );
u1 "has" ( "nothing" );
u2 "has" ( "nothing" )
];
// Will generate:
// [
// ("&u1", [("&cafe", 40), ("&cinema", 9)]),
// ("&u2", [("&cafe", 3), ("&cinema", 12)]),
// ("&u1", []), ("&u2", [])
// ]
On va profiter du fait qu’il est possible d’utiliser des macros dans des macros, pour gérer les répétitions imbriquées, ainsi que tout ce qu’on a vu dans cet article !
Le code final sera donc:
macro_rules! gen_poi_with_checkins {
// Converts.
(@as_expr $e:expr) => {$e};
// Inner.
(@accum( "nothing" ) -> ($($body:tt)*)) => {
Vec::new()
};
(@accum( $poi:ident "with" $checkin:literal "checkins" , $($tail:tt)+ ) -> ($($body:tt)*)) => {
gen_poi_with_checkins!(@accum( $($tail)+) -> ($($body)* (&$poi, $checkin),) )
};
(@accum( $poi:ident "with" $checkin:literal "checkins" ) -> ($($body:tt)*)) => {
gen_poi_with_checkins!(@as_expr vec![ $($body)* (&$poi, $checkin) ] )
};
// Error handling.
(@accum( $($tail:tt)+ ) -> ()) => {
compile_error!(concat!("gen_poi_with_checkins: Issue while recurring, state is ", stringify!([ $($tail)* ])))
};
// Public rules.
["nothing"] => {
gen_poi_with_checkins!(@accum( "nothing" ) -> ())
};
[$($tail:tt)+] => {
gen_poi_with_checkins!(@accum( $($tail)+ ) -> ())
};
}
macro_rules! gen_user_pois {
// Converts.
(@as_expr $e:expr) => {$e};
// Inner.
(@accum( $user:ident "has" ( $($inner:tt)+ ) ; $($tail:tt)+ ) -> ($($body:tt)*)) => {
gen_user_pois!(@accum( $($tail)+ ) -> ($($body)* (&$user, gen_poi_with_checkins![$($inner)+]), ))
};
(@accum( $user:ident "has" ( $($inner:tt)+ ) ) -> ($($body:tt)*)) => {
gen_user_pois!(@as_expr vec![ $($body)* (&$user, gen_poi_with_checkins![$($inner)+]) ] )
};
// Error handling.
(@accum( $($tail:tt)+ ) -> ()) => {
compile_error!(concat!("gen_user_pois: Issue while recurring, state is ", stringify!([ $($tail)* ])))
};
// Public rules.
[$($tail:tt)+] => {
gen_user_pois!(@accum( $($tail)+ ) -> ())
};
}