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)]structFoo{a:u8,
b:f32}implFoo{pubfnnew(a:u8, b:f32)->Self{ Foo { a, b
}}}#[test]fntest(){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:
structFoo{a:u8,
b:f32,
c:bool}implFoo{pubfnnew(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
Contrainte 2
Notre structure peut posséder des champs optionnels.
structFoo{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();
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]fninverted(){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]fnwithout_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]fnwithout_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
Notre Init est le Builder en lui-même, nous pouvons donc l’ignorer (pour l’instant 🤐).
Et maintenant, au tour des transitions !
implBuilder{pubfna(self, a:u8)-> WithA{ WithA { a, b:self.b }}}implWithA{pubfnb(self, b:f32)-> Buildable{ Buildable { a:self.a, b }}}implBuildable{pubfnbuild(self)-> Foo{ Foo { a:self.a, b:self.b,}}}
Bon mieux 😀
// compile et réussi ✅
#[test]fnbasic(){}// ne compile plus ✅
#[test]fninverted(){}// ne compile plus ✅
#[test]fnwithout_b(){}// ne compile plus ✅
#[test]fnwithout_a(){}
Ç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:
structInit;structWithA;structBuildable;#[derive(Default)]structBuilder<T>{a:u8,
b:f32,
}implBuilder<Init>{pubfna(self, a:u8)->Builder<WithA>{Builder::<WithA>{ a, b:self.b }}}implBuilder<WithA>{pubfnb(self, b:f32)->Builder<Buildable>{Builder::<Buildable>{ a:self.a, b }}}implBuilder<Buildable>{pubfnbuild(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>.
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. 😉
Essayons de voir si notre méthode optional() peut être placée n’importe où.Code Rust
#[test]fnwith_optional_before_a(){let foo =Foo::builder().a(12).optional(vec![45,-78]).b(45.5).build();let expected = Foo { a:12, b:45.5, optional:vec![45,-78],};assert_eq!(foo, expected);}#[test]fnwith_optional_before_builder(){let foo =Foo::builder().optional(vec![45,-78]).a(12).b(45.5).build();let expected = Foo { a:12, b:45.5, optional:vec![45,-78],};assert_eq!(foo, expected);}#[test]// On vérifie que le dernier appel à optional est celui qui détermine
// la valeur du champ final
fnwith_optional_duplicated(){let foo =Foo::builder().optional(vec![45,-78]).a(12).b(45.5).optional(vec![45,-78,-1]).build();let expected = Foo { a:12, b:45.5, optional:vec![45,-78,-1],};assert_eq!(foo, expected);}
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
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:Code Rust
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 modification
champ: 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 optionnels
champ : 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 un ident en string et ainsi de pouvoir l’afficher dans un println!, ou de la comparer avec une autre String.
macro_rules!fill_field{($self:ident, $field_name:ident, $target:ident, $value:expr)=>{ifstringify!($field_name)==stringify!($target){Some($value)}else{$self.$field_name}};}#[test]fntest_field_name_macro(){structA{a:Option<u8>,
b:Option<f32>}implA{fnset_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:
ifstringify!( a )==stringify!( a ){Some(a)// type Option<u8>
}else{self.a // type Option<u8>
}
Pour le champ b:
ifstringify!( 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.
Et si on veut écrire un test pour s’en assurer:Code Rust
usett_call::tt_if;usett_equal::tt_equal;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}]}};}#[test]fntest_field_name_macro(){#[derive(Debug, PartialEq)]structA{a:Option<u8>,
b:Option<f32>}implA{fnset_a(&self, a:u8)-> A{ A { a:fill_field!(self, a, a, a), b:fill_field!(self, b, a, a),}}}let a = A { a:None, b:None};let a = a.set_a(42);assert_eq!(a, A { a:Some(42), b:None})}
Nous avons besoin d’une deuxième macro pour gérer le cas des champs optionnels:
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.
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.Code Rust
pubfnoptional(self, optional:Vec<i8>)->Builder<T>{Builder::<T>{ a:fill_field!(self, a, optional, optional), b:fill_field!(self, b, optional, optional), c:fill_field!(self, c, optional, optional), optional:fill_field_optional!(self, optional, optional, optional), optional2:fill_field_optional!(self, optional2, optional, optional), state:Default::default(),}}pubfnoptional2(self, optional2:bool)->Builder<T>{Builder::<T>{ a:fill_field!(self, a, optional2, optional2), b:fill_field!(self, b, optional2, optional2), c:fill_field!(self, c, optional2, optional2), optional:fill_field_optional!(self, optional, optional2, optional2), optional2:fill_field_optional!(self, optional2, optional2, optional2), state:Default::default(),}}pubfna(self, a:u8)->Builder<WithA>{Builder::<WithA>{ a:fill_field!(self, a, a, a), b:fill_field!(self, b, a, a), c:fill_field!(self, c, a, a), optional:fill_field_optional!(self, optional, a, a), optional2:fill_field_optional!(self, optional2, a, a), state:Default::default(),}}pubfnb(self, b:f32)->Builder<WithB>{Builder::<WithB>{ a:fill_field!(self, a, b, b), b:fill_field!(self, b, b, b), c:fill_field!(self, c, b, b), optional:fill_field_optional!(self, optional, b, b), optional2:fill_field_optional!(self, optional2, b, b), state:Default::default(),}}
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.
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:
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]fnmain(){build_builder!{structFoo{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]fnmain(){build_builder!{structFoo{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]fnmain(){build_builder!{structFoo{a:u8,
b:f32,
c: Bar,
#[optionalfields]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:
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.
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:
implBuilder<Init>{pubfna(self, a:u8)->Builder<WithA>{fill_struct!(self, a, a, WithA)}}implBuilder<WithA>{pubfnb(self, b:f32)->Builder<WithB>{fill_struct!(self, b, b, WithB)}}implBuilder<WithB>{pubfnc(self, c: Bar)->Builder<Buildable>{fill_struct!(self, c, c, Buildable)}}
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:
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.
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.
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!.
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.
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.
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 ! ❤️
Auteur: Akanoa
Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.