https://lafor.ge/feed.xml

Blanket implementation : émuler l'héritage

2024-08-25

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.

struct Boat {
    longitude: f32,
    latitude: f32,
}

Puis on simule le déplacement via un delta qui possède deux composantes également.

impl Boat {
    fn progress(&self, delta: (f32, f32)) -> Vec<f32> {
        vec![self.longitude + delta.0, self.latitude + delta.1]
    }
}

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)

struct Plane {
    longitude: f64,
    latitude: f64,
    altidude: f64,
}

impl Plane {
    fn progress(&self, delta: (f64, f64, f64)) -> Vec<f64> {
        vec![
            self.longitude + delta.0,
            self.latitude + delta.1,
            self.altidude + delta.2,
        ]
    }
}

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.

struct A {
    x: isize,
    y: isize,
}

struct B {
    x: isize,
    y: isize,
}

La primitive principale de factorisation de code en Rust est le trait.

On défini alors le trait Progress.

trait Progress {
    fn progress(&self, delta: (isize, isize)) -> Vec<isize>;
}

On se retrouve alors à l'implémenter pour A et B.

impl Progress for A {
    fn progress(&self, delta: (isize, isize)) -> Vec<isize> {
        vec![self.x + delta.0, self.y + delta.1]
    }
}

impl Progress for B {
    fn progress(&self, delta: (isize, isize)) -> Vec<isize> {
        vec![self.x + delta.0, self.y + delta.1]
    }
}

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.

trait Progress {
    fn progress(&self, delta: (isize, isize)) -> Vec<isize> {
        vec![self.x + delta.0, self.y + delta.1]
    }
}

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.

trait Position {
    fn position(&self) -> (isize, isize);
}

On implémente alors ces traits pour A et B.

impl Position for A {
    fn position(&self) -> (isize, isize) {
        (self.x, self.y)
    }
}

impl Position for B {
    fn position(&self) -> (isize, isize) {
        (self.x, self.y)
    }
}

Si on reprend notre tentative de factorisation dans le trait.

trait Progress {
    fn progress(&self, delta: (isize, isize)) -> Vec<isize> {
        let position = self.position();
        vec![position.0 + delta.0, position.1 + delta.1]
    }
}

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

trait Progress: Position {
    fn progress(&self, delta: (isize, isize)) -> Vec<isize> {
        let position = self.position();
        vec![position.0 + delta.0, position.1 + delta.1]
    }
}

Et là c'est gagné !

Il nous suffit alors de vider les implémentation de A et B.

impl Progress for A {}
impl Progress for 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.

trait X {
    fn x();
}

On définit un second trait Marker qui va servir à accepter le comportement implicite

trait Marker {}

Alors, la généricité nous permet d'écrire une implémentation pour n'importe quel type T qui implémente Marker.

impl<T> X for T {
    fn x() {}
}

Alors si nous possédons des structures de données qui implémente le trait Marker.

struct A;
struct B;
enum E {
    V1,
    V2
}

impl Marker for A {}
impl Marker for B {}
impl Marker for E {}

Alors on peut écrire ce genre de choses.

fn main() {
    A.x();
    B.x();
    E::V1.x();
}

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().

impl<T: Position> Progress for T {}

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

impl Progress for A {}
impl Progress for B {}

Et maintenant cela fonctionne 🤩

On peut alors se faire un petit main pour tester notre code.

fn main() {
    let a = A { x: 1, y: 2 };
    a.progress((4, 2));
}

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 et Posistion, 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
trait Progress {
    fn progress(&self, delta: (isize, isize)) -> Vec<isize>;
}

impl<T: Position> Progress for T {
    fn progress(&self, delta: (isize, isize)) -> Vec<isize> {
        let position = self.position();
        vec![position.0 + delta.0, position.1 + delta.1]
    }
}

Ce découplage est esthétique car il ne peut y avoir qu'une seule blanket implementation par trait.

trait X {
    fn x(&self);
}

trait Marker {}
trait Marker2 {}

impl<T: Marker> X for T {
    fn x(&self) {}
}

impl<T: Marker2> X for T {
    fn x(&self) {}
}

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.

trait Position {
    fn position(&self) -> Vec<isize>;
}

On peut alors différencier A et B en nombre de composantes.

struct A {
    x: isize,
    y: isize,
}

struct B {
    x: f32,
    y: f32,
    z: f32,
}

Ce qui induit de modifier leur implementations dans A et B.

impl Position for A {
    fn position(&self) -> Vec<isize> {
        vec![self.x, self.y]
    }
}

impl Position for B {
    fn position(&self) -> Vec<isize> {
        vec![self.x, self.y, self.z]
    }
}

Finalement, nous pouvons modifier l'implémentation de Progress pour refléter cette modification.

impl<T: Position> Progress for T {
    fn progress(&self, delta: Vec<isize>) -> Vec<isize> {
        let position = self.position();
        std::iter::zip(position, delta)
            .map(|(component, delta)| component + delta)
            .collect()
    }
}

Nous ne manipulons donc plus que des tableaux. Et cela se reflète dans le main également:

fn main() {
    let a = A { x: 1, y: 2 };
    let b = B { x: 1, y: 2, z: 5 };
    a.progress(vec![4, 2]);
    b.progress(vec![8, -3, 0]);
}

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.

trait Progress {
    type Data;
    fn progress(&self, delta: Vec<Self::Data>) -> Vec<Self::Data>;
}

On fait également de même avec le trait Position.

trait Position {
    type Data;
    fn position(&self) -> Vec<Self::Data>;
}

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.

impl<T: Position> Progress for T
{
    fn progress(&self, delta: Vec<Self::Data>) -> Vec<Self::Data> {
        let position = self.position();
        std::iter::zip(position, delta)
            .map(|(component, delta)| component + delta)
            .collect()
    }
}

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

impl<T: Position> Progress for T
{
    // Définition du type Data par rapport au type de T::Data
    type Data = T::Data;

    fn progress(&self, delta: Vec<Self::Data>) -> Vec<Self::Data> {
        let position = self.position();
        std::iter::zip(position, delta)
            .map(|(component, delta)| component + delta)
            .collect()
    }
}

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

  impl<T: Position> Progress for T
 where
    // restriction du type T
    T::Data: std::ops::Add
  {
      type Data = T::Data;

      fn progress(&self, delta: Vec<Self::Data>) -> Vec<Self::Data> {
          let position = self.position();
          std::iter::zip(position, delta)
              .map(|(component, delta)| component + delta)
              .collect()
      }
  }

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.

impl<T: Position> Progress for T
where
    T::Data: Add<Output = T::Data>,
{
    type Data = T::Data;
    fn progress(&self, delta: Vec<Self::Data>) -> Vec<Self::Data> {
        let position = self.position();
        std::iter::zip(position, delta)
            .map(|(component, delta)| component + delta)
            .collect()
    }
}

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.

struct A {
    x: isize,
    y: isize,
}

struct B {
    x: f32,
    y: f32,
    z: f32,
}

Puis le trait Position et surtout le trait associé Position::Data

impl Position for A {
    type Data = isize;
    fn position(&self) -> Vec<Self::Data> {
        vec![self.x, self.y]
    }
}

impl Position for B {
    type Data = f32;
    fn position(&self) -> Vec<Self::Data> {
        vec![self.x, self.y, self.z]
    }
}

Finalement, cela nous donne le main suivant.

fn main() {
    let a = A { x: 1, y: 2 };
    let b = B {
        x: 1.,
        y: 2.,
        z: 5.,
    };
    a.progress(vec![4, 2]);
    b.progress(vec![8., -3., 0.]);
}

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.

trait Velocity {
    type Data;
    fn velocity(&self) -> Vec<Self::Data>;
}

Comme nous avons deux groupes de valeurs, nous allons modifier les définitions des structures A et B.

struct A {
    pos: (isize, isize),
    velocity: (isize, isize),
}

struct B {
    pos: (f32, f32, f32),
    velocity: (f32, f32, f32),
}

On peut alors implémenter Position et Velocity.

impl Position for A {
    type Data = isize;
    fn position(&self) -> Vec<Self::Data> {
        let (x, y) = self.pos;
        vec![x, y]
    }
}

impl Velocity for A {
    type Data = isize;
    fn velocity(&self) -> Vec<Self::Data> {
        let (x, y) = self.velocity;
        vec![x, y]
    }
}

impl Position for B {
    type Data = f32;
    fn position(&self) -> Vec<Self::Data> {
        let (x, y, z) = self.velocity;
        vec![x, y, z]
    }
}

impl Velocity for B {
    type Data = f32;
    fn velocity(&self) -> Vec<Self::Data> {
        let (x, y, z) = self.velocity;
        vec![x, y, z]
    }
}

On peut également définir des constructeurs.

impl A {
    fn new(x: isize, y: isize) -> Self {
        A {
            pos: (x, y),
            velocity: (0, 0),
        }
    }
}

impl B {
    fn new(x: f32, y: f32, z: f32) -> Self {
        B {
            pos: (x, y, z),
            velocity: (0., 0., 0.),
        }
    }
}

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.

trait Progress {
    type Data;
    fn progress(&self, delta: Self::Data) -> Vec<Self::Data>;
}

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:

fn progress(&self, delta: Self::Data) -> Vec<Self::Data> {
    // on récupère la position courante
    let position = self.position();
    // on récupère la vitesse 
    let velocity = self.velocity();
    // on réalise le calcul et on renvoie la nouvelle position
    std::iter::zip(position, velocity)
        .map(|(component, velocity)| (component + velocity) * delta)
        .collect()
}

Et si l'on injecte dans l'implémentation du trait Progress, en indiquant que le Progress::Data est T::PositionData

    impl<T: Position + Velocity> Progress for T
    {
        // on vient définir le type de Data
        type Data = T::PositionData;
        // on injecte notre nouvel fonction progress
        fn progress(&self, delta: Self::Data) -> Vec<Self::Data> {
            let position = self.position();
            let velocity = self.velocity();
            std::iter::zip(position, velocity)
                .map(|(component, velocity)| (component + velocity) * delta)
                .collect()
        }
    }

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.

    impl<T: Position + Velocity> Progress for T
    where
        // L'adddition de T::PositionData avec T::VelocityData donne un T::PositionData
        T::PositionData: Add<T::VelocityData, Output = T::PositionData>,
    {
        type Data = T::VelocityData;
        fn progress(&self, delta: Self::Data) -> Vec<Self::Data> {
            let position = self.position();
            let velocity = self.velocity();
            std::iter::zip(position, velocity)
                .map(|(component, velocity)| (component + velocity) * delta)
                .collect()
        }
    }

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.

    impl<T: Position + Velocity> Progress for T
    where
        T::PositionData: Add<T::VelocityData, Output = T::PositionData>,
        // Multiplier deux T::PositionData donne un T::PositionData
        T::PositionData: Mul<Output = T::PositionData>,
    {
        type Data = T::VelocityData;
        fn progress(&self, delta: Self::Data) -> Vec<Self::Data> {
            let position = self.position();
            let velocity = self.velocity();
            std::iter::zip(position, velocity)
                .map(|(component, velocity)| (component + velocity) * delta)
                .collect()
        }
    }

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.

impl<T: Position + Velocity> Progress for T
where
    T::PositionData: Add<T::VelocityData, Output = T::PositionData>,
    T::PositionData: Mul<Output = T::PositionData>,
    T::PositionData: Copy,
{
    type Data = T::PositionData;
    fn progress(&self, delta: Self::Data) -> Vec<Self::Data> {
        let position = self.position();
        let velocity = self.velocity();
        std::iter::zip(position, velocity)
            .map(|(component, velocity)| (component + velocity) * delta)
            .collect()
    }
}

Et cette fois-ci, nous sommes bons! 🤩

On peut alors s'amuser avec notre nouveau système.

fn main() {
    let a = A::new(1, 2);
    let b = B::new(1., 2., 5.);
    a.progress(1);
    b.progress(2.3);
}

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.

struct Boat {
    pos: (f32, f32),
    velocity: (f32, f32),
}

Et notre Plane sur 3 axes et une double précision sur les flottants.

struct Plane {
    pos: (f64, f64, f64),
    velocity: (f64, f64, f64),
}

On implémenter les différents traits nécessaires.

impl Position for Boat {
    type PositionData = f32;
    fn position(&self) -> Vec<Self::PositionData> {
        let (longitude, latitude) = self.pos;
        vec![longitude, latitude]
    }
}

impl Velocity for Boat {
    type VelocityData = f32;
    fn velocity(&self) -> Vec<Self::VelocityData> {
        let (velocity_x, velocity_y) = self.velocity;
        vec![velocity_x, velocity_y]
    }
}

impl Position for Plane {
    type PositionData = f64;
    fn position(&self) -> Vec<Self::PositionData> {
        let (longitude, latitude, altitude) = self.pos;
        vec![longitude, latitude, altitude]
    }
}

impl Velocity for Plane {
    type VelocityData = f64;
    fn velocity(&self) -> Vec<Self::VelocityData> {
        let (velocity_x, velocity_y, velocity_z) = self.velocity;
        vec![velocity_x, velocity_y, velocity_z]
    }
}

On se créé des constructeurs.

impl Boat {
    fn new(longitude: f32, latitude: f32) -> Self {
        Self {
            pos: (longitude, latitude),
            velocity: (0., 0.),
        }
    }
}

impl Plane {
    fn new(longitude: f64, latitude: f64, altitude: f64) -> Self {
        Self {
            pos: (longitude, latitude, altitude),
            velocity: (0., 0., 0.),
        }
    }
}

On obtient alors gratuitement la méthode progress() pour Boat et Plane.

fn main() {
    let boat = Boat::new(4.5, 90.2);
    let plane = Plane::new(10.851515415151, 33.545455255252, 15615.545555);
    boat.progress(12.5);
    plane.progress(12.5);
}

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. ❤️

avatar

Auteur: Akanoa

Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.

Ce travail est sous licence CC BY-NC-SA 4.0.