Les caractéristiques des langages orientés objet

Les développeurs ne se sont jamais entendus sur les fonctionnalités qu'un langage doit avoir pour être considéré orienté objet. Rust est influencé par de nombreux paradigmes de programmation, y compris la POO ; par exemple, nous avons examiné les fonctionnalités issues de la programmation fonctionnelle au chapitre 13. On peut vraisemblablement dire que les langages orientés objet ont plusieurs caractéristiques en commun, comme les objets, l'encapsulation et l'héritage. Examinons chacune de ces caractéristiques et regardons si Rust les supporte.

Les objets contiennent des données et suivent un comportement

Le livre Design Patterns: Elements of Reusable Object-Oriented Software d'Erich Gamma, Richard Helm, Ralph Johnson et John Vlissides (Addison-Wesley Professional, 1994) que l'on surnomme le livre du Gang of Four est un catalogue de patrons de conception orientés objet. Il définit la POO ainsi :

Les programmes orientés objet sont constitués d'objets. Un objet regroupe des données ainsi que les procédures qui opèrent sur ces données. Ces procédures sont typiquement appelées méthodes ou opérations.

Si l'on s'en tient à cette définition, Rust est orienté objet : les structures et les énumérations ont des données et les blocs impl leur fournissent des méthodes. Bien que les structures et les énumérations dotées de méthodes ne soient pas qualifiées d'objets, elles en ont les fonctionnalités selon la définition des objets faite par le Gang of Four.

L'encapsulation qui masque les détails d'implémentation

Un autre aspect qu'on associe souvent à la POO est l'idée d'encapsulation, ce qui signifie que les détails d'implémentation d'un objet ne sont pas accessibles au code utilisant cet objet. Ainsi, la seule façon d'interagir avec un objet est via son API publique ; le code qui utilise l'objet ne devrait pas pouvoir accéder aux éléments internes d'un objet et changer directement ses données ou son comportement. Cela permet au développeur de changer et remanier les éléments internes d'un objet sans avoir à changer le code qui utilise cet objet.

Nous avons abordé la façon de contrôler l'encapsulation au chapitre 7 : on peut utiliser le mot-clé pub pour décider quels modules, types, fonctions et méthodes de notre code devraient être publics ; par défaut, tout le reste est privé. Par exemple, nous pouvons définir une structure CollectionMoyennee qui a un champ contenant un vecteur de valeurs i32. La structure peut aussi avoir un champ qui contient la moyenne des valeurs dans le vecteur de sorte qu'il ne soit pas nécessaire de recalculer la moyenne à chaque fois que quelqu'un en a besoin. En d'autres termes, CollectionMoyennee va mettre en cache la moyenne calculée pour nous. L'encart 17-1 contient la définition de la structure CollectionMoyennee :

Fichier : src/lib.rs

pub struct CollectionMoyennee {
    liste: Vec<i32>,
    moyenne: f64,
}

Encart 17-1 : Une structure CollectionMoyennee qui contient une liste d'entiers et la moyenne des éléments de la collection

La structure est marquée pub de façon à ce qu'elle puisse être utilisée par du code externe, mais les champs au sein de la structure restent privés. C'est important dans ce cas puisque nous voulons nous assurer que lorsqu'une valeur est ajoutée ou retirée dans la liste, la moyenne soit aussi mise à jour. Nous le faisons en implémentant les méthodes ajouter, retirer et moyenne sur la structure, comme le montre l'encart 17-2 :

Fichier : src/lib.rs

pub struct CollectionMoyennee {
    liste: Vec<i32>,
    moyenne: f64,
}

impl CollectionMoyennee {
    pub fn ajouter(&mut self, valeur: i32) {
        self.liste.push(valeur);
        self.mettre_a_jour_moyenne();
    }

    pub fn retirer(&mut self) -> Option<i32> {
        let resultat = self.liste.pop();
        match resultat {
            Some(valeur) => {
                self.mettre_a_jour_moyenne();
                Some(valeur)
            }
            None => None,
        }
    }

    pub fn moyenne(&self) -> f64 {
        self.moyenne
    }

    fn mettre_a_jour_moyenne(&mut self) {
        let total: i32 = self.liste.iter().sum();
        self.moyenne = total as f64 / self.liste.len() as f64;
    }
}

Encart 17-2 : Implémentations des méthodes publiques ajouter, retirer et moyenne sur CollectionMoyennee

Les méthodes publiques ajouter, retirer et moyenne sont les seules façons d'accéder ou de modifier les données d'une instance de CollectionMoyennee. Lorsqu'un élément est ajouté à liste en utilisant la méthode ajouter ou retiré en utilisant la méthode retirer, l'implémentation de chacune de ces méthodes appelle la méthode privée mettre_a_jour_moyenne qui met à jour le champ moyenne également.

Nous laissons les champs liste et moyenne privés pour qu'il soit impossible pour du code externe d'ajouter ou de retirer des éléments dans notre champ liste directement ; sinon, le champ moyenne pourrait ne plus être synchronisé lorsque la liste change. La méthode moyenne renvoie la valeur du champ moyenne, ce qui permet au code externe de lire le champ moyenne mais pas de le modifier.

Puisque nous avons encapsulé les détails d'implémentation de la structure CollectionMoyennee, nous pourrons aisément en changer plus tard quelques aspects, tels que la structure de données. Par exemple, nous pourrions utiliser un HashSet<i32> plutôt qu'un Vec<i32> pour le champ liste. Du moment que les signatures des méthodes publiques ajouter, retirer et moyenne restent les mêmes, du code qui utilise CollectionMoyennee n'aurait pas besoin de changer. En revanche, si nous avions fait en sorte que liste soit publique, cela n'aurait pas été forcément le cas : HashSet<i32> et Vec<i32> ont des méthodes différentes pour ajouter et retirer des éléments, donc il aurait vraisemblablement fallu changer le code externe s'il modifiait directement liste.

Si l'encapsulation est une condition nécessaire pour qu'un langage soit considéré orienté objet, alors Rust satisfait cette condition. La possibilité d'utiliser pub ou non pour différentes parties de notre code permet d'encapsuler les détails d'implémentation.

L'héritage comme système de type et comme partage de code

L'héritage est un mécanisme selon lequel un objet peut hériter de la définition d'un autre objet, acquérant ainsi les données et le comportement de l'objet père sans que l'on ait besoin de les redéfinir.

Si un langage doit avoir de l'héritage pour être un langage orienté objet, alors Rust n'en est pas un. Il est impossible de définir une structure qui hérite des champs et de l'implémentation des méthodes de la structure mère. Cependant, si vous avez l'habitude d'utiliser l'héritage dans vos programmes, vous pouvez utiliser d'autres solutions en Rust, en fonction de la raison qui vous a conduit en premier lieu à vous tourner vers l'héritage.

Il y a deux principales raisons de choisir l'héritage. La première raison est la réutilisation de code : vous pouvez implémenter un comportement particulier pour un type, et l'héritage vous permet de réutiliser cette implémentation sur un autre type. À la place, vous pouvez partager du code Rust en utilisant des implémentations de méthodes de trait par défaut, comme nous l'avons vu dans l'encart 10-14 lorsque nous avons ajouté une implémentation par défaut de la méthode resumer sur le trait Resumable. La méthode resumer serait alors disponible sur tout type implémentant le trait Resumable sans avoir besoin de rajouter du code. C'est comme si vous aviez une classe mère avec l'implémentation d'une méthode et une classe fille avec une autre implémentation de cette méthode. On peut aussi remplacer l'implémentation par défaut de la méthode resumer quand on implémente le trait Resumable, un peu comme une classe fille qui remplace l'implémentation d'une méthode héritée d'une classe mère.

L'autre raison d'utiliser l'héritage concerne le système de types : pour permettre à un type fils d'être utilisé à la place d'un type père. Cela s'appelle le polymorphisme, ce qui veut dire qu'on peut substituer plusieurs objets entre eux à l'exécution s'ils partagent certaines caractéristiques.

Polymorphisme

Pour beaucoup de gens, le polymorphisme est synonyme d'héritage. Mais il s'agit en fait d'un principe plus général qui se rapporte au code manipulant des données de divers types. Pour l'héritage, ces types sont généralement des classes filles (ou sous-classes).

À la place, Rust utilise la généricité pour construire des abstractions des différents types et traits liés possibles pour imposer des contraintes sur ce que ces types doivent fournir. Cela est parfois appelé polymorphisme paramétrique borné.

L'héritage est récemment tombé en disgrâce en tant que solution de conception dans plusieurs langages de programmation parce qu'il conduit souvent à partager plus de code que nécessaire. Les classes mères ne devraient pas toujours partager toutes leurs caractéristiques avec leurs classes filles, mais elles y sont obligées avec l'héritage. Cela peut rendre la conception d'un programme moins flexible. De plus, cela introduit la possibilité d'appeler des méthodes sur des classes filles qui n'ont aucun sens ou qui entraînent des erreurs parce que les méthodes ne s'appliquent pas à la classe fille. De plus, certains langages ne permettront à une classe fille d'hériter que d'une seule classe, ce qui restreint d'autant plus la flexibilité de la conception d'un programme.

Voilà pourquoi Rust suit une autre approche, en utilisant des objets traits plutôt que l'héritage. Jetons un œil à la façon dont les objets traits permettent le polymorphisme en Rust.