Implémenter un patron de conception orienté-objet

Le patron état est un patron de conception orienté objet. Le point essentiel de ce patron est qu'une valeur possède un état interne qui est représenté par un ensemble d'objets état, et le comportement de la valeur change en fonction de son état interne. Les objets état partagent des fonctionnalités : en Rust, bien sûr, nous utilisons des structures et des traits plutôt que des objets et de l'héritage. Chaque objet état est responsable de son propre comportement et décide lorsqu'il doit changer pour un autre état. La valeur contenue dans un objet état ne sait rien sur les différents comportements des états et ne sait pas quand il va changer d'état.

L'utilisation du patron état signifie que lorsque les exigences métier du programme ont changé, nous n'avons pas besoin de changer le code à l'intérieur de l'objet état ou le code qui utilise l'objet. Nous avons juste besoin de modifier le code dans un des objets état pour changer son fonctionnement ou pour ajouter d'autres objets état. Voyons un exemple du patron état et comment l'utiliser en Rust.

Nous allons implémenter un processus de publication de billets de blogs de manière incrémentale. Les fonctionnalités finales du blog seront les suivantes :

  1. Un billet de blog commence par un brouillon vide.
  2. Lorsque le brouillon est terminé, une relecture du billet est demandée.
  3. Lorsqu'un billet est approuvé, il est publié.
  4. Seuls les billets de blog publiés retournent du contenu à afficher si bien que les billets non approuvés ne peuvent pas être publiés accidentellement.

Tous les autres changements effectués sur un billet n'auront pas d'effet. Par exemple, si nous essayons d'approuver un brouillon de billet de blog avant d'avoir demandé une relecture, le billet devrait rester à l'état de brouillon non publié.

L'encart 17-11 présente ce processus de publication sous forme de code : c'est un exemple d'utilisation de l'API que nous allons implémenter dans une crate de bibliothèque blog. Elle ne va pas encore se compiler car nous n'avons pas encore implémenté la crate blog.

Fichier : src/main.rs

use blog::Billet;

fn main() {
    let mut billet = Billet::new();

    billet.ajouter_texte("J'ai mangé une salade au déjeuner aujourd'hui");
    assert_eq!("", billet.contenu());

    billet.demander_relecture();
    assert_eq!("", billet.contenu());

    billet.approuver();
    assert_eq!("J'ai mangé une salade au déjeuner aujourd'hui", billet.contenu());
}

Encart 17-11 : du code qui montre le comportement attendu de notre crate blog

Nous voulons permettre à l'utilisateur de créer un nouveau brouillon de billet de blog avec Billet::new. Nous voulons qu'il puisse ajouter du texte au billet de blog. Si nous essayons d'obtenir immédiatement le contenu du billet, avant qu'il ne soit relu, nous n'obtiendrons aucun texte car le billet est toujours un brouillon. Nous avons ajouté des assert_eq! dans le code pour les besoins de la démonstration. Un excellent test unitaire pour cela serait de vérifier qu'un brouillon de billet de blog retourne bien une chaîne de caractères vide à partir de la méthode contenu, mais nous n'allons pas écrire de tests pour cet exemple.

Ensuite, nous voulons permettre de demander une relecture du billet, et nous souhaitons que contenu retourne toujours une chaîne de caractères vide pendant que nous attendons la relecture. Lorsque la relecture du billet est approuvée, il doit être publié, ce qui signifie que le texte du billet doit être retourné lors de l'appel à contenu.

Remarquez que le seul type avec lequel nous interagissons avec la crate est le type Billet. Ce type va utiliser le patron état et va héberger une valeur qui sera un des trois objets état représentant les différents états par lesquels passe un billet : brouillon, en attente de relecture ou publié. Le changement d'un état à un autre sera géré en interne du type Billet. Les états vont changer en réponse à l'appel des méthodes de l'instance de Billet par les utilisateurs de notre bibliothèque qui n'auront donc pas à les gérer directement. Ainsi les utilisateurs ne peuvent pas faire d'erreur avec les états, comme celle de publier un billet avant qu'il ne soit relu par exemple.

Définir Billet et créer une nouvelle instance à l'état de brouillon

Commençons l'implémentation de la bibliothèque ! Nous savons que nous aurons besoin d'une structure publique Billet qui héberge du contenu, donc nous allons commencer par définir cette structure ainsi qu'une fonction publique new qui lui est associée pour créer une instance de Billet, comme dans l'encart 17-12. Nous allons aussi créer un trait privé Etat. Ensuite Billet devra avoir un champ privé etat pour y loger une Option<T> contenant un objet trait de Box<dyn Etat>. Nous verrons plus tard l'intérêt du Option<T>.

Fichier : src/lib.rs

pub struct Billet {
    etat: Option<Box<dyn Etat>>,
    contenu: String,
}

impl Billet {
    pub fn new() -> Billet {
        Billet {
            etat: Some(Box::new(Brouillon {})),
            contenu: String::new(),
        }
    }
}

trait Etat {}

struct Brouillon {}

impl Etat for Brouillon {}

Encart 17-12 : définition d'une structure Billet et d'une fonction new qui crée une nouvelle instance de Billet, un trait Etat et une structure Brouillon

Le trait Etat définit le comportement partagé par plusieurs états de billet, et les états Brouillon, EnRelecture et Publier vont tous implémenter ce trait Etat. Pour l'instant, le trait n'a pas de méthode, et nous allons commencer par définir uniquement l'état Brouillon car c'est l'état dans lequel nous voulons que soit un nouveau billet lorsqu'il est créé.

Lorsque nous créons un nouveau Billet, nous assignons à son champ etat une valeur Some qui contient une Box. Cette Box pointe sur une nouvelle instance de la structure Brouillon. Cela garantira qu'à chaque fois que nous créons une nouvelle instance de Billet, elle commencera à l'état de brouillon. Comme le champ etat de Billet est privé, il n'y a pas d'autre manière de créer un Billet dans un autre état ! Dans la fonction Billet::new, nous assignons une nouvelle String vide au champ contenu.

Stocker le texte du contenu du billet

L'encart 17-11 a montré que nous souhaitons appeler une méthode ajouter_texte et lui passer un &str qui est ensuite ajouté au contenu textuel du billet de blog. Nous implémentons ceci avec une méthode plutôt que d'exposer publiquement le champ contenu avec pub. Cela signifie que nous pourrons implémenter une méthode plus tard qui va contrôler comment le champ contenu sera lu. La méthode ajouter_texte est assez simple, donc ajoutons son implémentation dans le bloc Billet de l'encart 17-13 :

Fichier : src/lib.rs

pub struct Billet {
    etat: Option<Box<dyn Etat>>,
    contenu: String,
}

impl Billet {
    // -- partie masquée ici --
    pub fn new() -> Billet {
        Billet {
            etat: Some(Box::new(Brouillon {})),
            contenu: String::new(),
        }
    }

    pub fn ajouter_texte(&mut self, texte: &str) {
        self.contenu.push_str(texte);
    }
}

trait Etat {}

struct Brouillon {}

impl Etat for Brouillon {}

Encart 17-13 : implémentation de la méthode ajouter_texte pour ajouter du texte au contenu d'un billet

La méthode ajouter_texte prend en argument une référence mutable vers self, car nous changeons l'instance Billet sur laquelle nous appelons ajouter_texte. Nous faisons ensuite appel à push_str sur le String dans contenu et nous y envoyons l'argument texte pour l'ajouter au contenu déjà stocké. Ce comportement ne dépend pas de l'état dans lequel est le billet, donc cela ne fait pas partie du patron état. La méthode ajouter_texte n'interagit pas du tout avec le champ etat, mais c'est volontaire.

S'assurer que le contenu d'un brouillon est vide

Même si nous avons appelé ajouter_texte et ajouté du contenu dans notre billet, nous voulons que la méthode contenu retourne toujours une slice de chaîne de caractères vide car le billet est toujours à l'état de brouillon, comme le montre la ligne 7 de l'encart 17-11. Implémentons maintenant la méthode contenu de la manière la plus simple qui réponde à cette consigne : toujours retourner un slice de chaîne de caractères vide. Nous la changerons plus tard lorsque nous implémenterons la capacité de changer l'état d'un billet afin qu'il puisse être publié. Pour l'instant, les billets ne peuvent qu'être à l'état de brouillon, donc le contenu du billet devrait toujours être vide. L'encart 17-14 montre l'implémentation de ceci :

Fichier : src/lib.rs

pub struct Billet {
    etat: Option<Box<dyn Etat>>,
    contenu: String,
}

impl Billet {
    // -- partie masquée ici --
    pub fn new() -> Billet {
        Billet {
            etat: Some(Box::new(Brouillon {})),
            contenu: String::new(),
        }
    }

    pub fn ajouter_texte(&mut self, texte: &str) {
        self.contenu.push_str(texte);
    }

    pub fn contenu(&self) -> &str {
        ""
    }
}

trait Etat {}

struct Brouillon {}

impl Etat for Brouillon {}

Encart 17-14 : ajout d'une implémentation de la méthode contenu sur Billet qui va toujours retourner une slice de chaîne de caractères vide

Avec cette méthode contenu ajoutée, tout ce qu'il y a dans l'encart 17-11 fonctionne comme prévu jusqu'à la ligne 7.

Demander une relecture du billet va changer son état

Ensuite, nous avons besoin d'ajouter une fonctionnalité pour demander la relecture d'un billet, qui devrait changer son état de Brouillon à EnRelecture. L'encart 17-15 montre ce code :

Fichier : src/lib.rs

pub struct Billet {
    etat: Option<Box<dyn Etat>>,
    contenu: String,
}

impl Billet {
    // -- partie masquée ici --
    pub fn new() -> Billet {
        Billet {
            etat: Some(Box::new(Brouillon {})),
            contenu: String::new(),
        }
    }

    pub fn ajouter_texte(&mut self, texte: &str) {
        self.contenu.push_str(texte);
    }

    pub fn contenu(&self) -> &str {
        ""
    }

    pub fn demander_relecture(&mut self) {
        if let Some(s) = self.etat.take() {
            self.etat = Some(s.demander_relecture())
        }
    }
}

trait Etat {
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat>;
}

struct Brouillon {}

impl Etat for Brouillon {
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat> {
        Box::new(EnRelecture {})
    }
}

struct EnRelecture {}

impl Etat for EnRelecture {
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat> {
        self
    }
}

Encart 17-15 : implémentation des méthodes demander_relecture sur Billet et le trait Etat

Nous installons la méthode publique demander_relecture sur Billet qui va prendre en argument une référence mutable à self. Ensuite nous appelons la méthode interne demander_relecture sur l'état interne de Billet, et cette deuxième méthode demander_relecture consomme l'état en cours et applique un nouvel état.

Nous avons ajouté la méthode demander_relecture sur le trait Etat ; tous les types qui implémentent le trait vont maintenant devoir implémenter la méthode demander_relecture. Remarquez qu'au lieu d'avoir self, &self, ou &mut self en premier paramètre de la méthode, nous avons self: Box<Self>. Cette syntaxe signifie que la méthode est valide uniquement lorsqu'on l'appelle sur une Box qui contient ce type. Cette syntaxe prend possession de Box<Self>, ce qui annule l'ancien état du Billet qui peut changer pour un nouvel état.

Pour consommer l'ancien état, la méthode demander_relecture a besoin de prendre possession de la valeur d'état. C'est ce à quoi sert le Option dans le champ etat de Billet : nous faisons appel à la méthode take pour obtenir la valeur dans le Some du champ etat et le remplacer par None, car Rust ne nous permet pas d'avoir des champs non renseignés dans des structures. Cela nous permet d'extraire la valeur de etat d'un Billet, plutôt que de l'emprunter. Ensuite, nous allons réaffecter le résultat de cette opération à etat du Billet concerné.

Nous devons assigner temporairement None à etat plutôt que de lui donner directement avec du code tel que self.etat = self.etat.demander_relecture(); car nous voulons prendre possession de la valeur etat. Cela garantit que Billet ne peut pas utiliser l'ancienne valeur de etat après qu'on ait changé cet état.

La méthode demander_relecture sur Brouillon doit retourner une nouvelle instance d'une structure EnRelecture dans une Box, qui représente l'état lorsqu'un billet est en attente de relecture. La structure EnRelecture implémente elle aussi la méthode demander_relecture mais ne fait aucune modification. A la place, elle se retourne elle-même, car lorsque nous demandons une relecture sur un billet déjà à l'état EnRelecture, il doit rester à l'état EnRelecture.

Désormais nous commençons à voir les avantages du patron état : la méthode demander_relecture sur Billet est la même peu importe la valeur de son etat. Chaque état est maître de son fonctionnement.

Nous allons conserver la méthode contenu sur Billet comme elle est, elle va donc continuer à retourner une slice de chaîne de caractères vide. Nous pouvons maintenant avoir un Billet à l'état Brouillon ou EnRelecture, mais nous voulons qu'il suive le même comportement lorsqu'il est dans l'état EnRelecture. L'encart 17-11 fonctionne maintenant jusqu'à la ligne 10 !

Ajouter une méthode approuver qui change le comportement de contenu

La méthode approuver ressemble à la méthode demander_relecture : elle va changer etat pour lui donner la valeur que l'état courant retournera lorsqu'il sera approuvé, comme le montre l'encart 17-16 :

Fichier : src/lib.rs

pub struct Billet {
    etat: Option<Box<dyn Etat>>,
    contenu: String,
}

impl Billet {
    // -- partie masquée ici --
    pub fn new() -> Billet {
        Billet {
            etat: Some(Box::new(Brouillon {})),
            contenu: String::new(),
        }
    }

    pub fn ajouter_texte(&mut self, texte: &str) {
        self.contenu.push_str(texte);
    }

    pub fn contenu(&self) -> &str {
        ""
    }

    pub fn demander_relecture(&mut self) {
        if let Some(s) = self.etat.take() {
            self.etat = Some(s.demander_relecture())
        }
    }

    pub fn approuver(&mut self) {
        if let Some(s) = self.etat.take() {
            self.etat = Some(s.approuver())
        }
    }
}

trait Etat {
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat>;
    fn approuver(self: Box<Self>) -> Box<dyn Etat>;
}

struct Brouillon {}

impl Etat for Brouillon {
    // -- partie masquée ici --
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat> {
        Box::new(EnRelecture {})
    }

    fn approuver(self: Box<Self>) -> Box<dyn Etat> {
        self
    }
}

struct EnRelecture {}

impl Etat for EnRelecture {
    // -- partie masquée ici --
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat> {
        self
    }

    fn approuver(self: Box<Self>) -> Box<dyn Etat> {
        Box::new(Publier {})
    }
}

struct Publier {}

impl Etat for Publier {
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat> {
        self
    }

    fn approuver(self: Box<Self>) -> Box<dyn Etat> {
        self
    }
}

Encart 17-16 : implémentation de la méthode approuver sur Billet et sur le trait Etat

Nous avons ajouté la méthode approuver au trait Etat et ajouté une nouvelle structure Publier, qui implémente Etat.

Comme pour la façon de fonctionner de demander_relecture sur EnRelecture, si nous faisons appel à la méthode approuver sur un Brouillon, cela n'aura pas d'effet car approuver va retourner self. Lorsque nous appellerons approuver sur EnRelecture, elle va retourner une nouvelle instance de la structure Publier dans une instance de Box. La structure Publier implémente le trait Etat, et pour chacune des méthodes demander_relecture et approuver, elle va retourner elle-même, car le billet doit rester à l'état Publier dans ce cas-là.

Nous devons maintenant modifier la méthode contenu sur Billet. Nous souhaitons que la valeur retournée par contenu dépende de l'état actuel du Billet, donc nous allons faire en sorte que le Billet délègue sa logique à une méthode contenu défini sur son etat, comme dans l'encart 17-17 :

Fichier : src/lib.rs

pub struct Billet {
    etat: Option<Box<dyn Etat>>,
    contenu: String,
}

impl Billet {
    // -- partie masquée ici --
    pub fn new() -> Billet {
        Billet {
            etat: Some(Box::new(Brouillon {})),
            contenu: String::new(),
        }
    }

    pub fn ajouter_texte(&mut self, texte: &str) {
        self.contenu.push_str(texte);
    }

    pub fn contenu(&self) -> &str {
        self.etat.as_ref().unwrap().contenu(self)
    }
    // -- partie masquée ici --

    pub fn demander_relecture(&mut self) {
        if let Some(s) = self.etat.take() {
            self.etat = Some(s.demander_relecture())
        }
    }

    pub fn approuver(&mut self) {
        if let Some(s) = self.etat.take() {
            self.etat = Some(s.approuver())
        }
    }
}

trait Etat {
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat>;
    fn approuver(self: Box<Self>) -> Box<dyn Etat>;
}

struct Brouillon {}

impl Etat for Brouillon {
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat> {
        Box::new(EnRelecture {})
    }

    fn approuver(self: Box<Self>) -> Box<dyn Etat> {
        self
    }
}

struct EnRelecture {}

impl Etat for EnRelecture {
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat> {
        self
    }

    fn approuver(self: Box<Self>) -> Box<dyn Etat> {
        Box::new(Publier {})
    }
}

struct Publier {}

impl Etat for Publier {
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat> {
        self
    }

    fn approuver(self: Box<Self>) -> Box<dyn Etat> {
        self
    }
}

Encart 17-17 : correction de la méthode contenu de Billet afin qu'elle délègue à la méthode contenu de Etat

Comme notre but est de conserver toutes ces règles dans les structures qui implémentent Etat, nous appelons une méthode contenu sur la valeur de etat et nous lui passons en argument l'instance du billet (avec le self). Nous retournons ensuite la valeur retournée par la méthode contenu sur la valeur de etat.

Nous faisons appel à la méthode as_ref sur Option car nous voulons une référence vers la valeur dans Option plutôt que d'en prendre possession. Comme etat est un Option<Box<dyn Etat>>, lorsque nous faisons appel à as_ref, une Option<&Box<dyn Etat>> est retournée. Si nous n'avions pas fait appel à as_ref, nous aurions obtenu une erreur car nous ne pouvons pas déplacer etat de &self, lui-même est emprunté et provenant des paramètres de la fonction.

Nous faisons ensuite appel à la méthode unwrap, mais nous savons qu'elle ne va jamais paniquer, car nous savons que les méthodes sur Billet vont garantir que etat contiendra toujours une valeur Some lorsqu'elles seront utilisées. C'est un des cas dont nous avons parlé dans une section du chapitre 9 lorsque nous savions qu'une valeur None ne serait jamais possible, même si le compilateur n'est pas capable de le comprendre.

A partir de là, lorsque nous faisons appel à contenu sur &Box<dyn Etat>, l'extrapolation de déréférencement va s'appliquer sur le & et le Box pour que la méthode contenu puisse finalement être appelée sur le type qui implémente le trait Etat. Cela signifie que nous devons ajouter contenu à la définition du trait Etat, et que c'est ici que nous allons placer la logique pour le contenu à retourner en fonction de l'état nous avons, comme le montre l'encart 17-18 :

Fichier : src/lib.rs

pub struct Billet {
    etat: Option<Box<dyn Etat>>,
    contenu: String,
}

impl Billet {
    pub fn new() -> Billet {
        Billet {
            etat: Some(Box::new(Brouillon {})),
            contenu: String::new(),
        }
    }

    pub fn ajouter_texte(&mut self, texte: &str) {
        self.contenu.push_str(texte);
    }

    pub fn contenu(&self) -> &str {
        self.etat.as_ref().unwrap().contenu(self)
    }

    pub fn demander_relecture(&mut self) {
        if let Some(s) = self.etat.take() {
            self.etat = Some(s.demander_relecture())
        }
    }

    pub fn approuver(&mut self) {
        if let Some(s) = self.etat.take() {
            self.etat = Some(s.approuver())
        }
    }
}

trait Etat {
    // -- partie masquée ici --
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat>;
    fn approuver(self: Box<Self>) -> Box<dyn Etat>;

    fn contenu<'a>(&self, billet: &'a Billet) -> &'a str {
        ""
    }
}

// -- partie masquée ici --

struct Brouillon {}

impl Etat for Brouillon {
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat> {
        Box::new(EnRelecture {})
    }

    fn approuver(self: Box<Self>) -> Box<dyn Etat> {
        self
    }
}

struct EnRelecture {}

impl Etat for EnRelecture {
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat> {
        self
    }

    fn approuver(self: Box<Self>) -> Box<dyn Etat> {
        Box::new(Publier {})
    }
}

struct Publier {}

impl Etat for Publier {
    // -- partie masquée ici --
    fn demander_relecture(self: Box<Self>) -> Box<dyn Etat> {
        self
    }

    fn approuver(self: Box<Self>) -> Box<dyn Etat> {
        self
    }

    fn contenu<'a>(&self, billet: &'a Billet) -> &'a str {
        &billet.contenu
    }
}

Encart 17-18 : ajout de la méthode contenu sur le trait Etat

Nous avons ajouté une implémentation par défaut pour la méthode contenu qui retourne une slice de chaîne de caractères vide. Cela nous permet de ne pas avoir à implémenter contenu sur les structures Brouillon et EnRelecture. La structure Publier va remplacer la méthode contenu et retourner la valeur présente dans billet.contenu.

Remarquez aussi que nous devons annoter des durées de vie sur cette méthode, comme nous l'avons vu au chapitre 10. Nous allons prendre en argument une référence au billet et retourner une référence à une partie de ce billet, donc la durée de vie retournée par la référence est liée à la durée de vie de l'argument billet.

Et nous avons maintenant terminé, tout le code de l'encart 17-11 fonctionne désormais ! Nous avons implémenté le patron état avec les règles de notre processus de publication définies pour notre blog. La logique des règles est intégrée dans les objets état plutôt que d'être dispersée un peu partout dans Billet.

Les inconvénients du patron état

Nous avons démontré que Rust est capable d'implémenter le patron état qui est orienté objet pour regrouper les différents types de comportement qu'un billet doit avoir à chaque état. Les méthodes sur Billet ne savent rien des différents comportements. De la manière dont nous avons organisé le code, nous n'avons qu'à regarder à un seul endroit pour connaître les différents comportements qu'un billet publié va suivre : l'implémentation du trait Etat sur la structure Publier.

Si nous avions utilisé une autre façon d'implémenter ces règles sans utiliser le patron état, nous aurions dû utiliser des expressions match dans les méthodes de Billet ou même dans le code du main qui vérifie l'état du billet et les comportements associés aux changements d'états. Cela aurait eu pour conséquence d'avoir à regarder à différents endroits pour comprendre toutes les conséquences de la publication d'un billet ! Et ce code grossirait au fur et à mesure que nous ajouterions des états : chaque expression match devrait avoir des nouvelles branches pour ces nouveaux états.

Avec le patron état, les méthodes de Billet et les endroits où nous utilisons Billet n'ont pas besoin d'expressions match, et pour ajouter un nouvel état, nous avons seulement besoin d'ajouter une nouvelle structure et d'implémenter les méthodes du trait sur cette structure.

L'implémentation qui utilise le patron état est facile à améliorer pour ajouter plus de fonctionnalités. Pour découvrir la simplicité de maintenance du code qui utilise le patron état, essayez d'accomplir certaines de ces suggestions :

  • Ajouter une méthode rejeter qui fait retourner l'état d'un billet de EnRelecture à Brouillon.
  • Attendre deux appels à approuver avant que l'état puisse être changé en Publier.
  • Permettre aux utilisateurs d'ajouter du contenu textuel uniquement lorsqu'un billet est à l'état Brouillon. Indice : rendre l'objet état responsable de ce qui peut changer dans le contenu mais pas responsable de la modification de Billet.

Un inconvénient du patron état est que comme les états implémentent les transitions entre les états, certains des états sont couplés entre eux. Si nous ajoutons un nouvel état entre EnRelecture et Publier, Planifier par exemple, nous devrons alors changer le code dans EnRelecture pour qu'il passe ensuite à l'état Planifier au lieu de Publier. Cela représenterait moins de travail si EnRelecture n'avait pas besoin de changer lorsqu'on ajoute un nouvel état, mais cela signifierait alors qu'il faudrait changer de patron.

Un autre inconvénient est que nous avons de la logique en double. Pour éviter ces doublons, nous devrions essayer de faire en sorte que les méthodes demander_relecture et approuver qui retournent self deviennent les implémentations par défaut sur le trait Etat ; cependant, cela violerait la sûreté des objets, car le trait ne sait pas ce qu'est exactement self. Nous voulons pouvoir utiliser Etat en tant qu'objet trait, donc nous avons besoin que ses méthodes soient sûres pour les objets.

Nous avons aussi des doublons dans le code des méthodes demander_relecture et approuver sur Billet. Ces deux méthodes délèguent leur travail à la même méthode de la valeur du champ etat de type Option et assignent la nouvelle valeur du même champ etat à la fin. Si nous avions beaucoup de méthodes sur Billet qui suivaient cette logique, nous devrions envisager de définir une macro pour éviter cette répétition (voir la section dédiée dans le chapitre 19).

En implémentant le patron état exactement comme il est défini pour les langages orientés-objet, nous ne profitons pas pleinement des avantages de Rust. Voyons voir si nous pouvons faire quelques changements pour que la crate blog puisse lever des erreurs dès la compilation lorsqu'elle aura détecté des états ou des transitions invalides.

Implémenter les états et les comportements avec des types

Nous allons vous montrer comment repenser le patron état pour qu'il offre des compromis différents. Plutôt que d'encapsuler complètement les états et les transitions, faisant que le code externe ne puissent pas les connaître, nous allons coder ces états sous forme de différents types. En conséquence, le système de vérification de type de Rust va empêcher toute tentative d'utilisation des brouillons de billets là où seuls des billets publiés sont autorisés, en provoquant une erreur de compilation.

Considérons la première partie du main de l'encart 17-11 :

Fichier : src/main.rs

use blog::Billet;

fn main() {
    let mut billet = Billet::new();

    billet.ajouter_texte("J'ai mangé une salade au déjeuner aujourd'hui");
    assert_eq!("", billet.contenu());

    billet.demander_relecture();
    assert_eq!("", billet.contenu());

    billet.approuver();
    assert_eq!("J'ai mangé une salade au déjeuner aujourd'hui", billet.contenu());
}

Nous pouvons toujours créer de nouveaux billets à l'état de brouillon en utilisant Billet::new et ajouter du texte au contenu du billet. Mais au lieu d'avoir une méthode contenu sur un brouillon de billet qui retourne une chaîne de caractères vide, nous faisons en sorte que les brouillons de billets n'aient même pas de méthode contenu. Ainsi, si nous essayons de récupérer le contenu d'un brouillon de billet, nous obtenons une erreur de compilation qui nous informera que la méthode n'existe pas. Finalement, il nous sera impossible de publier le contenu d'un brouillon de billet en production, car ce code ne se compilera même pas. L'encart 17-19 nous propose les définitions d'une structure Billet et d'une structure BrouillonDeBillet ainsi que leurs méthodes :

Fichier : src/lib.rs

pub struct Billet {
    contenu: String,
}

pub struct BrouillonDeBillet {
    contenu: String,
}

impl Billet {
    pub fn new() -> BrouillonDeBillet {
        BrouillonDeBillet {
            contenu: String::new(),
        }
    }

    pub fn contenu(&self) -> &str {
        &self.contenu
    }
}

impl BrouillonDeBillet {
    pub fn ajouter_texte(&mut self, texte: &str) {
        self.contenu.push_str(texte);
    }
}

Encart 17-19 : un Billet avec une méthode contenu et un BrouillonDeBillet sans méthode contenu

Les deux structures Billet et BrouillonDeBillet ont un champ privé contenu qui stocke le texte du billet de blog. Les structures n'ont plus le champ etat car nous avons déplacé la signification de l'état directement dans le nom de ces types de structures. La structure Billet représente un billet publié et possède une méthode contenu qui retourne le contenu.

Nous avons toujours la fonction Billet::new, mais au lieu de retourner une instance de Billet, elle va retourner une instance de BrouillonDeBillet. Comme contenu est privé et qu'il n'y a pas de fonction qui retourne Billet, il ne sera pas possible pour le moment de créer une instance de Billet.

La structure BrouillonDeBillet a une méthode ajouter_texte, donc nous pouvons ajouter du texte à contenu comme nous le faisions avant, mais remarquez toutefois que BrouillonDeBillet n'a pas de méthode contenu de définie ! Donc pour l'instant le programme s'assure que tous les billets démarrent à l'état de brouillon et que les brouillons ne proposent pas de contenu à publier. Toute tentative d'outre-passer ces contraintes va déclencher une erreur de compilation.

Implémenter les changements d'état en tant que changement de type

Donc, comment publier un billet ? Nous voulons renforcer la règle qui dit qu'un brouillon de billet doit être relu et approuvé avant de pouvoir être publié. Un billet à l'état de relecture doit continuer à ne pas montrer son contenu. Implémentons ces contraintes en introduisant une nouvelle structure, BilletEnRelecture, en définissant la méthode demander_relecture sur BrouillonDeBillet retournant un BilletEnRelecture, et en définissant une méthode approuver sur BilletEnRelecture pour qu'elle retourne un Billet, comme le propose l'encart 17-20 :

Fichier : src/lib.rs

pub struct Billet {
    contenu: String,
}

pub struct BrouillonDeBillet {
    contenu: String,
}

impl Billet {
    pub fn new() -> BrouillonDeBillet {
        BrouillonDeBillet {
            contenu: String::new(),
        }
    }

    pub fn contenu(&self) -> &str {
        &self.contenu
    }
}

impl BrouillonDeBillet {
    // -- partie masquée ici --
    pub fn ajouter_texte(&mut self, texte: &str) {
        self.contenu.push_str(texte);
    }

    pub fn demander_relecture(self) -> BilletEnRelecture {
        BilletEnRelecture {
            contenu: self.contenu,
        }
    }
}

pub struct BilletEnRelecture {
    contenu: String,
}

impl BilletEnRelecture {
    pub fn approuver(self) -> Billet {
        Billet {
            contenu: self.contenu,
        }
    }
}

Encart 17-20 : ajout d'un BilletEnRelecture qui est créé par l'appel à demander_relecture sur BrouillonDeBillet, ainsi qu'une méthode approuver qui transforme un BilletEnRelecture en Billet publié

Les méthodes demander_relecture et approuver prennent possession de self, ce qui consomme les instances de BrouillonDeBillet et de BilletEnRelecture pour les transformer respectivement en BilletEnRelecture et en Billet. Ainsi, il ne restera plus d'instances de BrouillonDeBillet après avoir appelé approuver sur elles, et ainsi de suite. La structure BilletEnRelecture n'a pas de méthode contenu qui lui est définie, donc si on essaye de lire son contenu, on obtient une erreur de compilation, comme avec BrouillonDeBillet. Comme la seule manière d'obtenir une instance de Billet qui a une méthode contenu de définie est d'appeler la méthodeapprouver sur un BilletEnRelecture, et que la seule manière d'obtenir un BilletEnRelecture est d'appeler la méthode demander_relecture sur un BrouillonDeBillet, nous avons désormais intégré le processus de publication des billets de blog avec le système de type.

Mais nous devons aussi faire quelques petits changements dans le main. Les méthodes demander_relecture et approuver retournent des nouvelles instances au lieu de modifier la structure sur laquelle elles ont été appelées, donc nous devons ajouter des assignations de masquage let billet = pour stocker les nouvelles instances retournées. Nous ne pouvons pas non plus vérifier que le contenu des brouillons de billets et de ceux en cours de relecture sont bien vides, donc nous n'avons plus besoin des vérifications associées : en effet, nous ne pouvons plus compiler du code qui essaye d'utiliser le contenu d'un billet dans ces états. Le code du main mis à jour est présenté dans l'encart 17-21 :

Fichier : src/main.rs

use blog::Billet;

fn main() {
    let mut billet = Billet::new();

    billet.ajouter_texte("J'ai mangé une salade au déjeuner aujourd'hui");

    let billet = billet.demander_relecture();

    let billet = billet.approuver();

    assert_eq!("J'ai mangé une salade au déjeuner aujourd'hui", billet.contenu());
}

Encart 17-21 : modification de main pour utiliser la nouvelle implémentation du processus de publication de billet de blog

Les modifications que nous avons eu besoin de faire à main pour réassigner billet impliquent que cette implémentation ne suit plus exactement le patron état orienté-objet : les changements d'états ne sont plus totalement intégrés dans l'implémentation de Billet. Cependant, nous avons obtenu que les états invalides sont désormais impossibles grâce au système de types et à la vérification de type qui s'effectue à la compilation ! Cela garantit que certains bogues, comme l'affichage du contenu d'un billet non publié, seront détectés avant d'arriver en production.

Essayez d'implémenter les exigences fonctionnelles supplémentaires suggérées dans la liste présente au début de cette section, sur la crate blog dans l'état où elle était après l'encart 17-20, afin de vous faire une idée sur cette façon de concevoir le code. Notez aussi que certaines de ces exigences pourraient déjà être implémentées implicitement du fait de cette conception.

Nous avons vu que même si Rust est capable d'implémenter des patrons de conception orientés-objet, d'autres patrons, tel qu'intégrer l'état dans le système de type, sont également possibles en Rust. Ces patrons présentent différents avantages et inconvénients. Bien que vous puissiez être très familier avec les patrons orientés-objet, vous gagnerez à repenser les choses pour tirer avantage des fonctionnalités de Rust, telles que la détection de certains bogues à la compilation. Les patrons orientés-objet ne sont pas toujours la meilleure solution en Rust à cause de certaines de ses fonctionnalités, comme la possession, que les langages orientés-objet n'ont pas.

Résumé

Que vous pensiez ou non que Rust est un langage orienté-objet après avoir lu ce chapitre, vous savez maintenant que vous pouvez utiliser les objets trait pour pouvoir obtenir certaines fonctionnalités orienté-objet en Rust. La répartition dynamique peut offrir de la flexibilité à votre code en échange d'une perte de performances à l'exécution. Vous pouvez utiliser cette flexibilité pour implémenter des patrons orientés-objet qui facilitent la maintenance de votre code. Rust offre d'autres fonctionnalités, comme la possession, que les langages orientés-objet n'ont pas. L'utilisation d'un patron orienté-objet n'est pas toujours la meilleure manière de tirer parti des avantages de Rust, mais cela reste une option disponible.

Dans le chapitre suivant, nous allons étudier les motifs, qui constituent une autre des fonctionnalités de Rust et apportent beaucoup de flexibilité. Nous les avons abordés brièvement dans le livre, mais nous n'avons pas encore vu tout leur potentiel. C'est parti !