Objets de trait pour des valeurs hétérogènes

RustRustBeginner
Pratiquer maintenant

This tutorial is from open-source community. Access the source code

💡 Ce tutoriel est traduit par l'IA à partir de la version anglaise. Pour voir la version originale, vous pouvez cliquer ici

Introduction

Bienvenue dans Using Trait Objects That Allow for Values of Different Types. Ce laboratoire est une partie du Rust Book. Vous pouvez pratiquer vos compétences Rust dans LabEx.

Dans ce laboratoire, nous allons explorer la manière d'utiliser des objets de trait pour autoriser des valeurs de différents types dans une bibliothèque, en particulier dans le contexte d'un outil d'interface graphique utilisateur (GUI).


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/ControlStructuresGroup(["Control Structures"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/DataTypesGroup -.-> rust/integer_types("Integer Types") rust/DataTypesGroup -.-> rust/string_type("String Type") rust/ControlStructuresGroup -.-> rust/for_loop("for Loop") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/AdvancedTopicsGroup -.-> rust/traits("Traits") rust/AdvancedTopicsGroup -.-> rust/operator_overloading("Traits for Operator Overloading") subgraph Lab Skills rust/variable_declarations -.-> lab-100442{{"Objets de trait pour des valeurs hétérogènes"}} rust/integer_types -.-> lab-100442{{"Objets de trait pour des valeurs hétérogènes"}} rust/string_type -.-> lab-100442{{"Objets de trait pour des valeurs hétérogènes"}} rust/for_loop -.-> lab-100442{{"Objets de trait pour des valeurs hétérogènes"}} rust/function_syntax -.-> lab-100442{{"Objets de trait pour des valeurs hétérogènes"}} rust/expressions_statements -.-> lab-100442{{"Objets de trait pour des valeurs hétérogènes"}} rust/method_syntax -.-> lab-100442{{"Objets de trait pour des valeurs hétérogènes"}} rust/traits -.-> lab-100442{{"Objets de trait pour des valeurs hétérogènes"}} rust/operator_overloading -.-> lab-100442{{"Objets de trait pour des valeurs hétérogènes"}} end

Using Trait Objects That Allow for Values of Different Types

Dans le chapitre 8, nous avons mentionné qu'une limitation des vecteurs est qu'ils ne peuvent stocker que des éléments d'un seul type. Nous avons créé un contournement dans la liste 8-9 où nous avons défini une énumération SpreadsheetCell qui avait des variantes pour stocker des entiers, des flottants et du texte. Cela signifie que nous pouvions stocker différents types de données dans chaque cellule et toujours avoir un vecteur représentant une ligne de cellules. C'est une solution parfaitement valable lorsque 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 soit capable d'étendre l'ensemble des types valides dans une situation donnée. Pour montrer comment nous pourrions y arriver, nous allons créer un exemple d'outil d'interface graphique utilisateur (GUI) qui parcourt une liste d'éléments, appelant une méthode draw sur chacun d'entre eux pour l'afficher à l'écran - une technique courante pour les outils GUI. Nous allons créer une boîte crânienne de bibliothèque appelée gui qui contient la structure d'une bibliothèque GUI. Cette boîte crânienne pourrait inclure certains types pour que les gens puissent les utiliser, tels que Button ou TextField. En outre, les utilisateurs de gui voudront créer leurs propres types qui peuvent être dessinés : par exemple, un programmeur pourrait ajouter une Image et un autre pourrait ajouter un SelectBox.

Nous ne mettrons pas en œuvre une bibliothèque GUI complète pour cet exemple, mais nous montrerons comment les pièces s'assembleraient. Au moment de la rédaction de 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 suivre de nombreux 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 se passera lorsque nous appellerons la méthode draw, juste que la valeur aura cette méthode disponible pour que nous puissions l'appeler.

Pour le faire dans un langage avec héritage, nous pourrions définir une classe nommée Component qui aurait une méthode nommée draw. Les autres classes, telles que Button, Image et SelectBox, hérite de Component et héritent donc de la méthode draw. Elles pourraient chacune remplacer la méthode draw pour définir leur comportement personnalisé, mais le cadre pourrait traiter tous les types comme s'ils étaient des instances de Component et appeler draw sur eux. Mais parce que Rust n'a pas d'héritage, nous avons besoin d'une autre manière de structurer la bibliothèque gui pour permettre aux utilisateurs de l'étendre avec de nouveaux types.

Définition d'un trait pour un comportement commun

Pour implémenter le comportement que nous voulons que gui ait, nous allons définir un trait nommé Draw qui aura une méthode nommée draw. Ensuite, nous pouvons définir un vecteur qui prend un objet de trait. Un objet de trait pointe vers à la fois une instance d'un type implémentant notre trait spécifié et une table utilisée pour rechercher les méthodes de trait sur ce type à l'exécution. Nous créons un objet de trait en spécifiant un certain type de pointeur, tel qu'une référence & ou un pointeur intelligent Box<T>, puis le mot clé dyn, puis en spécifiant le trait pertinent. (Nous parlerons des raisons pour lesquelles les objets de trait doivent utiliser un pointeur dans "Types de taille dynamique et le trait Sized".) Nous pouvons utiliser des objets de trait à la place d'un type générique ou concret. Partout où nous utilisons un objet de trait, le système de types de Rust assurera à la compilation que toute valeur utilisée dans ce contexte implémentera le trait de l'objet de trait. En conséquence, nous n'avons pas besoin de connaître tous les types possibles à la compilation.

Nous avons mentionné que, en Rust, nous évitons d'appeler les structs et les enums "objets" pour les distinguer des objets des autres langages. Dans un struct ou un enum, les données dans les champs du struct et le comportement dans les blocs impl sont séparés, tandis que dans les autres langages, les données et le comportement combinés en un seul concept sont souvent étiquetés comme un objet. Cependant, les objets de trait sont plus semblables aux objets dans les autres langages dans le sens où ils combinent données et comportement. Mais les objets de trait diffèrent des objets traditionnels en ce que nous ne pouvons pas ajouter de données à un objet de trait. Les objets de trait ne sont pas aussi généralement utiles que les objets dans les autres langages : leur but spécifique est d'autoriser l'abstraction sur un comportement commun.

La liste 17-3 montre comment définir un trait nommé Draw avec une méthode nommée draw.

Nom de fichier : src/lib.rs

pub trait Draw {
    fn draw(&self);
}

Liste 17-3 : Définition du trait Draw

Cette syntaxe devrait vous paraître familière d'après nos discussions sur la manière de définir des traits au chapitre 10. Ensuite vient une nouvelle syntaxe : la liste 17-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 de trait ; c'est un substitut pour tout type à l'intérieur d'un Box qui implémente le trait Draw.

Nom de fichier : src/lib.rs

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

Liste 17-4 : Définition de la struct Screen avec un champ components contenant un vecteur d'objets de trait qui implémentent le trait Draw

Sur la struct Screen, nous allons définir une méthode nommée run qui appellera la méthode draw sur chacun de ses components, comme montré dans la liste 17-5.

Nom de fichier : src/lib.rs

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

Liste 17-5 : Une méthode run sur Screen qui appelle la méthode draw sur chaque composant

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 remplacé qu'avec un type concret à la fois, tandis que les objets de trait permettent de multiples types concret de remplir le rôle de l'objet de 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 la liste 17-6.

Nom de fichier : src/lib.rs

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

Liste 17-6 : Une implémentation alternative de la struct Screen et de sa méthode run utilisant des types génériques et des contraintes de trait

Cela nous restreint à une instance de Screen qui a 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 types génériques et des contraintes de trait est préférable car les définitions seront monomorphisées à la compilation pour utiliser les types concret.

D'un autre côté, avec la méthode utilisant des objets de 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 de performance à l'exécution.

Implémentation du trait

Maintenant, nous allons ajouter quelques types qui implémentent le trait Draw. Nous fournirons le type Button. Encore une fois, la mise en œuvre réelle d'une bibliothèque GUI est en dehors des limites de ce livre, donc la méthode draw n'aura pas d'implémentation utile dans son corps. Pour imaginer à quoi pourrait ressembler l'implémentation, une struct Button pourrait avoir des champs pour width, height et label, comme montré dans la liste 17-7.

Nom de fichier : src/lib.rs

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

impl Draw for Button {
    fn draw(&self) {
        // code pour effectivement dessiner un bouton
    }
}

Liste 17-7 : Une struct Button qui implémente le trait Draw

Les champs width, height et label sur Button différeront des champs sur d'autres composants ; par exemple, un type TextField pourrait avoir les mêmes champs plus un champ placeholder. Chacun des types que nous voulons dessiner à l'écran implémentera le trait Draw mais utilisera du code différent dans la méthode draw pour définir la manière de dessiner ce type particulier, comme le fait Button 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 lorsqu'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 a des champs width, height et options, ils implémenteront également le trait Draw sur le type SelectBox, comme montré dans la liste 17-8.

Nom de fichier : src/main.rs

use gui::Draw;

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

impl Draw for SelectBox {
    fn draw(&self) {
        // code pour effectivement dessiner une zone de sélection
    }
}

Liste 17-8 : Un autre crate utilisant gui et implémentant le trait Draw sur une struct SelectBox

L'utilisateur de notre bibliothèque peut maintenant écrire sa fonction main pour créer une instance de Screen. À l'instance de Screen, ils peuvent ajouter une SelectBox et un Button en les mettant chacun dans un Box<T> pour en faire un objet de trait. Ils peuvent ensuite appeler la méthode run sur l'instance de Screen, qui appellera draw sur chacun des composants. La liste 17-9 montre cette implémentation.

Nom de fichier : src/main.rs

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

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

Lorsque 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 été capable de fonctionner sur le nouveau type et de le dessiner car SelectBox implémente le trait Draw, ce qui signifie qu'il implémente la méthode draw.

Ce concept - de ne s'intéresser qu'aux messages à laquelle une valeur répond plutôt que au type concret de la valeur - est similaire au concept de duck typing dans les langages à typage dynamique : si ça marche comme un canard et quack comme un canard, alors ça doit être un canard! Dans l'implémentation de run sur Screen dans la liste 17-5, run n'a pas besoin de savoir quel est le type concret de chaque composant. Il ne vérifie pas si un composant est une instance d'un Button ou d'un SelectBox, il appelle simplement la méthode draw sur le composant. En spécifiant Box<dyn Draw> comme le type des valeurs dans le vecteur components, nous avons défini Screen pour avoir besoin de valeurs sur lesquelles nous pouvons appeler la méthode draw.

L'avantage d'utiliser des objets de 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 besoin de vérifier si une valeur implémente une méthode particulière à l'exécution ou de nous inquiéter d'obtenir des erreurs si une valeur n'implémente pas une méthode mais que nous l'appelons malgré tout. Rust ne compilera pas notre code si les valeurs n'implémentent pas les traits que les objets de trait nécessitent.

Par exemple, la liste 17-10 montre ce qui se passe si nous essayons de créer un Screen avec une String comme composant.

Nom de fichier : src/main.rs

use gui::Screen;

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

    screen.run();
}

Liste 17-10 : Tentative d'utilisation d'un type qui n'implémente pas le trait de l'objet de trait

Nous obtiendrons cette erreur car String n'implémente pas le trait Draw :

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`
  |
  = note: required for the cast to the object type `dyn Draw`

Cette erreur nous informe que soit nous passons quelque chose à Screen que nous n'avions pas l'intention de passer et que nous devrions donc passer un autre type, soit nous devrions implémenter Draw sur String pour que Screen soit capable d'appeler draw sur elle.

Les objets de trait effectuent un appel de fonction dynamique

Rappelez-vous dans "Performance of Code Using Generics" notre discussion sur le processus de monomorphisation effectué par le compilateur lorsque nous utilisons des contraintes de trait sur des types 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 un appel de fonction statique, c'est-à-dire que le compilateur sait quelle méthode vous appelez à la compilation. Cela est opposé à l'appel de fonction dynamique, qui est lorsque le compilateur ne peut pas savoir à la compilation quelle méthode vous appelez. Dans les cas d'appel de fonction dynamique, le compilateur émet du code qui déterminera à l'exécution quelle méthode appeler.

Lorsque nous utilisons des objets de trait, Rust doit utiliser un appel de fonction dynamique. Le compilateur ne connaît pas tous les types qui pourraient être utilisés avec le code qui utilise des objets de trait, donc il ne sait pas quelle méthode implémentée sur quel type appeler. Au lieu de cela, à l'exécution, Rust utilise les pointeurs à l'intérieur de l'objet de trait pour savoir quelle méthode appeler. Cette recherche entraîne une surcharge d'exécution qui n'est pas présente avec l'appel de fonction statique. L'appel de fonction dynamique empêche également le compilateur de choisir d'inliner le code d'une méthode, ce qui empêche à son tour certaines optimisations. Cependant, nous avons obtenu une flexibilité supplémentaire dans le code que nous avons écrit dans la liste 17-5 et avons pu prendre en charge dans la liste 17-9, il s'agit donc d'un compromis à considérer.

Sommaire

Félicitations! Vous avez terminé le laboratoire Using Trait Objects That Allow for Values of Different Types. Vous pouvez pratiquer d'autres laboratoires dans LabEx pour améliorer vos compétences.