Définition d'un Enum

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 Définition d'un Enum. Ce laboratoire est une partie du Rust Book. Vous pouvez pratiquer vos compétences Rust dans LabEx.

Dans ce laboratoire, nous allons définir une énumération appelée IpAddrKind pour représenter les types possibles d'adresses IP, y compris la version quatre (V4) et la version six (V6).


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust(("Rust")) -.-> rust/MemorySafetyandManagementGroup(["Memory Safety and Management"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/DataTypesGroup -.-> rust/integer_types("Integer Types") rust/DataTypesGroup -.-> rust/string_type("String Type") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/MemorySafetyandManagementGroup -.-> rust/lifetime_specifiers("Lifetime Specifiers") 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-100398{{"Définition d'un Enum"}} rust/integer_types -.-> lab-100398{{"Définition d'un Enum"}} rust/string_type -.-> lab-100398{{"Définition d'un Enum"}} rust/function_syntax -.-> lab-100398{{"Définition d'un Enum"}} rust/expressions_statements -.-> lab-100398{{"Définition d'un Enum"}} rust/lifetime_specifiers -.-> lab-100398{{"Définition d'un Enum"}} rust/method_syntax -.-> lab-100398{{"Définition d'un Enum"}} rust/traits -.-> lab-100398{{"Définition d'un Enum"}} rust/operator_overloading -.-> lab-100398{{"Définition d'un Enum"}} end

Définition d'un Enum

Alors que les structs vous donnent un moyen de regrouper des champs et des données liés, comme un Rectangle avec sa largeur et sa hauteur, les enums vous donnent un moyen de dire qu'une valeur est l'une d'un ensemble possible de valeurs. Par exemple, nous pouvons vouloir dire que Rectangle est l'un d'un ensemble de formes possibles qui inclut également Circle et Triangle. Pour ce faire, Rust nous permet d'encoder ces possibilités sous forme d'un enum.

Regardons une situation que nous pourrions vouloir exprimer dans le code et voyons pourquoi les enums sont utiles et plus appropriés que les structs dans ce cas. Disons que nous devons travailler avec des adresses IP. Actuellement, deux normes majeures sont utilisées pour les adresses IP : la version quatre et la version six. Parce que ce sont les seules possibilités pour une adresse IP que notre programme rencontrera, nous pouvons énumérer toutes les variantes possibles, d'où le nom d'énumération.

Toute adresse IP peut être soit une adresse de version quatre soit une adresse de version six, mais pas les deux en même temps. Cette propriété des adresses IP rend la structure de données enum appropriée car une valeur enum ne peut être qu'une de ses variantes. Les adresses de version quatre et de version six sont toujours fondamentalement des adresses IP, donc elles devraient être traitées comme le même type lorsque le code gère des situations qui s'appliquent à n'importe quel type d'adresse IP.

Nous pouvons exprimer ce concept dans le code en définissant une énumération IpAddrKind et en listant les types possibles qu'une adresse IP peut avoir, V4 et V6. Ce sont les variantes de l'enum :

enum IpAddrKind {
    V4,
    V6,
}

IpAddrKind est désormais un type de données personnalisé que nous pouvons utiliser ailleurs dans notre code.

Valeurs d'énumération

Nous pouvons créer des instances de chacune des deux variantes de IpAddrKind comme ceci :

let four = IpAddrKind::V4;
let six = IpAddrKind::V6;

Remarquez que les variantes de l'énumération sont encadrées par son identifiant, et nous utilisons deux points pour les séparer. Cela est pratique car maintenant les deux valeurs IpAddrKind::V4 et IpAddrKind::V6 sont du même type : IpAddrKind. Nous pouvons ensuite, par exemple, définir une fonction qui prend n'importe quel IpAddrKind :

fn route(ip_kind: IpAddrKind) {}

Et nous pouvons appeler cette fonction avec n'importe laquelle des variantes :

route(IpAddrKind::V4);
route(IpAddrKind::V6);

L'utilisation d'énums présente encore plus d'avantages. En réfléchissant davantage à notre type d'adresse IP, pour le moment, nous n'avons pas de moyen de stocker les données de l'adresse IP réelle ; nous ne savons que de quel type elle est. Étant donné que vous venez d'apprendre les structs au chapitre 5, vous pourriez être tenté de résoudre ce problème avec des structs comme dans la liste 6-1.

1 enum IpAddrKind {
    V4,
    V6,
}

2 struct IpAddr {
  3 kind: IpAddrKind,
  4 address: String,
}

5 let home = IpAddr {
    kind: IpAddrKind::V4,
    address: String::from("127.0.0.1"),
};

6 let loopback = IpAddr {
    kind: IpAddrKind::V6,
    address: String::from("::1"),
};

Liste 6-1 : Stockage des données et de la variante IpAddrKind d'une adresse IP à l'aide d'un struct

Ici, nous avons défini un struct IpAddr [2] qui a deux champs : un champ kind [3] qui est de type IpAddrKind (l'énumération que nous avons définie précédemment [1]) et un champ address [4] de type String. Nous avons deux instances de ce struct. La première est home [5], et elle a la valeur IpAddrKind::V4 comme kind avec des données d'adresse associées de 127.0.0.1. La deuxième instance est loopback [6]. Elle a l'autre variante de IpAddrKind comme valeur de kind, V6, et a l'adresse ::1 associée. Nous avons utilisé un struct pour regrouper les valeurs kind et address ensemble, de sorte que maintenant la variante est associée à la valeur.

Cependant, représenter le même concept en utilisant seulement un enum est plus concise : au lieu d'avoir un enum à l'intérieur d'un struct, nous pouvons directement insérer des données dans chaque variante d'énumération. Cette nouvelle définition de l'énumération IpAddr indique que les deux variantes V4 et V6 auront des valeurs String associées :

enum IpAddr {
    V4(String),
    V6(String),
}

let home = IpAddr::V4(String::from("127.0.0.1"));

let loopback = IpAddr::V6(String::from("::1"));

Nous attachons directement des données à chaque variante de l'énumération, il n'est donc pas nécessaire d'avoir un struct supplémentaire. Ici, il est également plus facile de voir un autre détail sur la façon dont les enums fonctionnent : le nom de chaque variante d'énumération que nous définissons devient également une fonction qui construit une instance de l'énumération. C'est-à-dire que IpAddr::V4() est un appel de fonction qui prend un argument String et renvoie une instance du type IpAddr. Nous obtenons automatiquement cette fonction constructeur définie en définissant l'énumération.

Il y a un autre avantage à utiliser un enum plutôt qu'un struct : chaque variante peut avoir différents types et quantités de données associées. Les adresses IP de version quatre auront toujours quatre composants numériques qui auront des valeurs comprises entre 0 et 255. Si nous voulions stocker les adresses V4 comme quatre valeurs u8 mais toujours exprimer les adresses V6 comme une seule valeur String, nous ne pourrions pas le faire avec un struct. Les enums gèrent ce cas avec aisance :

enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);

let loopback = IpAddr::V6(String::from("::1"));

Nous avons montré plusieurs façons différentes de définir des structures de données pour stocker les adresses IP de version quatre et de version six. Cependant, il s'avère que le fait de vouloir stocker des adresses IP et d'encoder de quel type elles sont est si courant que la bibliothèque standard a une définition que nous pouvons utiliser! Voyons comment la bibliothèque standard définit IpAddr : elle a exactement l'énumération et les variantes que nous avons définies et utilisées, mais elle incorpore les données d'adresse à l'intérieur des variantes sous forme de deux structs différents, qui sont définis différemment pour chaque variante :

struct Ipv4Addr {
    --snip--
}

struct Ipv6Addr {
    --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}

Ce code illustre que vous pouvez insérer n'importe quel type de données dans une variante d'énumération : des chaînes, des types numériques ou des structs, par exemple. Vous pouvez même inclure un autre enum! De plus, les types de bibliothèque standard ne sont souvent pas beaucoup plus complexes que ceux que vous pourriez imaginer.

Remarquez que même si la bibliothèque standard contient une définition pour IpAddr, nous pouvons toujours créer et utiliser notre propre définition sans conflit car nous n'avons pas importé la définition de la bibliothèque standard dans notre portée. Nous en parlerons plus longuement au chapitre 7.

Regardons un autre exemple d'énumération dans la liste 6-2 : celle-ci a une grande variété de types incorporés dans ses variantes.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

Liste 6-2 : Un Message enum dont les variantes stockent différentes quantités et types de valeurs

Cet enum a quatre variantes avec différents types :

  • Quit n'a aucune donnée associée.
  • Move a des champs nommés, comme un struct.
  • Write inclut une seule String.
  • ChangeColor inclut trois valeurs i32.

Définir un enum avec des variantes telles que celles de la liste 6-2 est similaire à définir différents types de définitions de structs, sauf que l'énumération n'utilise pas le mot clé struct et que toutes les variantes sont regroupées sous le type Message. Les structs suivants pourraient contenir les mêmes données que les variantes d'énumération précédentes :

struct QuitMessage; // struct unitaire
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // struct tuple
struct ChangeColorMessage(i32, i32, i32); // struct tuple

Mais si nous utilisions les différents structs, chacun ayant son propre type, nous ne pourrions pas aussi facilement définir une fonction pour prendre n'importe quel type de message que nous pourrions avec l'énumération Message définie dans la liste 6-2, qui est un seul type.

Il y a encore une autre similitude entre les enums et les structs : tout comme nous sommes capables de définir des méthodes sur les structs en utilisant impl, nous sommes également capables de définir des méthodes sur les enums. Voici une méthode nommée call que nous pourrions définir sur notre énumération Message :

impl Message {
    fn call(&self) {
      1 // le corps de la méthode serait défini ici
    }
}

2 let m = Message::Write(String::from("hello"));
m.call();

Le corps de la méthode utiliserait self pour obtenir la valeur sur laquelle nous avons appelé la méthode. Dans cet exemple, nous avons créé une variable m [2] qui a la valeur Message::Write(String::from("hello")), et c'est ce que self sera dans le corps de la méthode call [1] lorsque m.call() est exécuté.

Regardons un autre enum dans la bibliothèque standard qui est très courant et utile : Option.

L'énumération Option et ses avantages par rapport aux valeurs nulles

Cette section explore une étude de cas de Option, qui est une autre énumération définie par la bibliothèque standard. Le type Option encode le scénario très courant dans lequel une valeur peut être quelque chose ou ne rien être.

Par exemple, si vous demandez le premier élément d'une liste contenant plusieurs éléments, vous obtiendrez une valeur. Si vous demandez le premier élément d'une liste vide, vous n'obtiendrez rien. Exprimer ce concept au niveau du système de types signifie que le compilateur peut vérifier si vous avez traité tous les cas que vous devriez traiter ; cette fonctionnalité peut empêcher des bugs extrêmement courants dans d'autres langages de programmation.

La conception des langages de programmation est souvent considérée en termes des fonctionnalités que vous incluez, mais les fonctionnalités que vous excluez sont également importantes. Rust n'a pas la fonctionnalité null que de nombreux autres langages ont. Null est une valeur qui signifie qu'il n'y a pas de valeur là. Dans les langages avec null, les variables peuvent toujours être dans l'un des deux états : null ou non-null.

Dans sa présentation de 2009 intitulée "Null References: The Billion Dollar Mistake", Tony Hoare, l'inventeur de null, a ceci à dire :

Je l'appelle ma grosse erreur de milliards de dollars. À l'époque, je concevais le premier système de types complet pour les références dans un langage orienté objet. Mon objectif était de garantir que toute utilisation des références soit absolument sûre, avec des vérifications effectuées automatiquement par le compilateur. Mais je n'ai pas résisté à la tentation d'ajouter une référence null, simplement parce que c'était si facile à implémenter. Cela a entraîné d'innombrables erreurs, vulnérabilités et plantages de systèmes, qui ont probablement causé des milliards de dollars de problèmes et de dégâts au cours des quarante dernières années. Le problème avec les valeurs nulles est que si vous essayez d'utiliser une valeur null comme une valeur non-nulle, vous obtiendrez une erreur de quelque type. Étant donné que cette propriété null ou non-null est omniprésente, il est extrêmement facile de commettre ce genre d'erreur.

Cependant, le concept que null essaie d'exprimer est toujours un concept utile : un null est une valeur qui est actuellement invalide ou absente pour une certaine raison.

Le problème n'est pas vraiment avec le concept mais avec la mise en œuvre particulière. En conséquence, Rust n'a pas de nulls, mais il a une énumération qui peut encoder le concept d'une valeur étant présente ou absente. Cette énumération est Option<T>, et elle est définie par la bibliothèque standard comme suit :

enum Option<T> {
    None,
    Some(T),
}

L'énumération Option<T> est si utile qu'elle est même incluse dans le préambule ; vous n'avez pas besoin de l'importer explicitement dans votre portée. Ses variantes sont également incluses dans le préambule : vous pouvez utiliser Some et None directement sans le préfixe Option::. L'énumération Option<T> est toujours juste une énumération normale, et Some(T) et None sont toujours des variantes du type Option<T>.

La syntaxe <T> est une fonctionnalité de Rust dont nous n'avons pas encore parlé. C'est un paramètre de type générique, et nous aborderons les génériques en détail au chapitre 10. Pour l'instant, tout ce que vous avez besoin de savoir est que <T> signifie que la variante Some de l'énumération Option peut contenir une donnée de n'importe quel type, et que chaque type concret qui est utilisé à la place de T rend le type Option<T> global un type différent. Voici quelques exemples d'utilisation de valeurs Option pour stocker des types numériques et des types de chaînes :

let some_number = Some(5);
let some_char = Some('e');

let absent_number: Option<i32> = None;

Le type de some_number est Option<i32>. Le type de some_char est Option<char>, qui est un type différent. Rust peut déduire ces types car nous avons spécifié une valeur à l'intérieur de la variante Some. Pour absent_number, Rust nous oblige à annoter le type Option global : le compilateur ne peut pas déduire le type que la variante Some correspondante contiendra en ne regardant que la valeur None. Ici, nous disons à Rust que nous voulons que absent_number soit de type Option<i32>.

Lorsque nous avons une valeur Some, nous savons qu'une valeur est présente et que la valeur est contenue dans Some. Lorsque nous avons une valeur None, en un certain sens, cela signifie la même chose que null : nous n'avons pas de valeur valide. Alors pourquoi avoir Option<T> est-il meilleur que d'avoir null?

En résumé, parce que Option<T> et T (où T peut être n'importe quel type) sont des types différents, le compilateur ne nous permettra pas d'utiliser une valeur Option<T> comme si elle était définitivement une valeur valide. Par exemple, ce code ne compilera pas, car il essaie d'ajouter un i8 à un Option<i8> :

let x: i8 = 5;
let y: Option<i8> = Some(5);

let sum = x + y;

Si nous exécutons ce code, nous obtenons un message d'erreur comme celui-ci :

error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`

Intense! En fait, ce message d'erreur signifie que Rust ne sait pas comment ajouter un i8 et un Option<i8>, car ce sont des types différents. Lorsque nous avons une valeur d'un type comme i8 en Rust, le compilateur veillera à ce que nous ayons toujours une valeur valide. Nous pouvons procéder avec confiance sans avoir à vérifier si la valeur est null avant de l'utiliser. Seule lorsque nous avons une Option<i8> (ou n'importe quel type de valeur avec lequel nous travaillons), nous devons nous soucier du fait que nous n'ayons peut-être pas de valeur, et le compilateur veillera à ce que nous traitons ce cas avant d'utiliser la valeur.

En d'autres termes, vous devez convertir une Option<T> en un T avant de pouvoir effectuer des opérations sur T avec elle. Généralement, cela aide à détecter l'un des problèmes les plus courants avec null : supposer que quelque chose n'est pas null lorsqu'il l'est effectivement.

Éliminer le risque d'assumer incorrectement une valeur non-nulle vous aide à être plus confiant dans votre code. Pour avoir une valeur qui peut éventuellement être null, vous devez explicitement choisir de le faire en donnant à ce type de valeur le type Option<T>. Ensuite, lorsque vous utilisez cette valeur, vous êtes obligé d'explicitement gérer le cas où la valeur est null. Partout où une valeur a un type qui n'est pas un Option<T>, vous pouvez supposer avec confiance que la valeur n'est pas null. Cette décision de conception délibérée de Rust vise à limiter la généralité de null et à augmenter la sécurité du code Rust.

Alors, comment extraire la valeur T d'une variante Some lorsque vous avez une valeur de type Option<T> afin que vous puissiez utiliser cette valeur? L'énumération Option<T> a un grand nombre de méthodes qui sont utiles dans diverses situations ; vous pouvez les consulter dans sa documentation. Devenir familier avec les méthodes sur Option<T> sera extrêmement utile dans votre parcours avec Rust.

En général, pour utiliser une valeur Option<T>, vous voulez avoir du code qui traitera chaque variante. Vous voulez du code qui ne s'exécutera que lorsque vous avez une valeur Some(T), et ce code est autorisé à utiliser le T interne. Vous voulez un autre code qui ne s'exécutera que si vous avez une valeur None, et ce code n'a pas de valeur T disponible. L'expression match est une construction de contrôle de flux qui fait exactement cela lorsqu'elle est utilisée avec des enums : elle exécutera du code différent selon la variante de l'énumération qu'elle a, et ce code peut utiliser les données à l'intérieur de la valeur correspondante.

Sommaire

Félicitations! Vous avez terminé le laboratoire Définition d'un Enum. Vous pouvez pratiquer d'autres laboratoires dans LabEx pour améliorer vos compétences.