Utiliser les objets traits qui permettent des valeurs de types différents
Au chapitre 8, nous avions mentionné qu'une limite des vecteurs est qu'ils ne
peuvent stocker des éléments que d'un seul type. Nous avions contourné le
problème dans l'encart 8-10 en définissant une énumération Cellule
avec des
variantes pouvant contenir des entiers, des flottants et du texte. Ainsi, on
pouvait stocker différents types de données dans chaque cellule et quand même
avoir un vecteur qui représentait une rangée de cellules. C'est une très bonne
solution quand nos éléments interchangeables ne possèdent qu'un ensemble bien
déterminé de types que nous connaissons lors de la compilation de notre code.
Cependant, nous avons parfois envie que l'utilisateur de notre bibliothèque
puisse étendre l'ensemble des types valides dans une situation donnée. Pour
montrer comment nous pourrions y parvenir, créons un exemple d'outil d'interface
graphique (GUI) qui itère sur une liste d'éléments et appelle une méthode
afficher
sur chacun d'entre eux pour l'afficher à l'écran — une technique
courante pour les outils d'interface graphique. Créons une crate de
bibliothèque appelée gui
qui contient la structure d'une bibliothèque
d'interface graphique. Cette crate pourrait inclure des types que les usagers
pourront utiliser, tels que Bouton
ou ChampDeTexte
. De plus, les
utilisateurs de gui
voudront créer leurs propres types qui pourront être
affichés : par exemple, un développeur pourrait ajouter une Image
et un autre
pourrait ajouter une ListeDeroulante
.
Nous n'implémenterons pas une véritable bibliothèque d'interface graphique pour
cet exemple, mais nous verrons comment les morceaux pourraient s'assembler. Au
moment d'écrire la bibliothèque, nous ne pouvons pas savoir ni définir tous les
types que les autres développeurs auraient envie de créer. Mais nous savons que
gui
doit gérer plusieurs valeurs de types différents et qu'elle
doit appeler la méthode afficher
sur chacune de ces valeurs de types
différents. Elle n'a pas besoin de savoir exactement ce qui arrivera quand on
appellera la méthode afficher
, mais seulement de savoir que la valeur
disposera de cette méthode que nous pourrons appeler.
Pour faire ceci dans un langage avec de l'héritage, nous pourrions définir une
classe Composant
qui a une méthode afficher
. Les autres
classes, telles que Bouton
, Image
et ListeDeroulante
hériteraient de
Composant
et hériteraient ainsi de la méthode afficher
. Elles pourraient
toutes redéfinir la méthode afficher
avec leur comportement personnalisé,
mais l'environnement de développement pourrait considérer tous les types comme
des instances de Composant
et appeler afficher
sur chacun d'entre eux. Mais
puisque Rust n'a pas d'héritage, il nous faut un autre moyen de structurer la
bibliothèque gui
pour permettre aux utilisateurs de l'enrichir avec de
nouveaux types.
Définir un trait pour du comportement commun
Pour implémenter le comportement que nous voulons donner à gui
, nous
définirons un trait nommé Affichable
qui aura une méthode nommée afficher
.
Puis nous définirons un vecteur qui prend un objet trait. Un objet trait
pointe à la fois vers une instance d'un type implémentant le trait indiqué ainsi
que vers une table utilisée pour chercher les méthodes de trait de ce type à
l'exécution. Nous créons un objet trait en indiquant une sorte de pointeur, tel
qu'une référence &
ou un pointeur intelligent Box<T>
, puis le mot-clé dyn
et enfin le trait en question. (Nous expliquerons pourquoi les objets traits
doivent utiliser un pointeur dans une section
du chapitre 19.) Nous pouvons
utiliser des objets traits à la place d'un type générique ou concret. Partout où
nous utilisons un objet trait, le système de types de Rust s'assurera à la
compilation que n'importe quelle valeur utilisée dans ce contexte implémentera
le trait de l'objet trait. Ainsi, il n'est pas nécessaire de connaître tous les
types possibles à la compilation.
Nous avons mentionné qu'en Rust, nous nous abstenons de qualifier les structures
et énumérations d'objets pour les distinguer des objets des autres langages.
Dans une structure ou une énumération, les données dans les champs de la
structure et le comportement dans les blocs impl
sont séparés, alors que dans
d'autres langages, les données et le comportement se combinent en un concept
souvent qualifié d'objet. En revanche, les objets traits ressemblent davantage
aux objets des autres langages dans le sens où ils combinent des données et du
comportement. Mais les objets traits diffèrent des objets traditionnels dans le
sens où on ne peut pas ajouter des données à un objet trait. Les objets traits
ne sont généralement pas aussi utiles que les objets des autres langages : leur
but spécifique est de permettre de construire des abstractions de comportements
communs.
L'encart 17-3 illustre la façon de définir un trait nommé Affichable
avec une
méthode nommée afficher
:
Fichier : src/lib.rs
pub trait Affichable {
fn afficher(&self);
}
Cette syntaxe devrait vous rappeler nos discussions sur comment définir des
traits au chapitre 10. Puis vient une nouvelle syntaxe : l'encart 17-4 définit
une structure Ecran
qui contient un vecteur composants
. Ce
vecteur est du type Box<dyn Affichable>
, qui est un objet trait ; c'est un
bouche-trou pour n'importe quel type au sein d'un Box
qui implémente le trait
Affichable
.
Fichier : src/lib.rs
pub trait Affichable {
fn afficher(&self);
}
pub struct Ecran {
pub composants: Vec<Box<dyn Affichable>>,
}
Sur la structure Ecran
, nous allons définir une méthode nommée executer
qui
appellera la méthode afficher
sur chacun de ses composants
, comme l'illustre
l'encart 17-5 :
Fichier : src/lib.rs
pub trait Affichable {
fn afficher(&self);
}
pub struct Ecran {
pub composants: Vec<Box<dyn Affichable>>,
}
impl Ecran {
pub fn executer(&self) {
for composant in self.composants.iter() {
composant.afficher();
}
}
}
Cela ne fonctionne pas de la même manière que d'utiliser une structure avec un
paramètre de type générique avec des traits liés. Un paramètre de type générique
ne peut être remplacé que par un seul type concret à la fois, tandis que les
objets traits permettent à plusieurs types concrets de remplacer l'objet trait à
l'exécution. Par exemple, nous aurions pu définir la structure Ecran
en
utilisant un type générique et un trait lié comme dans l'encart 17-6 :
Fichier : src/lib.rs
pub trait Affichable {
fn afficher(&self);
}
pub struct Ecran<T: Affichable> {
pub composants: Vec<T>,
}
impl<T> Ecran<T>
where
T: Affichable,
{
pub fn executer(&self) {
for composant in self.composants.iter() {
composant.afficher();
}
}
}
Cela nous restreint à une instance de Ecran
qui a une liste de composants qui
sont soit tous de type Bouton
, soit tous de type ChampDeTexte
. Si vous ne
voulez que des collections homogènes, il est préférable d'utiliser la généricité
et les traits liés parce que les définitions seront monomorphisées à la
compilation pour utiliser les types concrets.
D'un autre côté, en utilisant des objets traits, une instance de Ecran
peut
contenir un Vec<T>
qui contient à la fois un Box<Bouton>
et un
Box<ChampDeTexte>
. Regardons comment cela fonctionne, puis nous parlerons
ensuite du coût en performances à l'exécution.
Implémenter le trait
Ajoutons maintenant quelques types qui implémentent le trait Affichable
. Nous
fournirons le type Bouton
. Encore une fois, implémenter une vraie bibliothèque
d'interface graphique dépasse la portée de ce livre, alors la méthode afficher
n'aura pas d'implémentation utile dans son corps. Pour imaginer à quoi pourrait
ressembler l'implémentation, une structure Bouton
pourrait avoir des champs
largeur
, hauteur
et libelle
, comme l'illustre l'encart 17-7 :
Fichier : src/lib.rs
pub trait Affichable {
fn afficher(&self);
}
pub struct Ecran {
pub composants: Vec<Box<dyn Affichable>>,
}
impl Ecran {
pub fn executer(&self) {
for composant in self.composants.iter() {
composant.afficher();
}
}
}
pub struct Bouton {
pub largeur: u32,
pub hauteur: u32,
pub libelle: String,
}
impl Affichable for Bouton {
fn afficher(&self) {
// code servant à afficher vraiment un bouton
}
}
Les champs largeur
, hauteur
et libelle
de Bouton
pourront ne pas être
les mêmes que ceux d'autres composants, comme un type ChampDeTexte
, qui
pourrait avoir ces champs plus un champ texte_de_substitution
à la place.
Chacun des types que nous voudrons afficher à l'écran implémentera le trait
Affichable
mais utilisera du code différent dans la méthode afficher
pour
définir comment afficher ce type en particulier, comme c'est le cas de Bouton
ici (sans le vrai code d'implémentation, qui dépasse le cadre de ce chapitre).
Le type Bouton
, par exemple, pourrait avoir un bloc impl
supplémentaire
contenant des méthodes en lien à ce qui arrive quand un utilisateur clique sur
le bouton. Ce genre de méthodes ne s'applique pas à des types comme
ChampDeTexte
.
Si un utilisateur de notre bibliothèque décide d'implémenter une structure
ListeDeroulante
avec des champs largeur
, hauteur
et options
, il
implémentera également le trait Affichable
sur le type ListeDeroulante
,
comme dans l'encart 17-8 :
Fichier : src/main.rs
use gui::Affichable;
struct ListeDeroulante {
largeur: u32,
hauteur: u32,
options: Vec<String>,
}
impl Affichable for ListeDeroulante {
fn afficher(&self) {
// code servant à afficher vraiment une liste déroulante
}
}
fn main() {}
L'utilisateur de notre bibliothèque peut maintenant écrire sa fonction main
pour créer une instance de Ecran
. Il peut ajouter à l'instance de Ecran
une
ListeDeroulante
ou un Bouton
en les mettant chacun dans un Box<T>
pour en
faire des objets traits. Il peut ensuite appeler la méthode executer
sur
l'instance de Ecran
, qui appellera afficher
sur chacun de ses composants.
L'encart 17-9 montre cette implémentation :
Fichier : src/main.rs
use gui::Affichable;
struct ListeDeroulante {
largeur: u32,
hauteur: u32,
options: Vec<String>,
}
impl Affichable for ListeDeroulante {
fn afficher(&self) {
// code servant vraiment à afficher une liste déroulante
}
}
use gui::{Bouton, Ecran};
fn main() {
let ecran = Ecran {
composants: vec![
Box::new(ListeDeroulante {
largeur: 75,
hauteur: 10,
options: vec![
String::from("Oui"),
String::from("Peut-être"),
String::from("Non"),
],
}),
Box::new(Bouton {
largeur: 50,
hauteur: 10,
libelle: String::from("OK"),
}),
],
};
ecran.executer();
}
Quand nous avons écrit la bibliothèque, nous ne savions pas que quelqu'un
pourrait y ajouter le type ListeDeroulante
, mais notre implémentation de
Ecran
a pu opérer sur le nouveau type et l'afficher parce que
ListeDeroulante
implémente le trait Affichable
, ce qui veut dire qu'elle
implémente la méthode afficher
.
Ce concept — se préoccuper uniquement des messages auxquels une valeur répond
plutôt que du type concret de la valeur — est similaire au concept du duck
typing (“typage canard”) dans les langages typés dynamiquement : si ça marche
comme un canard et que ça fait coin-coin comme un canard, alors ça doit être un
canard ! Dans l'implémentation de executer
sur Ecran
dans l'encart 17-5,
executer
n'a pas besoin de connaître le type concret de chaque composant. Elle
ne vérifie pas si un composant est une instance de Bouton
ou de
ListeDeroulante
, elle ne fait qu'appeler la méthode afficher
sur le
composant. En spécifiant Box<dyn Affichable>
comme type des valeurs dans le
vecteur composants
, nous avons défini que Ecran
n'avait besoin que de valeurs
sur lesquelles on peut appeler la méthode afficher
.
L'avantage d'utiliser les objets traits et le système de types de Rust pour écrire du code semblable à celui utilisant le duck typing est que nous n'avons jamais besoin de vérifier si une valeur implémente une méthode en particulier à l'exécution, ni de nous inquiéter d'avoir des erreurs si une valeur n'implémente pas une méthode mais qu'on l'appelle quand même. Rust ne compilera pas notre code si les valeurs n'implémentent pas les traits requis par les objets traits.
Par exemple, l'encart 17-10 montre ce qui arrive si on essaie de créer un
Ecran
avec une String
comme composant :
Fichier : src/main.rs
use gui::Ecran;
fn main() {
let ecran = Ecran {
composants: vec![Box::new(String::from("Salutations"))],
};
ecran.run();
}
Nous aurons cette erreur parce que String
n'implémente pas le trait
Affichable
:
$ cargo run
Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Affichable` is not satisfied
--> src/main.rs:5:26
|
5 | composants: vec![Box::new(String::from("Salutations"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Affichable` is not implemented for `String`
|
= note: required for the cast to the object type `dyn Affichable`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` due to previous error
L'erreur nous fait savoir que soit nous passons quelque chose à Ecran
que nous
ne voulions pas lui passer et que nous devrions lui passer un type différent, soit
nous devrions implémenter Affichable
sur String
de sorte que Ecran
puisse
appeler afficher
dessus.
Les objets traits effectuent de la répartition dynamique
Rappelez-vous de notre discussion dans une section du chapitre 10 à propos du processus de monomorphisation effectué par le compilateur quand nous utilisons des traits liés sur des génériques : le compilateur génère des implémentations non génériques de fonctions et de méthodes pour chaque type concret que nous utilisons à la place d'un paramètre de type générique. Le code résultant de la monomorphisation effectue du dispatch statique (répartition statique), qui peut être mis en place quand le compilateur sait, au moment de la compilation, quelle méthode vous appelez. Cela s'oppose au dispatch dynamique (répartition dynamique), qui est mis en place quand le compilateur ne peut pas déterminer à la compilation quelle méthode vous appelez. Dans le cas de la répartition dynamique, le compilateur produit du code qui devra déterminer à l'exécution quelle méthode appeler.
Quand nous utilisons des objets traits, Rust doit utiliser de la répartition dynamique. Le compilateur ne connaît pas tous les types qui pourraient être utilisés avec le code qui utilise des objets traits, donc il ne sait pas quelle méthode implémentée sur quel type il doit appeler. À la place, lors de l'exécution, Rust utilise les pointeurs à l'intérieur de l'objet trait pour savoir quelle méthode appeler. Il y a un coût à l'exécution lors de la recherche de cette méthode qui n'a pas lieu avec la répartition statique. La répartition dynamique empêche en outre le compilateur de choisir de remplacer un appel de méthode par le code de cette méthode, ce qui empêche par ricochet certaines optimisations. Cependant, cela a permis de rendre plus flexible le code que nous avons écrit dans l'encart 17-5 et que nous avons pu gérer dans l'encart 17-9, donc c'est un compromis à envisager.