Les macros en Rust

Du plus basique au plus avancé

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)+ ) -> ())
    };
}