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,
}
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;
}
}
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.