Blanket implementation : émuler l'héritage
Bonjour à toutes et à tous 😀
Le système de traits de Rust est extrêmement puissant et bien pensé.
Nous allons aujourd'hui l'utiliser pour créer un système d'héritage.
Mise en situation
Partons d'un exemple très simple, imaginons que l'on souhaite modéliser le déplacement d'un bateau sur l'eau. Pour le repérer nous avons besoin de 2 positions: la latitude et la longitude.
Puis on simule le déplacement via un delta
qui possède deux composantes également.
Ce qui permet alors de faire les combinaisons de mouvements que l'on désire:
- accélérer
- ralentir
- machine arrière
- virer de bord
Et si on veux faire de même pour un avion ?
Un avion, je ne vous l'apprends pas, ça vole ! Donc pour le placer dans l'espace, il nous faut 3 positions :
- longitude
- latitude
- altitude
On se retrouve alors avec ceci. (noté j'ai changé le type des composantes exprès, ça va me servir pour la suite des explications)
Implémentation du trait Progress
Les deux codes sont trop semblables pour ne pas être factorisable.
Nous avons deux différences:
- le nombre de composantes
- le type des composantes
Nous allons y aller progressivement. Mais je vous conseil de suivre avec un IDE ouvert et du café. 😅
Nous allons faire deux changements pour venir construire notre abstraction finale.
Premièrement on va considérer qu'il n'y a que deux composantes et que leur type est isize pour tout le monde.
On se créé alors deux structures.
La primitive principale de factorisation de code en Rust est le trait.
On défini alors le trait Progress
.
On se retrouve alors à l'implémenter pour A
et B
.
Tentative de factorisation
Ce qui ne règle pas le problème de factorisation.
Factorisation du trait Progress
Heureusement, il est possible de remonter l'implémentation dans le trait.
Enfin, on voudrait bien, mais self.x
n'existe pas au niveau du trait 😑
no field `x` on type `&Self`
|
| trait Progress {
| -------------- type parameter 'Self' declared here
| fn progress(&self, delta: (isize, isize)) -> Vec<isize> {
| vec![self.x + delta.0, self.y + delta.1]
| ^ unknown field
Introduction du trait Position
Pour résoudre cela on va utilisier un deuxième trait qui va avoir pour rôle de renvoyer nos composantes.
On implémente alors ces traits pour A
et B
.
Si on reprend notre tentative de factorisation dans le trait.
On est pas plus avancé ...
no method named `position` found for reference `&Self` in the current scope
|
| let position = self.position();
Super-trait
Sauf qu'en fait si ! Il suffit que Progress
ait pour super-trait Position
Et là c'est gagné !
Il nous suffit alors de vider les implémentation de A
et B
.
Mais il n'y aurait-il pas moyen de se débarasser de ces deux lignes ?
Et bien si ! Et c'est même le sujet de cet article! 😃
Blanket implementation
Le sytème de typage de Rust est extrêmement puissant, il permet au travers de traits de venir définir implicitement des comportements des structures de données.
Implicitement !? Mais Rust on dit toujours que Rust est explicite. Est-ce que c'est une exception?
Non, toujours pas, il faut que le développeur choisisse d'implémenter le trait avant de pouvoir profiter du comportement implicite.
Du coup, comment ça marche?
Disons que nous avons le trait X
qui définit un comportement.
On définit un second trait Marker
qui va servir à accepter le comportement implicite
Alors, la généricité nous permet d'écrire une implémentation pour n'importe quel type T
qui implémente Marker
.
Alors si nous possédons des structures de données qui implémente le trait Marker
.
;
;
Alors on peut écrire ce genre de choses.
On remarque que la méthode x
est accessible alors qu'elle n'a pas été définie par les structures de données.
Ce mécanisme se nomme blanket implementation
.
On peut en voir sur Vec<T> par exemple.
L'idée est simple, si quelque chose a besoin d'être Position
pour implémenter Progress
, alors le concept de générique peut nous sauver.
Blanket implémentation de Progress (première version)
Nous commençons par implémenter Progress
pour tout T
implémentant Position
.
Ce qui permet à T
d'obtenir l'accès à position()
.
On remarque que l'implémentation est vide car elle est définie par défaut dans le trait Progress
.
Par contre a deux conflits, respectivement pour A
et B
.
| impl<T: Position> Progress for T {}
| -------------------------------- first implementation here
...
| impl Progress for A {}
| ^^^^^^^^^^^^^^^^^^^ conflicting implementation for `A`
Normal, notre T
est un remplacement à la compilation de A
et B
.
On peut complètement supprimer les implémentations
Et maintenant cela fonctionne 🤩
On peut alors se faire un petit main pour tester notre code.
Et voilà ! Merci à toutes ...
Alors oui mais non, trois choses ne sont pas correctes.
- nous ne gérons pas un nombre variables de composantes
- nous avons un type
isize
qui est fixe - nous avons couplé
Progress
etPosistion
, du point vue du code c'est correcte, mais sémantiquement c'est absurde.
Blanket implémentation de Progress (deuxième version)
Réglons d'abord le problème du couplage.
Pour cela on opère trois changements:
- Position n'est plus le super-trait de Progress
- Il n'y a plus d'implémentation par défaut utilisant Position
- l'implémentation redescend au niveau de T
Ce découplage est esthétique car il ne peut y avoir qu'une seule blanket implementation par trait.
Provoque une erreur:
error: conflicting implementations of trait `X`
|
| impl<T: Marker> X for T {
| ----------------------- first implementation here
...
| impl<T: Marker2> X for T {
| ^^^^^^^^^^^^^^^^^^^^^^^^ conflicting implementation
Mais cela nous sera utile dans la suite de nos opérations.
Blanket implémentation de Progress (troisième version)
Maintenant que nous avons opéré le découplage, nous pouvons nous attaquer au deux autres contraintes:
- nous ne gérons pas un nombre variables de composantes
- nous avons un type
isize
qui est fixe
Attaquons nous en premier lieu à la multiplicité des composantes.
Nous allons faire une petite modification. Au lieu de faire les additions sur chaque composantes, on somme le zip des deltas avec les composantes.
Nous modifions tout d'abord le trait Position
pour que le retour ne soit non plus un (isize, isize)
mais un Vec<isize>
bien plus souple.
On peut alors différencier A
et B
en nombre de composantes.
Ce qui induit de modifier leur implementations dans A
et B
.
Finalement, nous pouvons modifier l'implémentation de Progress
pour refléter cette modification.
Nous ne manipulons donc plus que des tableaux. Et cela se reflète dans le main
également:
Bon on avance! 😃
Blanket implémentation de Progress (quatrième version)
Plus que une contrainte:
- nous avons un type
isize
qui est fixe
Pour y arriver, nous allons devoir changer des choses en profondeur.
Nous allons rajouter une type associé Data
à notre trait Progress
.
Et nous allons dire que notre méthode progress
prend un tableau de Data
et renvoie également un tableau de Data
.
On fait également de même avec le trait Position
.
Pour le gros du morceau, allons-y progressivement, et laissons-nous guider par le compilateur.
On commence par rendre compatible la méthode progress
de notre implémentation pour T
.
Première erreur
not all trait items implemented, missing: `Data`
|
| type Data;
| --------- `Data` from trait
...
| impl<T: Position> Progress for T
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `Data` in implementation
Effectivement, il faut que le type de Progress::Data
soit configuré. Nous allons lui donner celui de Position::Data
Nouvelle erreur, il n'est pas capable de déterminer si deux T::Data
peuvent s'additionner
error: cannot add `T::Data` to `T::Data`
|
340 | .map(|(component, delta)| component + delta)
| --------- ^ ----- T::Data
| |
| T::Data
Et comme nous l'indique le compilateur, nous pouvons restreindre le scope de type acceptés à seuleument ceux qui peuvent s'additionner.
help: consider further restricting the associated type
|
| fn progress(&self, delta: Vec<Self::Data>) -> Vec<Self::Data> where T::Data: Add {
| ++++++++++++++++++
On applique les recommendations
Mais nouvelle erreur:
error: a value of type `Vec<T::Data>` cannot be built from an iterator over elements of type `<T::Data as Add>::Output`
--> src/main.rs:341:18
|
| .collect()
| ^^^^^^^ value of type `Vec<T::Data>` cannot be built from `std::iter::Iterator<Item=<T::Data as Add>::Output>`
|
Ce qu'il nous dit, c'est qu'il n'est pas certain de pouvoir construire un Vec<T::Data>
à partir d'un itérateur des résultats de somme de deux T::Data
.
En effet, rien n'oblige la somme de deux T::Data
d'être lui même un T::Data
.
Le compilateur nous amène dans une direction qui consiste à contraindre l'itérateur.
= help: the trait `FromIterator<<T::Data as Add>::Output>` is not implemented for `Vec<T::Data>`
note: required by a bound in `collect`
|
| fn collect<B: FromIterator<Self::Item>>(self) -> B
| ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `Iterator::collect`
help: consider extending the `where` clause, but there might be an alternative better way to express this requirement
|
| T::Data: std::ops::Add, Vec<T::Data>: FromIterator<<T::Data as Add>::Output>
Mais j'ai trouvé une méthode plus élégante en mon sens.
Lui dire que T::Data + T::Data = T::Data
, ainsi il n'a plus d'ambiguité possible.
Pour cela on utilise le type associé Output
du trait Add
pour contraindre le retour.
Et cette fois-ci, il est content. 😎
Nous pouvons alors implémenter A
et B
que nous faisons varier en nombre de composantes et en type.
Puis le trait Position
et surtout le trait associé Position::Data
Finalement, cela nous donne le main suivant.
Succès!! 🤩😎
Complexifions le système de traits
Par contre, physiquement ce n'est pas comme ça que l'on calcul un déplacement.
Le déplacement c'est la position actuelle additionnée à la vitesse fois un pas de temps. $$ pos_{new} = pos_{current} + v \times t$$
Il nous faut donc quelque chose pour modéliser cette vitesse.
Et bien globalement on va faire pareil que pour la position, nous allons créer un trait Velocity
identique à Position
.
Comme nous avons deux groupes de valeurs, nous allons modifier les définitions des structures A
et B
.
On peut alors implémenter Position
et Velocity
.
On peut également définir des constructeurs.
Nous pouvons maintenant nous attaquer à la plus grosse partie: l'implémentation du trait Progress
.
Comme précédemment, nous allons y aller pas à pas.
La première chose que l'on désire c'est de pouvoir faire le calcul $ pos_{new} = pos_{current} + v \times t$ pour chacune des composantes.
Pour refléter cette volonté, nous modifions le trait Progress
.
Désormais, notre fonction progress
prendra un delta de temps d'un certain type et renverra un tableau de ce même type.
Pour faire ce calcul, nous aurons besoin de la vitesse et de la position courante, ainsi que du pas de temps.
Ainsi ce la nous donne:
Et si l'on injecte dans l'implémentation du trait Progress
, en indiquant que le Progress::Data est T::PositionData
On va avoir plusieurs soucis.
Premièrement:
error: cannot add `T::VelocityData` to `T::PositionData`
|
| .map(|(component, velocity)| (component + velocity) * delta)
| --------- ^ -------- T::VelocityData
| |
| T::PositionData
Cela, nous connaissons, il faut montrer à Rust que les deux types de Velocity et de Position sont additionnables en restreingnant les types.
Nous allons alors rencontrer une autre erreur.
cannot multiply `T::PositionData` by `T::PositionData`
|
| .map(|(component, velocity)| (component + velocity) * delta)
| ---------------------- ^ ----- T::PositionData
| |
| T::PositionData
On peut alors de nouveau restreindre le type pour le rendre compatible à la multiplication.
Et presque...
cannot move out of `delta`, a captured variable in an `FnMut` closure
|
| fn progress(&self, delta: Self::Data) -> Vec<Self::Data> {
| ----- captured outer variable
...
| .map(|(component, velocity)| (component + velocity) * delta)
| ----------------------- ^^^^^ move occurs because `delta` has type `T::PositionData`,
which does not implement the `Copy` trait
| |
| captured by this `FnMut` closure
Rust ne sait pas encore qu'il peut copier dans la closure de la map notre valeur de delta. On peut le lui expliquer.
Et cette fois-ci, nous sommes bons! 🤩
On peut alors s'amuser avec notre nouveau système.
Résolution
Bien revenons à notre problématique de base, nous pouvons réappliquer ce que l'on connaît.
On a notre Boat
possédant des coordonnées en 2D et une simple précision sur les flottants.
Et notre Plane
sur 3 axes et une double précision sur les flottants.
On implémenter les différents traits nécessaires.
On se créé des constructeurs.
On obtient alors gratuitement la méthode progress()
pour Boat
et Plane
.
Et fin du voyage ! 😎
Conclusion
J'ai utilisé la blanket implementation
pour aborder le sujet des traits. Je voulais le faire depuis bien longtemps mais je n'avais pas d'angle pour y arriver.
Et on peut dire que l'on a brassé large: on défini des traits, des méthodes par défauts, des types associés et même des blanket implementations.
J'espère que cela vous a plu et que vous avez appris deux ou trois choses.
Merci pour votre lecture. ❤️
Ce travail est sous licence CC BY-NC-SA 4.0.