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);
}

Encart 17-3 : définition du trait Affichable

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

Encart 17-4 : définition de la structure Ecran avec un champ composants contenant un vecteur d'objets traits qui implémentent le trait 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();
        }
    }
}

Encart 17-5 : une méthode executer sur Ecran qui appelle la méthode afficher sur chaque composant

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();
        }
    }
}

Encart 17-6 : une implémentation différente de la structure Ecran et de sa méthode executer en utilisant la généricité et les traits liés

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
    }
}

Encart 17-7 : une structure Bouton qui implémente le trait Affichable

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() {}

Encart 17-8 : une autre crate utilisant gui et implémentant le trait Affichable sur une structure ListeDeroulante

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();
}

Encart 17-9 : utilisation d'objets traits pour stocker des valeurs de types différents qui implémentent le même trait

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();
}

Encart 17-10 : tentative d'utiliser un type qui n'implémente pas le trait de l'objet trait

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.