Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Utilisation des objets traits pour abstraire des comportements communs

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-9 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.

Au moment de l’écriture de la bibliothèque, nous ne pouvons savoir ni définir tous les types que d’autres programmeurs pourraient vouloir créer. Au moment d’écrire la bibliothèque, nous ne pouvons pas connaître 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 créer de nouveaux types compatibles avec la bibliothèque.

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 la section “Les types à taille dynamique et le trait Sized” du chapitre 20). 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 diffèrent des objets des autres langages dans le sens où on ne peut pas ajouter de 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 18-3 illustre la façon de définir un trait nommé Affichable avec une méthode nommée afficher.

Filename: src/lib.rs
pub trait Affichable {
    fn afficher(&self);
}
Listing 18-3: Definition of the Draw trait

Cette syntaxe devrait vous rappeler nos discussions concernant la définition des traits au chapitre 10. Puis vient une nouvelle syntaxe : l’encart 18-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 substitut pour n’importe quel type au sein d’un Box qui implémente le trait Affichable.

Filename: src/lib.rs
pub trait Affichable {
    fn afficher(&self);
}

pub struct Ecran {
    pub composants: Vec<Box<dyn Affichable>>,
}
Listing 18-4: Definition of the Screen struct with a components field holding a vector of trait objects that implement the Draw trait

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 18-5.

Filename: 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();
        }
    }
}
Listing 18-5: A run method on Screen that calls the draw method on each component

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 18-6.

Filename: 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();
        }
    }
}
Listing 18-6: An alternate implementation of the Screen struct and its run method using generics and trait bounds

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 18-7.

Filename: 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
    }
}
Listing 18-7: A Button struct that implements the Draw trait

Les champs largeur, hauteur et libelle de Bouton pourront ne pas être les mêmes que ceux d’autres composants ; par exemple, un type ChampDeTexte pourrait avoir ces champs plus un champ texte_de_substitution. 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, comme déjà spécifié). 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 18-8.

Filename: 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() {}
Listing 18-8: Another crate using gui and implementing the Draw trait on a SelectBox struct

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 18-9 montre cette implémentation.

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

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();
}
Listing 18-9: Using trait objects to store values of different types that implement the same 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 18-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 18-10 montre ce qui arrive si on essaie de créer un Ecran avec une String comme composant.

Filename: src/main.rs
use gui::Ecran;

fn main() {
    let ecran = Ecran {
        composants: vec![Box::new(String::from("Salutations"))],
    };

    ecran.executer();
}
Listing 18-10: Attempting to use a type that doesn’t implement the trait object’s 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`
   |
help: the trait `Affichable` is implemented for `Bouton`
  --> src/lib.rs:23:1
   |
23 | impl Affichable for Bouton {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^
   = note: required for the cast from `Box<String>` to `Box<dyn Affichable>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 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.

Mise en œuvre de la répartition dynamique

Rappelez-vous de notre discussion dans “Performance du code utilisant les génériques” du chapitre 10 à propos du processus de monomorphisation effectué sur les génériques par le compilateur : ce dernier 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 de la répartition statique (static dispatch), qui peut être mis en place quand le compilateur sait, au moment de la compilation, quelle méthode vous appelez. Cela s’oppose à la répartition dynamique (dynamic dispatch), 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, à l’exécution, saura 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. Cette recherche entraîne un coût à l’exécution 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, et Rust a des règles concernant les endroits où l’on peut ou pas utiliser la répartition dynamique, appelées compatibilité dyn (dyn compatibility). Ces règles sortent du cadre de cette discussion, mais vous pouvez en savoir plus en consultant la référence. Cependant, cela a permis de rendre plus flexible le code que nous avons écrit dans l’encart 18-5 et que nous avons pu gérer dans l’encart 18-9, donc c’est un compromis à envisager.