Les caractéristiques des langages orientés objet
Il n’y a pas de consensus dans la communauté de la programmation sur les fonctionnalités qu’un langage doit posséder pour être considéré comme orienté objet. Rust est influencé par de nombreux paradigmes de programmation, y compris la POO ; par exemple, nous avons exploré les fonctionnalités issues de la programmation fonctionnelle au chapitre 13. On peut argumenter que les langages POO partagent certaines caractéristiques communes — à savoir les objets, l’encapsulation et l’héritage. Voyons ce que chacune de ces caractéristiques signifie et si Rust la prend en charge.
Les objets contiennent des données et du comportement
Le livre Design Patterns: Éléments of Reusable Object-Oriented Software d’Erich Gamma, Richard Helm, Ralph Johnson et John Vlissides (Addison-Wesley, 1994), communément appelé le livre du Gang of Four (GoF), est un catalogue de patrons de conception orientés objet. Il définit la POO de cette manière :
Les programmes orientés objet sont composés d’objets. Un objet regroupe à la fois les données et les procédures qui opèrent sur ces données. Les procédures sont généralement appelées méthodes ou opérations.
Selon cette définition, Rust est orienté objet : les structs et les enums contiennent des données, et les blocs impl fournissent des méthodes sur les structs et les enums. Même si les structs et les enums avec des méthodes ne sont pas appelés objets, ils fournissent la même fonctionnalité, selon la définition des objets du Gang of Four.
L’encapsulation qui masque les détails d’implémentation
Un autre aspect couramment associé à 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. Par conséquent, la seule façon d’interagir avec un objet est à travers son API publique ; le code utilisant l’objet ne devrait pas pouvoir accéder aux composants internes de l’objet et modifier directement les données ou le comportement. Cela permet au programmeur de modifier et refactoriser les composants internes d’un objet sans avoir besoin de modifier le code qui utilise l’objet.
Nous avons discuté de la façon de contrôler l’encapsulation au chapitre 7 : nous pouvons utiliser le mot-clé pub pour décider quels modules, types, fonctions et méthodes de notre code doivent être publics, et par défaut tout le reste est privé. Par exemple, nous pouvons définir une struct AveragedCollection qui à un champ contenant un vecteur de valeurs i32. La struct peut aussi avoir un champ qui contient la moyenne des valeurs du vecteur, ce qui signifie que la moyenne n’a pas besoin d’être calculée à la demande chaque fois que quelqu’un en a besoin. En d’autres termes, AveragedCollection mettra en cache la moyenne calculée pour nous. L’encart 18-1 contient la définition de la struct AveragedCollection.
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
AveragedCollection struct that maintains a list of integers and the average of the items in the collectionLa struct est marquée pub pour que d’autre code puisse l’utiliser, mais les champs au sein de la struct restent privés. C’est important dans ce cas car nous voulons nous assurer que chaque fois qu’une valeur est ajoutée ou retirée de la liste, la moyenne est également mise à jour. Nous faisons cela en implémentant les méthodes add, remove et average sur la struct, comme montré dans l’encart 18-2.
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
impl AveragedCollection {
pub fn add(&mut self, value: i32) {
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<i32> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
}
None => None,
}
}
pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
}
add, remove, and average on AveragedCollectionLes méthodes publiques add, remove et average sont les seules façons d’accéder ou de modifier les données dans une instance de AveragedCollection. Quand un élément est ajouté à list en utilisant la méthode add ou retiré en utilisant la méthode remove, les implémentations de chacune appellent la méthode privée update_average qui gère aussi la mise à jour du champ average.
Nous laissons les champs list et average privés pour qu’il n’y ait aucun moyen pour du code externe d’ajouter ou de retirer des éléments du champ list directement ; sinon, le champ average pourrait se désynchroniser quand list change. La méthode average retourné la valeur du champ average, permettant au code externe de lire la moyenne mais pas de la modifier.
Comme nous avons encapsulé les détails d’implémentation de la struct AveragedCollection, nous pouvons facilement changer des aspects, comme la structure de données, à l’avenir. Par exemple, nous pourrions utiliser un HashSet<i32> au lieu d’un Vec<i32> pour le champ list. Tant que les signatures des méthodes publiques add, remove et average restent les mêmes, le code utilisant AveragedCollection n’aurait pas besoin de changer. Si nous avions rendu list public à la place, ce ne serait pas nécessairement le cas : HashSet<i32> et Vec<i32> ont des méthodes différentes pour ajouter et retirer des éléments, donc le code externe devrait probablement changer s’il modifiait list directement.
Si l’encapsulation est un aspect requis pour qu’un langage soit considéré comme orienté objet, alors Rust satisfait cette exigence. L’option d’utiliser pub ou non pour différentes parties du code permet l’encapsulation des détails d’implémentation.
L’héritage comme système de types et comme partage de code
L’héritage est un mécanisme par lequel un objet peut hériter d’éléments de la définition d’un autre objet, obtenant ainsi les données et le comportement de l’objet parent sans avoir à les redéfinir.
Si un langage doit avoir l’héritage pour être orienté objet, alors Rust n’est pas un tel langage. Il n’y à aucun moyen de définir une struct qui hérite des champs et des implémentations de méthodes de la struct parente sans utiliser une macro.
Cependant, si vous êtes habitué à avoir l’héritage dans votre boîte à outils de programmation, vous pouvez utiliser d’autres solutions en Rust, selon la raison pour laquelle vous avez recours à l’héritage en premier lieu.
Vous choisiriez l’héritage pour deux raisons principales. La première est la réutilisation du code : vous pouvez implémenter un comportement particulier pour un type, et l’héritage vous permet de réutiliser cette implémentation pour un type différent. Vous pouvez faire cela de manière limitée en Rust en utilisant les implémentations par défaut des méthodes de trait, ce que vous avez vu dans l’encart 10-14 quand nous avons ajouté une implémentation par défaut de la méthode summarize sur le trait Summary. Tout type implémentant le trait Summary aurait la méthode summarize disponible sans code supplémentaire. C’est similaire à une classe parente ayant une implémentation d’une méthode et une classe enfant héritante ayant aussi l’implémentation de la méthode. Nous pouvons aussi surcharger l’implémentation par défaut de la méthode summarize quand nous implémentons le trait Summary, ce qui est similaire à une classe enfant surchargeant l’implémentation d’une méthode héritée d’une classe parente.
L’autre raison d’utiliser l’héritage est liée au système de types : permettre à un type enfant d’être utilisé aux mêmes endroits que le type parent. Cela s’appelle aussi le polymorphisme, ce qui signifie que vous pouvez substituer plusieurs objets les uns aux autres à l’exécution s’ils partagent certaines caractéristiques.
Polymorphisme
Pour beaucoup de gens, le polymorphisme est synonyme d’héritage. Mais c’est en réalité un concept plus général qui fait référence à du code pouvant travailler avec des données de plusieurs types. Pour l’héritage, ces types sont généralement des sous-classes.
Rust utilise plutôt les génériques pour abstraire les différents types possibles et les contraintes de trait pour imposer des contraintes sur ce que ces types doivent fournir. Cela s’appelle parfois le polymorphisme paramétrique borné.
Rust a choisi un ensemble différent de compromis en ne proposant pas l’héritage. L’héritage risque souvent de partager plus de code que nécessaire. Les sous-classes ne devraient pas toujours partager toutes les caractéristiques de leur classe parente, mais elles le font avec l’héritage. Cela peut rendre la conception d’un programme moins flexible. Cela introduit aussi la possibilité d’appeler des méthodes sur des sous-classes qui n’ont pas de sens ou qui causent des erreurs parce que les méthodes ne s’appliquent pas à la sous-classe. De plus, certains langages ne permettent que l’héritage simple (ce qui signifie qu’une sous-classe ne peut hériter que d’une seule classe), limitant encore davantage la flexibilité de la conception d’un programme.
Pour ces raisons, Rust adopte l’approche différente d’utiliser des objets trait au lieu de l’héritage pour obtenir le polymorphisme à l’exécution. Voyons comment les objets trait fonctionnent.