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

Utiliser des objets trait pour abstraire un comportement commun

Au chapitre 8, nous avons mentionné qu’une limitation des vecteurs est qu’ils ne peuvent stocker des éléments que d’un seul type. Nous avons créé une solution de contournement dans l’encart 8-9 où nous avons défini un enum SpreadsheetCell qui avait des variantes pour contenir des entiers, des flottants et du texte. Cela signifiait que nous pouvions stocker différents types de données dans chaque cellule et avoir quand même un vecteur représentant une ligne de cellules. C’est une solution parfaitement valable quand nos éléments interchangeables sont un ensemble fixe de types que nous connaissons au moment de la compilation de notre code.

Cependant, parfois nous voulons que l’utilisateur de notre bibliothèque puisse étendre l’ensemble des types valides dans une situation particulière. Pour montrer comment nous pourrions y parvenir, nous allons créer un exemple d’outil d’interface graphique utilisateur (GUI) qui parcourt une liste d’éléments, en appelant une méthode draw sur chacun pour le dessiner à l’écran — une technique courante pour les outils GUI. Nous créerons un crate de bibliothèque appelé gui qui contient la structure d’une bibliothèque GUI. Ce crate pourrait inclure certains types à utiliser, comme Button ou TextField. De plus, les utilisateurs de gui voudront créer leurs propres types pouvant être dessinés : par exemple, un programmeur pourrait ajouter un Image, et un autre pourrait ajouter un SelectBox.

Au moment d’écrire la bibliothèque, nous ne pouvons pas connaître et définir tous les types que d’autres programmeurs pourraient vouloir créer. Mais nous savons que gui doit garder une trace de nombreuses valeurs de différents types, et qu’il doit appeler une méthode draw sur chacune de ces valeurs de types différents. Il n’a pas besoin de savoir exactement ce qui va se passer quand nous appelons la méthode draw, juste que la valeur aura cette méthode disponible pour que nous puissions l’appeler.

Pour faire cela dans un langage avec héritage, nous pourrions définir une classe nommée Component qui à une méthode nommée draw. Les autres classes, comme Button, Image et SelectBox, hériteraient de Component et donc hériteraient de la méthode draw. Elles pourraient chacune surcharger la méthode draw pour définir leur comportement personnalisé, mais le framework pourrait traiter tous les types comme s’ils étaient des instances de Component et appeler draw sur eux. Mais comme Rust n’a pas d’héritage, nous avons besoin d’une autre façon 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 un comportement commun

Pour implémenter le comportement que nous voulons que gui ait, nous définirons un trait nommé Draw qui aura une méthode nommée draw. Ensuite, nous pourrons définir un vecteur qui prend un objet trait. Un objet trait pointe à la fois vers une instance d’un type implémentant notre trait spécifié et vers une table utilisée pour rechercher les méthodes du trait sur ce type à l’exécution. Nous créons un objet trait en spécifiant une sorte de pointeur, comme une référence ou un pointeur intelligent Box<T>, puis le mot-clé dyn, puis en spécifiant le trait pertinent. (Nous parlerons de la raison pour laquelle les objets trait doivent utiliser un pointeur dans [« Types de taille dynamique et le trait Sized »][dynamically-sized] au chapitre 20.) Nous pouvons utiliser des objets trait à 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 au moment de la compilation que toute valeur utilisée dans ce contexte implémentera le trait de l’objet trait. Par conséquent, nous n’avons pas besoin de connaître tous les types possibles au moment de la compilation.

Nous avons mentionné qu’en Rust, nous nous abstenons d’appeler les structs et les enums des « objets » pour les distinguer des objets des autres langages. Dans une struct ou un enum, les données dans les champs de la struct et le comportement dans les blocs impl sont séparés, tandis que dans d’autres langages, les données et le comportement combinés en un seul concept sont souvent qualifiés d’objet. Les objets trait diffèrent des objets dans d’autres langages en ce que nous ne pouvons pas ajouter de données à un objet trait. Les objets trait ne sont pas aussi généralement utiles que les objets dans d’autres langages : leur but spécifique est de permettre l’abstraction à travers un comportement commun.

L’encart 18-3 montre comment définir un trait nommé Draw avec une méthode nommée draw.

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

Cette syntaxe devrait vous être familière de nos discussions sur la façon de définir des traits au chapitre 10. Vient ensuite une nouvelle syntaxe : l’encart 18-4 définit une struct nommée Screen qui contient un vecteur nommé components. Ce vecteur est de type Box<dyn Draw>, qui est un objet trait ; c’est un substitut pour tout type à l’intérieur d’un Box qui implémente le trait Draw.

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

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}
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 struct Screen, nous définirons une méthode nommée run qui appellera la méthode draw sur chacun de ses components, comme montré dans l’encart 18-5.

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

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
Listing 18-5: A run method on Screen that calls the draw method on each component

Cela fonctionne différemment de la définition d’une struct qui utilise un paramètre de type générique avec des contraintes de trait. Un paramètre de type générique ne peut être substitué qu’avec un seul type concret à la fois, tandis que les objets trait permettent à plusieurs types concrets de remplir le rôle de l’objet trait à l’exécution. Par exemple, nous aurions pu définir la struct Screen en utilisant un type générique et une contrainte de trait, comme dans l’encart 18-6.

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

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
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 Screen qui à une liste de composants tous de type Button ou tous de type TextField. Si vous n’aurez jamais que des collections homogènes, utiliser des génériques et des contraintes de trait est préférable car les définitions seront monomorphisées au moment de la compilation pour utiliser les types concrets.

D’un autre côté, avec la méthode utilisant des objets trait, une instance de Screen peut contenir un Vec<T> qui contient un Box<Button> ainsi qu’un Box<TextField>. Voyons comment cela fonctionne, puis nous parlerons des implications en termes de performance à l’exécution.

Implémenter le trait

Nous allons maintenant ajouter quelques types qui implémentent le trait Draw. Nous fournirons le type Button. Encore une fois, implémenter réellement une bibliothèque GUI dépasse le cadre de ce livre, donc la méthode draw n’aura pas d’implémentation utile dans son corps. Pour imaginer à quoi l’implémentation pourrait ressembler, une struct Button pourrait avoir des champs pour width, height et label, comme montré dans l’encart 18-7.

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

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // code to actually draw a button
    }
}
Listing 18-7: A Button struct that implements the Draw trait

Les champs width, height et label de Button différeront des champs des autres composants ; par exemple, un type TextField pourrait avoir ces mêmes champs plus un champ placeholder. Chacun des types que nous voulons dessiner à l’écran implémentera le trait Draw mais utilisera un code différent dans la méthode draw pour définir comment dessiner ce type particulier, comme Button le fait ici (sans le code GUI réel, comme mentionné). Le type Button, par exemple, pourrait avoir un bloc impl supplémentaire contenant des méthodes liées à ce qui se passe quand un utilisateur clique sur le bouton. Ces types de méthodes ne s’appliqueront pas à des types comme TextField.

Si quelqu’un utilisant notre bibliothèque décide d’implémenter une struct SelectBox qui à des champs width, height et options, il implémenterait aussi le trait Draw sur le type SelectBox, comme montré dans l’encart 18-8.

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

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

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 Screen. À l’instance de Screen, il peut ajouter un SelectBox et un Button en mettant chacun dans un Box<T> pour devenir un objet trait. Il peut ensuite appeler la méthode run sur l’instance de Screen, qui appellera draw sur chacun des composants. L’encart 18-9 montre cette implémentation.

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

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}
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 ajouter le type SelectBox, mais notre implémentation de Screen a pu opérer sur le nouveau type et le dessiner parce que SelectBox implémente le trait Draw, ce qui signifie qu’il implémente la méthode draw.

Ce concept — ne se préoccuper que des messages auxquels une valeur répond plutôt que du type concret de la valeur — est similaire au concept de duck typing dans les langages à typage dynamique : si ça marche comme un canard et si ça cancane comme un canard, alors ça doit être un canard ! Dans l’implémentation de run sur Screen dans l’encart 18-5, run n’a pas besoin de connaître le type concret de chaque composant. Il ne vérifie pas si un composant est une instance de Button ou de SelectBox, il appelle simplement la méthode draw sur le composant. En spécifiant Box<dyn Draw> comme type des valeurs dans le vecteur components, nous avons défini Screen comme ayant besoin de valeurs sur lesquelles nous pouvons appeler la méthode draw.

L’avantage d’utiliser des objets trait et le système de types de Rust pour écrire du code similaire au code utilisant le duck typing est que nous n’avons jamais à vérifier si une valeur implémente une méthode particulière à l’exécution ni à nous inquiéter d’obtenir des erreurs si une valeur n’implémente pas une méthode mais que nous l’appelons quand même. Rust ne compilera pas notre code si les valeurs n’implémentent pas les traits dont les objets trait ont besoin.

Par exemple, l’encart 18-10 montre ce qui se passe si nous essayons de créer un Screen avec une String comme composant.

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

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}
Listing 18-10: Attempting to use a type that doesn’t implement the trait object’s trait

Nous obtiendrons cette erreur parce que String n’implémente pas le trait Draw : console {{#include ../listings/ch18-oop/listing-18-10/output.txt}}

$ cargo run
   Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
  |
  = help: the trait `Draw` is implemented for `Button`
  = note: required for the cast from `Box<String>` to `Box<dyn Draw>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error

Cette erreur nous indique que soit nous passons quelque chose à Screen que nous n’avions pas l’intention de passer et donc nous devrions passer un type différent, soit nous devrions implémenter Draw sur String pour que Screen puisse appeler draw dessus.

Effectuer la répartition dynamique

Rappelez-vous dans « Performance du code utilisant des génériques » au chapitre 10 notre discussion sur le processus de monomorphisation effectué par le compilateur quand nous utilisons des contraintes de trait 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 qui résulte de la monomorphisation effectue une répartition statique, qui est lorsque le compilateur sait quelle méthode vous appelez au moment de la compilation. Cela s’oppose à la répartition dynamique, qui est lorsque le compilateur ne peut pas dire au moment de la compilation quelle méthode vous appelez. Dans les cas de répartition dynamique, le compilateur émet du code qui au moment de l’exécution déterminera quelle méthode appeler.

Quand nous utilisons des objets trait, Rust doit utiliser la répartition dynamique. Le compilateur ne connaît pas tous les types qui pourraient être utilisés avec le code utilisant des objets trait, donc il ne sait pas quelle méthode implémentée sur quel type appeler. À la place, à l’exécution, Rust utilise les pointeurs à l’intérieur de l’objet trait pour savoir quelle méthode appeler. Cette recherche engendre un coût à l’exécution qui ne se produit pas avec la répartition statique. La répartition dynamique empêche aussi le compilateur de choisir d’inliner le code d’une méthode, ce qui à son tour empêche certaines optimisations, et Rust a certaines règles sur les endroits où vous pouvez et ne pouvez pas utiliser la répartition dynamique, appelées compatibilité dyn. Ces règles dépassent le cadre de cette discussion, mais vous pouvez en lire davantage [dans la référence][dyn-compatibility]. Cependant, nous avons obtenu une flexibilité supplémentaire dans le code que nous avons écrit dans l’encart 18-5 et que nous avons pu prendre en charge dans l’encart 18-9, donc c’est un compromis à considérer.