Builder Rust
Bonjour à toutes et tous :)
Pour ceux qui ne me connaissent pas, je suis à la fois fainéant et travailleur lorsque cela me permet de ne rien faire plus tard. 😂
Aujourd’hui je vais vous conter la merveilleuse épopée d’un développement qui m’a pris 3 jours pour quelque chose qui nécessitait que 30min. 🙃
Builder
Dans de très nombreux langages de programmation et en particulier ceux qui possèdent un typage statique, il est possible de définir des structures de données possédant plusieurs champs.
Pour générer cette structure, il faut généralement passer par un constructeur qui vient réaliser les opérations de définition des différents champs.
À tout hasard en Rust 😄
#[derive(Debug, PartialEq)]
struct Foo {
a: u8,
b: f32
}
impl Foo {
pub fn new(a: u8, b: f32) -> Self {
Foo {
a,
b
}
}
}
#[test]
fn test() {
let foo : Foo = Foo::new(12, 45.5);
let expected = Foo {
a: 12,
b: 45.5
};
assert(foo, expected);
}
Si l’on veut rajouter un champ c: bool
à notre structure nous allons être obligés de modifier le code en conséquence:
struct Foo {
a: u8,
b: f32,
c: bool
}
impl Foo {
pub fn new(a: u8, b: f32, c: bool) -> Self {
Foo {
a,
b,
c
}
}
}
Bref, ce n’est pas très passionnant. 😑
Pourquoi ne mettrions-nous pas en place une syntaxe proche des builders de classes comme en Java ?
let foo = Foo::builder()
.a(12)
.b(45.5)
.c(true)
.build();
Ceci s’appelle le Design Pattern: Builder
Cahier des charges
Ok! Nous avons défini notre API, mais quelles contraintes veut-on pour notre système ?
Obliger la définition des champs
Contrainte 1
Tous les champs doivent être définis avant que l’appel à la méthode
build
ne soit possible.
Dans notre cas cela signifie que l’on peut soit réaliser le trajet:
flowchart LR builder-->a-->b-->build
ou
flowchart LR builder-->b-->a-->build
Mais celui-ci:
flowchart LR builder-->a-->build
Car cela signifierait que le champ b
n’a pas de valeur définie !!
Permettre l’existence de champs optionels
{% tip(header="Contrainte 2" %} Notre structure peut posséder des champs optionnels. {% end %}
struct Foo {
a: u8,
b: f32,
optional: Vec<i8>
}
Ici le champ optional
est optionel, il n’est pas obligatoire de le définir si l’on le désire.
Ce qui signifie qu’aussi bien:
let foo = Foo::builder()
.a(12)
.b(45.5)
.build();
que:
let foo = Foo::builder()
.a(12)
.b(45.5)
.optional(vec![15, -45])
.build();
sont valides.
De même
let foo = Foo::builder()
.a(12)
.optional(vec![15, -45])
.b(45.5)
.build();
ou
let foo = Foo::builder()
.b(45.5)
.optional(vec![15, -45])
.a(12)
.build();
ou des trucs absurdes mais justes, comme:
let foo = Foo::builder()
.optional(vec![15, -45])
.optional(vec![15, -45, 33])
.a(12)
.b(45.5)
.build();
et
let foo = Foo::builder()
.optional(vec![15, -45])
.a(12)
.optional(vec![15, -45, 33])
.b(45.5)
.build();
le sont aussi !
Notre graphe devient un poil plus complexe:
flowchart LR optional_builder[optional] builder-->optional_builder optional_builder-->optional_builder optional_builder-->a optional_a[optional] a-->optional_a optional_a-->optional_a optional_a-->b optional-->optional optional-->build b-->optional builder-->b b-.->a a-->build builder-->a a-.->b b-->build
Du fait que notre appel à optional
peut-être n’importe où entre les appels des méthodes builder
et build
.
Et encore nous n’avons qu’un seul champ optionel pour le moment. 😋
Vous sentez la combinatoire bien énervée qui s’approche ? 😈
Mais il y a un problème: le cycle en pointillé.
Dans les faits, il permet de faire des choses comme:
flowchart LR builder-->a-->build
ou
flowchart LR builder-->b-->build
Ce qui est interdit par notre première règle!
L’ordre d’appel des champ obligatoire est fixe
Nous allons donc nous imposer une 3ème règle.
Contrainte 3
Les champs obligatoires doivent être définis dans un ordre précis.
Nous voulons forcer le graphe suivant:
flowchart LR builder-->a-->b-->build
et interdire:
flowchart LR builder-->b-->a-->build
Ce qui nous donne un graphe des enfers un peu moins infernal
flowchart LR optional_builder[optional] builder-->optional_builder optional_builder-->optional_builder optional_builder-->a optional_a[optional] a-->optional_a optional_a-->optional_a optional_a-->b optional-->optional optional-->build b-->optional builder-->a a-->b b-->build
qui respecte nos deux règles 🥳
Et juste pour rire, je vous montre un graphe avec l’introduction d’un champ optional2
.
flowchart LR optional_builder[optional] builder-->optional_builder optional_builder-->optional_builder optional_builder-->a optional_builder2[optional2] optional_a[optional] a-->optional_a optional_a-->optional_a optional_a-->b optional-->optional optional-->build b-->optional builder-->optional_builder2 optional_builder2-->optional_builder2 optional_builder2-->a optional_builder-->optional_builder2 optional_builder2-->optional_builder optional_a2[optional2] a-->optional_a2 optional_a2-->optional_a2 optional_a2-->b optional2-->optional2 optional2-->build b-->optional2 optional_a-->optional_a2 optional_a2-->optional_a optional-->optional2 optional2-->optional builder-->a a-->b b-->build
C’est harmonieux non ? 😍
Doit échouer à la compilation pas au runtime
Contraite 4
Un processus de build incorrect ou inachevé ne doit pas permettre de compiler
Il est essentiel d’être averti le plus rapidement possible lorsqu’un code est incorrect.
Ainsi à la différence de certain builders qui imposent d’unwrap le résultat.
Nous désirons que ce soit à l’étape de compilation que nous soyons avertis, lorsqu’un champ obligatoire n’a pas, ou a été incorrectement défini.
Implémentation naïve
Implémentons à la main le cas le plus simple:
J’aime bien travailler en utilisant le TDD, on va donc développer avec lui. 😁
#[derive(Debug, PartialEq, Default)]
struct Foo {
a: u8,
b: f32
}
#[test]
fn basic() {
let foo = Foo::builder()
.a(12)
.b(45.5)
.build();
let expected = Foo {
a: 12,
b: 45.5
};
assert_eq!(foo, expected);
}
La première chose c’est de doter notre struct Foo
d’une méthode statique builder
. Le plus facile. ^^
impl Foo {
fn builder() {}
}
Bon Ok, ça nous avance pas à grand chose. 😛
Définissons maintenant notre Builder
.
#[derive(Default)]
struct Builder;
impl Foo {
fn builder() -> Builder {
Builder::default()
}
}
Nos appels à a()
, b()
et build()
ne sont toujours pas disponibles.
Remédions à ça:
#[derive(Default)]
struct Builder {
a: u8,
b: f32
}
impl Builder {
pub fn a(self, _a: u8) -> Self {
self
}
pub fn b(self, _b: f32) -> Self {
self
}
pub fn build(self) -> Foo {
Foo::default()
}
}
Pas sûr que cela passe les tests unitaires, mais maintenant, ça compile. 😎
'assertion failed: `(left == right)
Left: Foo { a: 0, b: 0.0 }
Right: Foo { a: 12, b: 45.5 }
Et en effet non.
On doit donc propager les valeurs jusqu’au build de Foo
.
La méthode la plus simple est de réaliser quelque chose comme:
impl Builder {
pub fn a(self, a: u8) -> Self {
Builder {
a,
b: self.b
}
}
}
Le self.b
permet de propager la valeur dans le Builder suivant.
On peut faire pareil pour l’autre méthode b
.
impl Builder {
pub fn b(self, b: f32) -> Self {
Builder {
a: self.a,
b
}
}
}
Et maintenant nous pouvons implémenter notre build
.
impl Builder {
pub fn build(self) -> Foo {
Foo {
a: self.a,
b: self.b
}
}
}
On résume
Plus de détails
Bon le test est au vert ✅
Par contre
Certains de nos tests fonctionnent alors qu’ils ne le devraient pas et inversement.
// compilation réussi, mais ne devrait pas être possible ❌
// le seul order possible devrait être builder->a->b->build
// contrainte 3 non respectée
#[test]
fn inverted() {
let foo = Foo::builder().b(45.5).a(12).build();
let expected = Foo { a: 12, b: 45.5 };
assert_eq!(foo, expected);
}
// compile mais fail ❌
// la compilation réussi alors qu'il manque l'appel à b()
// contrainte 1 non respectée
#[test]
fn without_b() {
let foo = Foo::builder().a(12).build();
let expected = Foo { a: 12, b: 45.5 };
assert_eq!(foo, expected);
}
// compile mais fail ❌
// la compilation réussi alors qu'il manque l'appel à a()
// contrainte 1 non respectée
// contrainte 3 non respectée
#[test]
fn without_a() {
let foo = Foo::builder().b(45.5).build();
let expected = Foo { a: 12, b: 45.5 };
assert_eq!(foo, expected);
}
Nous sommes dans le cas
flowchart LR builder-->a builder-->b a-->b b-->a b-->build a-->build
Alors que l’on veut
flowchart LR builder-->a-->b-->build
Nous devons contraindre nos états.
Rage on the State Machine
Cette notion d’états et de transitions se nomme une Machine à états.
En Rust, nous allons matérialiser nos états par des structures. Et les transitions par des appels aux méthodes de ces structures.
flowchart LR fin([Foo construit]) start([Foo à construire]) start-->|"builder()"|Init Init[Init ou Builder]-->|"a()"|WithA WithA-->|"b()"|Buildable Buildable-->|"build()"|fin
Déclarons nos structures:
struct WithA {
a: u8,
b: f32,
}
struct Buildable {
a: u8,
b: f32,
}
Notre Init
est le Builder
en lui-même, nous pouvons donc l’ignorer (pour l’instant 🤐).
Et maintenant, au tour des transitions !
impl Builder {
pub fn a(self, a: u8) -> WithA {
WithA { a, b: self.b }
}
}
impl WithA {
pub fn b(self, b: f32) -> Buildable {
Buildable { a: self.a, b }
}
}
impl Buildable {
pub fn build(self) -> Foo {
Foo {
a: self.a,
b: self.b,
}
}
}
Bon mieux 😀
// compile et réussi ✅
#[test]
fn basic() {}
// ne compile plus ✅
#[test]
fn inverted() {}
// ne compile plus ✅
#[test]
fn without_b() {}
// ne compile plus ✅
#[test]
fn without_a() {}
Nous venons de contraindre notre système !
Récapitulons:
Plus de détails
La répétition c’est le mal!
Dans la programmation, s’il y a bien une chose qui est insupportable, c’est la répétion.
Imaginez, ici nous n’avons que 2 champs, mais avec trois champs, ça demande d’écrire quelque chose comme ceci:
#[derive(Default)]
struct Builder {
a: u8,
b: f32,
c: bool,
}
struct WithA {
a: u8,
b: f32,
c: bool,
}
struct WithB {
a: u8,
b: f32,
c: bool,
}
struct Buildable {
a: u8,
b: f32,
}
Ça devient fastidieux! Et rappellez vous, le maître mot qui nous guide durant notre épopée est la fainéantise. 😅
Qu’utilise-t-on en Rust lorsque l’on manipule des structures avec des champs qui peuvent avoir des types variables?
Les génériques bien sûr !!
Réécrivons notre code dans ce sens:
struct Init;
struct WithA;
struct Buildable;
#[derive(Default)]
struct Builder<T> {
a: u8,
b: f32,
}
impl Builder<Init> {
pub fn a(self, a: u8) -> Builder<WithA> {
Builder::<WithA> { a, b: self.b }
}
}
impl Builder<WithA> {
pub fn b(self, b: f32) -> Builder<Buildable> {
Builder::<Buildable> { a: self.a, b }
}
}
impl Builder<Buildable> {
pub fn build(self) -> Foo {
Foo {
a: self.a,
b: self.b,
}
}
}
Et oui! Init
est de retour, je vous avais bien dit qu’on allait s’en occuper plus tard ^^.
Compilons !
| struct Builder<T> {
| ^ unused parameter
|
= help: consider removing `T`, referring to it in a field, or using a marker such as `PhantomData`
= help: if you intended `T` to be a const parameter, use `const T: usize` instead
Hum presque 😥
Mais heureusement le compilateur nous met sur la voie. Il faut que nous rajoutions un champ supplémentaire qui va tenir l’état de la structure.
Ce type un peu spécial, se nomme un PhantomData
et ne sert que de marqueur permettant de tenir notre <T>
.
#[derive(Default)]
struct Builder<T> {
a: u8,
b: f32,
state: PhantomData<T>,
}
impl Builder<Init> {
pub fn a(self, a: u8) -> Builder<WithA> {
Builder::<WithA> {
a,
b:
self.b, state: Default::default()
}
}
}
impl Builder<WithA> {
pub fn b(self, b: f32) -> Builder<Buildable> {
Builder::<Buildable> {
a: self.a,
b,
state: Default::default()
}
}
}
impl Builder<Buildable> {
pub fn build(self) -> Foo {
Foo {
a: self.a,
b: self.b,
}
}
}
Bon, cela commence à ressembler à quelque chose sans trop de répétitions.
Comme de coutume, un nouveau milestone !
Plus de détails
Vous reprendrez bien un peu d’options ?
Maintenant que les critères 1 & 3 de notre cahier des charges sont remplis (échec à la compilation et tous les champs obligatoires doivent avoir été appellés).
Nous pouvons réécrire notre machine à états pour y inclure notre appel à optional()
, pour chaque état de la machine.
flowchart LR fin([Foo construit]) start([Foo à construire]) start-->|"builder()"|Init Init[Init ou Builder]-->|"a()"|WithA WithA-->|"b()"|Buildable Buildable-->|"build()"|fin Init-->|"optional()"|Init WithA-->|"optional()"|WithA Buildable-->|"optional()"|Buildable
Établissons nos tests, nous sommes en TDD, ne perdons pas les bonnes habitudes. 😉
#[test]
fn with_optional() {
let foo = Foo::builder()
.a(12)
.b(45.5)
.optional(vec![45, -78])
.build();
let expected = Foo {
a: 12,
b: 45.5,
optional: vec![45, -78],
};
assert_eq!(foo, expected);
}
Pour y parvenir, nous allons profiter d’avoir une structure générique.
En effet si l’on écrit:
struct Builder<T> {
a: u8,
b: f32,
optional : Vec<i8>,
state: PhantomData<T>
}
impl Builder<T> {
pub fn optional(optional: Vec<i8>) -> Builder<T> {
Builder::<T> {
a: self.a,
b: self.b,
optional
}
}
}
On crée une fonction optional()
qui :
- si elle est appellée par
Builder<Init>
, renverra un nouveauBuilder<Init>
- si elle est appellée par
Builder<WithA>
, renverra un nouveauBuilder<WithA>
- et de manière générale, si elle est appellée par
Builder<T>
, elle renverra un nouveauBuilder<T>
Réécrivons notre code:
Plus de détails
Nos deux tests sont au verts ! ✅
Essayons de voir si notre méthode optional()
peut être placée n’importe où.
Plus de détails
Tout nos tests sont au verts ! ✅
Et si nous avons plus d’un champ optionel ?
Même combat, si on a à la fois optional
et optional2
, notre machine à état devient:
flowchart LR fin([Foo construit]) start([Foo à construire]) start-->|"builder()"|Init Init[Init ou Builder]-->|"a()"|WithA WithA-->|"b()"|Buildable Buildable-->|"build()"|fin Init-->|"optional()"|Init WithA-->|"optional()"|WithA Buildable-->|"optional()"|Buildable Init-->|"optional2()"|Init WithA-->|"optional2()"|WithA Buildable-->|"optional2()"|Buildable
Ce qui nous donne l’implémentation suivante:
Plus de détails
Je vous passe la phase de TDD, mais oui c’est du vert aussi. 😛
Petite amélioration
Nous avons supposé que tous les champs de notre structure implémentaient le trait Default
.
Mais ce n’est pas forcément vrai.
Plus de détails
Ce code ne compile pas:
28 | #[derive(Default)]
| ------- in this derive macro expansion
...
32 | c: Bar,
| ^^^^^^ the trait `Default` is not implemented for `Bar`
|
= note: this error originates in the derive macro `Default` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider annotating `Bar` with `#[derive(Default)]`
Cargo n’est pas content. 😥
Soit nous imposons que tous les champs implémentent Default
, et du coup on impose une contrainte supplémentaire et non nécessaire.
Ou plus malin, on s’appuie sur les Optional
et on réécrit les choses ainsi:
Plus de détails
Ça compile et en plus nous n’obligeons plus l’implémentation du trait Default
sur Bar
. Tout est beau dans le meilleur des mondes !
Macros
Je sais pas vous, mais moi je trouve que ce code se répète un peu beaucoup. 🤔
Rajouter un ou plusieurs champs est encore plus fastidieux !
Nous devons trouver un moyen de nous faciliter la tâche.
Ce moyen est tout trouvé en Rust et il se nomme les macros.
J’ai d’ailleurs fait un autre article dessus, si vous voulez découvrir ou vous rafraîchir la mémoire sur ce concept.
Automatiser la création des champs des structures builders
Nous allons y aller pas à pas pour construire notre automatisation.
La première chose que l’on remarque est que les implémentations se ressemblent et se répètent à quelques différences près.
impl<T> Builder<T> {
pub fn optional(self, optional: Vec<i8>) -> Builder<T> {
Builder::<T> {
a: self.a,
b: self.b,
c: self.c,
optional,
optional2: self.optional2,
state: Default::default(),
}
}
pub fn optional2(self, optional2: bool) -> Builder<T> {
Builder::<T> {
a: self.a,
b: self.b,
c: self.c,
optional: self.optional,
optional2,
state: Default::default(),
}
}
}
impl Builder<Init> {
pub fn a(self, a: u8) -> Builder<WithA> {
Builder::<WithA> {
a: Some(a),
b: self.b,
c: self.c,
optional: self.optional,
optional2: self.optional2,
state: Default::default(),
}
}
}
impl Builder<WithA> {
pub fn b(self, b: f32) -> Builder<Buildable> {
Builder::<Buildable> {
a: self.a,
b: Some(b),
c: self.c,
optional: self.optional,
optional2: self.optional2,
state: Default::default(),
}
}
}
On peut déjà sortir deux cas:
Si l’on traite des champs obligatoires, nous avons deux possibilités:
champ : self.champ
: cas où l’on désire transmettre la valeur du champ de l’étape précédente vers l’étape suivante sans modificationchamp: Some(champ)
: cas où l’on désire configurer la valeur du champ pour l’étape suivante avec la valeur en entrée.
Si l’on traite des champs optionels, nous avons aussi deux possibilités:
champ : self.champ
: même principe que pour les champs optionnelschamp
: on réalise un remplacement de la valeur pour la suite des opérations
En Rust, il est possible d’omettre le nom du champ si la variable s’appelle pareil.
Nous pouvons construire une macro qui vient réaliser ce travail à notre place.
La première chose à se demander: comment discriminer les deux cas ?
Le critère est le nom du champ, si le nom du champ est égal au nom du paramètre, alors on doit procéder au remplacement de la valeur, sinon à sa propagation.
Première tentative (ça marchera pas ^^’)
Pour notre première tentative, nous allons essayer d’utiliser une condition pour arriver au résultat voulu.
La macro
stringify!
permet de transformer unident
en string et ainsi de pouvoir l’afficher dans unprintln!
, ou de la comparer avec une autre String.
macro_rules! fill_field {
($self:ident, $field_name:ident, $target:ident, $value:expr) => {
if stringify!($field_name) == stringify!($target) {
Some($value)
} else {
$self.$field_name
}
};
}
#[test]
fn test_field_name_macro() {
struct A {
a: Option<u8>,
b: Option<f32>
}
impl A {
fn set_a(&self, a: u8 ) -> A {
A {
a: fill_field!(self, a, a, a),
b: fill_field!(self, b, a, a),
}
}
}
}
Ce code ne compile pas à cause de la ligne 23.
error[E0308]: mismatched types
--> src\steps\part2.rs:134:44
|
23 | b: fill_field!(self, b, a, a),
| ^ expected `f32`, found `u8`
|
help: you can convert a `u8` to an `f32`, producing the floating point representation of the integer
|
23 | b: fill_field!(self, b, a, a.into()),
| +++++++
En effet, si on étend cette macro, nous avons les résultats suivants:
Pour le champ a
:
if stringify!( a ) == stringify!( a ) {
Some(a) // type Option<u8>
} else {
self.a // type Option<u8>
}
Pour le champ b
:
if stringify!( b ) == stringify!( a ) {
Some(a) // type Option<u8>
} else {
self.b // type Option<f32>
}
Et là ça coince car le code ainsi généré n’est pas valide et ne peut donc pas compiler!
En en effet, notre if
faisant réellement partie du code généré, il doit être également analysé et donc ses branches doivent retourner le même type.
Nous sommes plutôt bloqués. Notre seul discriminant étant la correspondance entre le nom de la variable et le champ.
Il nous faut un autre moyen pour ne pas générer tout le code, mais seulement la partie de la condition qui nous intéresse.
Deuxième tentative, une pincée de proc_macro (la bonne)
Le dicton dit bien:
Dicton éculé mais vrai
Si votre seul outil est un marteau, tous vos problèmes seront des clous.
Ici notre marteau représente les macros et les macros sont difficiles à utiliser pour faire des conditions.
Heureusement, il existe un autre type de macro, les proc_macro
.
Celles-ci sont du Rust pour écrire du Rust et donc on vient compiler du Rust pour générer du Rust qui lui même sera compilé.
Il va sans dire que les proc_macros peuvent elles mêmes contenir des macros! 😵
Pour réaliser notre génération conditionnelle de code, nous allons utiliser les crates
appellées tt-call
et tt-equal
.
Pour les utiliser, vous devez rajouter à votre cargo.toml les dépendances suivantes:
[dependencies]
tt-call = "1.0.8"
tt-equal = "0.1.2"
Ou si vous êtes en rust > 1.62
, vous pouvez les commandes:
cargo add tt-call tt-equal
Maintenant nous pouvons utiliser des macros plus avancées pour faire ce que l’on désire.
La crate tt-call
nous fournira une macro tt_if!
qui possède une syntaxe spécifique.
tt_if! {
input = [<A> <B>]
condition = [{condition}]
true = [{...}]
false = [{...}]
}
La macro prend plusieurs paramètres:
<A>
et<B>
: les expressions qui vont être comparées.condition
: quel opération renvoyant un booléen sera appliqué à<A>
et<B>
.true
: code à générer en cas de succèsfalse
: code à générer en cas d’échec
La condition que nous allons appliquer est elle-même une macro tt_equal
, provenant de la crate tt_equal
.
Remplaçons par nos paramètres:
tt_if! {
input = [$field_name $target]
condition = [{tt_equal}]
true = [{...}]
false = [{...}]
}
Dans notre cas, nous allons comparer l’égalité entre $field_name:ident
et $target:ident
.
Pour les branches de notre condition.
Nous avons d’une part:
true = [{Some($value)}]
Et d’autre part:
false = [{$self.$field_name}]
Si on regroupe le tout:
macro_rules! fill_field {
($self:ident, $field_name:ident, $target:ident, $value:expr) => {
tt_if!{
condition = [{tt_equal}]
input = [{ $target $field_name }]
true = [{
Some($value)
}]
false = [{
$self.$field_name
}]
}
};
}
Et si on veut écrire un test pour s’en assurer:
Plus de détails
Nous avons besoin d’une deuxième macro pour gérer le cas des champs optionnels:
macro_rules! fill_field_optional {
($self:ident, $field_name:ident, $target:ident, $value:expr) => {
tt_if!{
condition = [{tt_equal}]
input = [{ $target $field_name }]
true = [{
$value
}]
false = [{
$self.$field_name
}]
}
};
}
Maintenant, au tour de notre implémentation de builder
Plus de détails
Automatiser la création de la stucture elle-même
Il semblerait que notre macro ne nous nous épargne pas l’écriture de beaucoup de lignes. 😕
J’aurai même tendance à penser qu’elle fait l’inverse et nous donne de travail qu’avant.
Phrase de grand penseur 😁
Si l’Automatisation ne marche pas, c’est qu’on utilise pas suffisamment d’automatisation!!
On va donc appliquer ce grand précepte et écrire une macro qui réalise le travail à notre place ^^
Comme toujours lorsque l’on conçoit une macro, l’idée est de repérer les différences et similitudes entre les codes pour trouver des règles de génération permettant de nous éviter le maximum d’efforts.
Regardons de plus près les structures du corp de notre machine à états.
Plus de détails
Comparons le type de retour de optional
, optional2
, d’une part, et a
etb
, d’autre part.
Les premières fonctions renvoient systématiquement un Builder<T>
.
Tandis que les secondes ont comme type de retour un type différent : ici, respectivement Builder<WithA>
, Builder<WithB>
.
Maintenant que nous avons mis en lumière les différences, nous allons donc devoir écrire deux macros,
La première, pour les champs obligatoires, prendra un paramètre supplémentaire de type ty
, pour variabiliser le type de la structure de sortie.
Pour le corps des deux macros, nous allons réutiliser la macro précédente fill_field!
, pour nous faciliter la vie.
Cela nous donne les macros suivantes:
macro_rules! fill_struct {
($self:ident, $target:ident, $value:expr, $new_state:ty) => {
Builder::<$new_state> {
a: fill_field!($self, a, $target, $value),
b: fill_field!($self, b, $target, $value),
c: fill_field!($self, c, $target, $value),
optional: fill_field_optional!($self, optional, $target, $value),
optional2: fill_field_optional!($self, optional2, $target, $value),
state: Default::default(),
}
};
}
macro_rules! fill_struct_optional {
($self:ident, $target:ident, $value:expr) => {
Builder::<T> {
a: fill_field!($self, a, $target, $value),
b: fill_field!($self, b, $target, $value),
c: fill_field!($self, c, $target, $value),
optional: fill_field_optional!($self, optional, $target, $value),
optional2: fill_field_optional!($self, optional2, $target, $value),
state: Default::default(),
}
};
}
Code utilisant la macro fill_struct!
:
Plus de détails
Automatiser les champs de la structure
Notre système commence à être un minimum automatisé, mais la problématique reste entière, si l’on veut rajouter un nouveau champ (optionel ou obligatoire), nous devons écrire les implémentations de la machine à états, ce qui passablement agaçant. 😑
Nous devons touver un moyen de réaliser l’introspection des différents champs de la structure que nous désirons construire.
Introspection des champs de la structure
L’instrospection seule ne nous permettra pas de tout faire, mais à minima de nous donner une base de réflexion pour la suite des opérations.
Pour ce faire nous allons nous inspirer de cet exemple.
Le résultat que l’on tente d’atteindre est le suivant:
#[derive(Default)]
struct Builder<T> {
a: Option<u8>,
b: Option<f32>,
c: Option<Bar>,
optional: Vec<i8>,
optional2: bool,
state: PhantomData<T>,
}
Nous devons aussi distinguer deux cas, les champs optionnels et les champs obligatoires.
Nous avons donc en quelque sorte, deux macros à écrire:
- La première, qui génère des champs en
champ: Option<type de champ>
- La seconde :
champ : type de champ
Pour les distinguer, nous allons rajouter un discriminant permettant à la macro de comprendre ce qu’elle analyse.
Notre macro va se nommer build_builder!
. Et aura pour motif de match:
Premier essai de motif de match
macro_rules! build_builder {
($struct_vis:vis struct $name:ident{
$($field_name:ident : $field_type:ty,)*
$($field_name_optional:ident : $field_type_optional:ty, [optional])*
} ) => {
println!("la structure {}", stringify!($name));
$(println!("\ta le champ obligatoire {} a pour type {}", stringify!($field_name), stringify!($field_type)));*;
$(println!("\ta le champ optionnel {} a pour type {}", stringify!($field_name_optional), stringify!($field_type_optional)));*;
};
}
#[test]
fn main() {
build_builder! {
struct Foo {
a: u8,
b: f32,
c: Bar,
optional: Vec<i8>, [optional]
optional2: bool, [optional]
}
}
}
Ce premier essai ne compilera pas et nous provoquera une erreur.
error: local ambiguity when calling macro `build_builder`:
multiple parsing options: built-in NTs ident ('field_name') or ident ('field_name_optional')
Car les motifs sont trop semblables et le moteur de macro n’est pas capable de faire la distinction entre les champs optionels et obligatoires.
Nous allons devoir rajouter un séparateur entre nos champs obligatoires et optionnels.
Deuxième essai via un séparateur
Prenons par exemple ---
.
macro_rules! build_builder {
($struct_vis:vis struct $name:ident{
$($field_name:ident : $field_type:ty,)*
---
$($field_name_optional:ident : $field_type_optional:ty, [optional])*
} ) => {
println!("la structure {}", stringify!($name));
$(println!("\ta le champ obligatoire {} a pour type {}", stringify!($field_name), stringify!($field_type)));*;
$(println!("\ta le champ optionnel {} a pour type {}", stringify!($field_name_optional), stringify!($field_type_optional)));*;
};
}
#[test]
fn main() {
build_builder! {
struct Foo {
a: u8,
b: f32,
c: Bar,
---
optional: Vec<i8>, [optional]
optional2: bool, [optional]
}
}
}
Cette fois-ci, notre code compile et nous affiche:
la structure Foo
a le champ obligatoire a a pour type u8
a le champ obligatoire b a pour type f32
a le champ obligatoire c a pour type Bar
a le champ optionnel optional a pour type Vec<i8>
a le champ optionnel optional2 a pour type bool
On peut même se dispenser des [optional]
et remplacer le ---
par #[optional fields]
pour plus de clarté.
On en profite aussi pour permettre de rajouter optionnellement une virgule au dernier champ de la structure.
macro_rules! build_builder {
($struct_vis:vis struct $name:ident{
$($field_name:ident : $field_type:ty,)*
#[optional fields]
$($field_name_optional:ident : $field_type_optional:ty),*$(,)*
} ) => {
println!("la structure {}", stringify!($name));
$(println!("\ta le champ obligatoire {} a pour type {}", stringify!($field_name), stringify!($field_type)));*;
$(println!("\ta le champ optionnel {} a pour type {}", stringify!($field_name_optional), stringify!($field_type_optional)));*;
};
}
#[test]
fn main() {
build_builder! {
struct Foo {
a: u8,
b: f32,
c: Bar,
#[optional fields]
optional: Vec<i8>,
optional2: bool
}
}
}
Pas mal, nous sommes désormais capable de réaliser l’introspection des champs de notre structure! 🥳
Génération de la machine à état pour les champs optionnels
Nous avons la stucture générale de macro, nous pouvons maintenant passer à son implémentation.
Pour commencer, nous allons nous attaquer aux champs optionnels.
Le code que nous allons devoir générer est le suivant:
impl<T> Builder<T> {
pub fn optional(self, optional: Vec<i8>) -> Builder<T> {
fill_struct_optional!(self, optional, optional)
}
pub fn optional2(self, optional2: bool) -> Builder<T> {
fill_struct_optional!(self, optional2, optional2)
}
}
Nous avons plusieurs choses à réaliser:
Tout d’abord, nous devons déclarer le impl Builder<T>
Puis, pour chacun des champs optionnels, nous devons générer les fonctions associées.
Pour cela, nous allons là aussi utiliser le principe de répétition. Mais cette fois-ci nous allons répéter le motif de la déclaration de la fonction de définition, et pour chaque champ optionnel.
macro_rules! build_builder {
($struct_vis:vis struct $name:ident{
$($field_name:ident : $field_type:ty,)*
#[optional fields]
$($field_name_optional:ident : $field_type_optional:ty),*$(,)*
} ) => {
impl<T> Builder<T> {
$(
pub fn $field_name_optional(self, $field_name_optional: $field_type_optional) -> Builder<T> {
fill_struct_optional!(self, $field_name_optional, $field_name_optional)
}
)*
}
};
}
Génération de la machine à état pour les champs obligatoires
Pour pouvoir générer les méthodes permettant de passer d’un état obligatoire à un autre, nous devons connaître l’état courant, ainsi que l’état suivant.
Pour ce faire, nous allons devoir introduire un DSL spécifique, qui permettra de pouvoir déterminer ces états lors de la génération de nos implémentations.
Pour rappel, ces implémentations sont les suivantes:
impl Builder<Init> {
pub fn a(self, a: u8) -> Builder<WithA> {
fill_struct!(self, a, a, WithA)
}
}
impl Builder<WithA> {
pub fn b(self, b: f32) -> Builder<WithB> {
fill_struct!(self, b, b, WithB)
}
}
impl Builder<WithB> {
pub fn c(self, c: Bar) -> Builder<Buildable> {
fill_struct!(self, c, c, Buildable)
}
}
Notre DSL ressemblera à:
build_builder! {
struct Foo {
a: u8, [ Init => WithA ]
b: f32, [ WithA => WithB ]
c: Bar, [ WithB => Buildable ]
#[optional fields]
optional: Vec<i8>,
optional2: bool
}
}
Nous avons choisi une notation en [ État courant => État suivant]
. L’idée est de former un graph amenant de l’état Init
jusqu’à l’état Buildable
.
Tout d’abord, nous venons matcher les états pour chaque champ:
[ $current_state:ident => $next_state:ident ]
Puis pour chacun de ceux-ci, implémenter les différents états de la machine.
macro_rules! build_builder {
($struct_vis:vis struct $name:ident{
$($field_name:ident : $field_type:ty, [ $current_state:ident => $next_state:ident ])*
#[optional fields]
$($field_name_optional:ident : $field_type_optional:ty),*$(,)*
} ) => {
// ... code précédent
$(
impl Builder<$current_state> {
fn $field_name(self, $field_name: $field_type) -> Builder<$next_state> {
fill_struct!(self, $field_name, $field_name, $next_state)
}
}
)*
}
Si on résume:
Plus de détails
On génère maintenant l’intégralité des transitions de la machine, que ce soit les transitions obligatoires ou optionnelles !!! 😎
Automatiser la construction du Builder en lui-même
Il nous reste encore pas mal de boulot, en effet bien que la structure du builder soit générique pour le moment, nous sommes dans l’obligation de l’écrire par nous même.
Nous allons remédier à cette situation intôlérable ! 😁
Le code que nous allons devoir générer est le suivant:
#[derive(Default)]
struct Builder<T> {
a: Option<u8>,
b: Option<f32>,
c: Option<Bar>,
optional: Vec<i8>,
optional2: bool,
state: PhantomData<T>,
}
Pour ce faire, nous allons continuer à nous appuyer sur la macro build_builder!
, celle-ci réalisant l’introspection des noms des champs de la structure, ainsi que leur type.
macro_rules! build_builder {
($struct_vis:vis struct $name:ident{
$($field_name:ident : $field_type:ty, [ $current_state:ident => $next_state:ident ])*
#[optional fields]
$($field_name_optional:ident : $field_type_optional:ty),*$(,)*
} ) => {
#[derive(Default)]
struct Builder<T> {
$($field_name: Option<$field_type>),*,
$($field_name_optional: $field_type_optional),*,
state: PhantomData<T>,
}
}
Toujours plus d’automatisation !
Plus de détails
Bon, ça commence à se dégrossir !
Génération des états de la machine
Maintenant que nous avons les transitions, au tour des états.
Pour cela, nous allons devoir enrichir notre DSL pour prendre en compte cette nouvelle problématique.
Le code que nous devons générer est le suivant :
#[derive(Default)]
struct Init;
struct WithA;
struct WithB;
struct Buildable;
Deux états sont fixes pour toutes les implémentations, il s’agit de Init
et Buildable
. Init
doit aussi implémenter le trait Default
.
Le DSL que je vous propose est le suivant: nous définissons entre [
et ]
après de la structure, tous les états de notre machine, excepté Init
et Build
.
build_builder! {
struct Foo [
WithA,
WithB
]{
a: u8, [ Init => WithA ]
b: f32, [ WithA => WithB ]
c: Bar, [ WithB => Buildable ]
#[optional fields]
optional: Vec<i8>,
optional2: bool
}
}
Nous allons donc générer les WithA
et WithB
.
macro_rules! build_builder {
($struct_vis:vis struct $name:ident [
$($state:ident),*$(,)*
]{
$($field_name:ident : $field_type:ty, [ $current_state:ident => $next_state:ident ])*
#[optional fields]
$($field_name_optional:ident : $field_type_optional:ty),*$(,)*
} ) => {
#[derive(Default)]
struct Init;
$(struct $state);*;
struct Buildable;
}
Nous avons désormais intégré les états dans notre DSL.
Plus de détails
Occupons nous de la méthode build
Continuons notre nettoyage de printemps!
La méthode build
, permettant de réaliser la construction de la structure finale, doit être encore implémententée manuellement.
Nous allons améliorer les choses en demandant à notre macro de réaliser ce travail à notre place.
Pour rappel, celle-ci a pour définition :
impl Builder<Buildable> {
pub fn build(self) -> Foo {
Foo {
a: self.a.unwrap(),
b: self.b.unwrap(),
c: self.c.unwrap(),
optional: self.optional,
optional2: self.optional2,
}
}
}
Nous allons utiliser une macro pour automatiser sa création.
macro_rules! build_builder {
($struct_vis:vis struct $name:ident [
$($state:ident),*$(,)*
]{
$($field_name:ident : $field_type:ty, [ $current_state:ident => $next_state:ident ])*
#[optional fields]
$($field_name_optional:ident : $field_type_optional:ty),*$(,)*
} ) => {
impl Builder<Buildable> {
pub fn build(self) -> $name {
$name {
$($field_name: self.$field_name.unwrap()),*,
$($field_name_optional: self.$field_name_optional),*,
}
}
}
}
Ainsi, nous n’aurons plus besoin d’implémenter la méthode build quelque soit le nombre de champs.
Automatiser les macros fill_struct!
Parlant d’automatisation de champs, nous avons encore des champs a
, b
, c
qui trainent dans les macros fill_struct!
et fill_struct_optional!
.
macro_rules! fill_struct {
($self:ident, $target:ident, $value:expr, $new_state:ty) => {
Builder::<$new_state> {
a: fill_field!($self, a, $target, $value),
b: fill_field!($self, b, $target, $value),
c: fill_field!($self, c, $target, $value),
optional: fill_field_optional!($self, optional, $target, $value),
optional2: fill_field_optional!($self, optional2, $target, $value),
state: Default::default(),
}
};
}
macro_rules! fill_struct_optional {
($self:ident, $target:ident, $value:expr) => {
Builder::<T> {
a: fill_field!($self, a, $target, $value),
b: fill_field!($self, b, $target, $value),
c: fill_field!($self, c, $target, $value),
optional: fill_field_optional!($self, optional, $target, $value),
optional2: fill_field_optional!($self, optional2, $target, $value),
state: Default::default(),
}
};
}
Nous allons avoir besoin de l’instrospection des champs de la structure. Ce qui implique que nous allons devoir déclarer ces macros à l’intérieur de notre macro build_builder!
.
macro_rules! build_builder {
($struct_vis:vis struct $name:ident [
$($state:ident),*$(,)*
]{
$($field_name:ident : $field_type:ty, [ $current_state:ident => $next_state:ident ])*
#[optional fields]
$($field_name_optional:ident : $field_type_optional:ty),*$(,)*
} ) => {
macro_rules! fill_struct {
($self:ident, $target:ident, $value:expr, $new_state:ty) => {
Builder::<$new_state> {
$($field_name: fill_field!($self, $field_name, $target, $value)),*,
$($field_name_optional: fill_field_optional!($self, $field_name_optional, $target, $value)),*,
state: Default::default(),
}
};
}
macro_rules! fill_struct_optional {
($self:ident, $target:ident, $value:expr) => {
Builder::<T> {
$($field_name: fill_field!($self, $field_name, $target, $value)),*,
$($field_name_optional: fill_field_optional!($self, $field_name_optional, $target, $value)),*,
state: Default::default(),
}
};
}
}
Et là on s’aperçoit du potentiel quasi illimité des macros et des métavariables qui peuvent être utilisées dans le corps d’autres macros imbriquées pour créer des comportements très complexes.
Il ne reste plus grand chose en dehors de la macro build_builder!
, à part une dernière chose…
Générer la fonction Builder
Notre méthode builder
est très simple et donc très facile à générer.
impl Foo {
fn builder() -> Builder<Init> {
Builder::default()
}
}
Ce qui donne sous la forme de macro:
macro_rules! build_builder {
($struct_vis:vis struct $name:ident [
$($state:ident),*$(,)*
]{
$($field_name:ident : $field_type:ty, [ $current_state:ident => $next_state:ident ])*
#[optional fields]
$($field_name_optional:ident : $field_type_optional:ty),*$(,)*
} ) => {
impl $name {
fn builder() -> Builder<Init> {
Builder::default()
}
}
}
Générer la structure à partir du DSL de la macro
Un truc m’ennuie, nous avons une répétition dans la définition des champs:
Une pour la structure à créer:
#[derive(Debug, PartialEq)]
struct Foo {
a: u8,
b: f32,
c: Bar,
optional: Vec<i8>,
optional2: bool,
}
Et une deuxième dans la déclaration du DSL:
build_builder! {
struct Foo [
WithA,
WithB
]{
a: u8, [ Init => WithA ]
b: f32, [ WithA => WithB ]
c: Bar, [ WithB => Buildable ]
#[optional fields]
optional: Vec<i8>,
optional2: bool
}
}
Pourquoi ne pas générer la structure du dessus directement depuis notre DSL ?
Il faut par contre prendre en compte les attributs de la structure:
#[derive(Debug, PartialEq)]
Notre macro devra donc être capable de comprendre et de générer les attributs au-dessus de la structure finale.
En mettant les attributs en dur, cela donne quelque chose comme ça:
macro_rules! build_builder {
($struct_vis:vis struct $name:ident [
$($state:ident),*$(,)*
]{
$($field_name:ident : $field_type:ty, [ $current_state:ident => $next_state:ident ])*
#[optional fields]
$($field_name_optional:ident : $field_type_optional:ty),*$(,)*
} ) => {
#[derive(Debug, PartialEq)]
$struct_vis struct $name {
$($field_name: $field_type),*,
$($field_name_optional: $field_type_optional),*,
}
}
Si on veut aussi les variabiliser, on doit modifier le pattern de match pour prendre en compte cette nouvelle contrainte.
macro_rules! build_builder {
(
$(#[$attr:meta])*
$struct_vis:vis struct $name:ident [
$($state:ident),*$(,)*
]{
$($field_name:ident : $field_type:ty, [ $current_state:ident => $next_state:ident ])*
#[optional fields]
$($field_name_optional:ident : $field_type_optional:ty),*$(,)*
}
) => {
#[$($attr)*]
$struct_vis struct $name {
$($field_name: $field_type),*,
$($field_name_optional: $field_type_optional),*,
}
}
Tout la magie se passe à la ligne 3
. Elle capture les attributs et les recopies dans la structure générée.
$(#[$attr:meta])*
Cette ligne permet de capturer tous les attributs qui sont associés à la structure.
Notre nouveau DSL a maintenant cette tête:
build_builder! {
#[derive(Debug, PartialEq)]
struct Foo [
WithA,
WithB
]{
a: u8, [ Init => WithA ]
b: f32, [ WithA => WithB ]
c: Bar, [ WithB => Buildable ]
#[optional fields]
optional: Vec<i8>,
optional2: bool
}
}
On récapitule:
Plus de détails
Et bien tout est dans la macro !!! 🥳🥳🥳
On arrive au bout ! Encore quelques améliorations et on aura enfin fini, et on pourra ENFIN en profiter !
Pouvoir renommer le nom du Builder
Il est possible que le nom du builder soit déjà utilisé quelque part dans le module, et donc que le nom Builder
soit en conflit avec une autre structure ou énumération, déjà existante.
Nous allons permettre à l’utilisateur de pouvoir en configurer le nom et même le chemin vers ce builder.
En effet, nous allons aussi envelopper notre mécanique de Builder à l’intérieur d’un module, pour éviter des modifications externes et ainsi n’exposer que les éléments essentiels.
Nous allons introduire, une nouvelle notation dans notre DSL.
Il s’agira d’un clef-valeur. Par exemple :
[builder_name => builder::Builder ]
Notre macro créera un module interne builder
à l’emplacement de l’invocation de la macro.
Ceci nous permettra de sceller les états de la machine.
Voici notre nouveau DSL.
build_builder! {
#[derive(Debug, PartialEq)]
struct Foo [
WithA,
WithB
]{
a: u8, [ Init => WithA ]
b: f32, [ WithA => WithB ]
c: Bar, [ WithB => Buildable ]
#[optional fields]
optional: Vec<i8>,
optional2: bool
};
[builder_name => builder::BuilderFoo]
}
Cet appel à la macro doit créer une structure telle que:
mod builder {
struct Builder {...}
}
Et voici, notre nouvelle macro avec le nom du Builder variabilisé.
Plus de détails
Nous allons maintenant sceller notre builder et tout ce qui doit l’être dans le module.
Il faut faire attention à la visibilité de ce que l’on manipule.
Notre module de scellement doit bien évidemment être public.
Nous devons aussi importer les éléments du module supérieur:
pub mod builder{
use super::*;
}
Notre builder scellé
Plus de détails
Ce qui nous permet d’obtenir le résultat suivant:
fn main() {
let builder : builder::BuilderFoo<builder::Init> = Foo::builde();
}
Comparaison des codes
Sans macros, nous avions tout ça à écrire:
Plus de détails
Avec la macro, il suffit de:
build_builder! {
#[derive(Debug, PartialEq)]
struct Foo [
WithA,
WithB
]{
a: u8, [ Init => WithA ]
b: f32, [ WithA => WithB ]
c: Bar, [ WithB => Buildable ]
#[optional fields]
optional: Vec<i8>,
optional2: bool
};
[builder_name => builder::BuilderFoo]
}
Pour pouvoir appeler le code:
let foo = Foo::builder()
.a(12)
.optional2(bool)
.b(45.5)
.optional(vec![45, -78])
.c(Bar)
.build();
Amusons nous un peu
Pour un champ obligatoire d : String
. Nous pouvons appeler notre macro ainsi.
Nous créons un nouvel état WithC
, puis la transition WithB => WithC
pour intercaler notre nouvelle état.
build_builder! {
#[derive(Debug, PartialEq)]
struct Foo [
WithA,
WithB,
WithC,
]{
a: u8, [ Init => WithA ]
b: f32, [ WithA => WithB ]
c: Bar, [ WithB => WithC ]
d: String [ WithC => Buildable ]
#[optional fields]
optional: Vec<i8>,
optional2: bool
};
[builder_name => builder::BuilderFoo]
}
Et ainsi, pouvoir définir notre champ d
.
let foo = Foo::builder()
.a(12)
.optional2(bool)
.b(45.5)
.optional(vec![45, -78])
.c(Bar)
.d("String".to_string())
.build();
Le champ
d
doit être obligatoirement défini, sinon, une erreur de compilation est levée.
Conclusion
Et bien quel voyage ! 😄
Nous avons notre builder qui est entièrement généré par macro.
Il est évidemment perfectible et ne gére pas tous les cas existants.
Par exemple, il n’est pas possible d’avoir de lifetime dans la déclaration de type de champ et la syntaxe est encore un trop rigide pour être utilisable dans tous les cas.
Mais nous avons l’essentiel !
J’espère que cette application pratique des macros vous aura plu.
Et si vous le désirez, je pourrai écrire un article sur les différentes améliorations que l’on peut lui apporter. 😀
Merci de votre lecture et à la prochaine ! ❤️
Ce travail est sous licence CC BY-NC-SA 4.0.