Définir une enum
Là où les structures vous permettent de regrouper des champs et des données liées, comme un Rectangle avec sa width (largeur) et sa height (hauteur), les enums vous permettent d’exprimer qu’une valeur fait partie d’un ensemble possible de valeurs. Par exemple, nous pourrions vouloir dire que Rectangle est l’une des formes possibles d’un ensemble qui inclut aussi Circle et Triangle. Pour ce faire, Rust nous permet d’encoder ces possibilités sous forme d’enum.
Examinons une situation que nous pourrions vouloir exprimer dans du code et voyons pourquoi les enums sont utiles et plus appropriées que les structures dans ce cas. Imaginons que nous devions travailler avec des adresses IP. Actuellement, deux standards principaux sont utilisés pour les adresses IP : la version quatre et la version six. Comme ce sont les seules possibilités d’adresse IP que notre programme rencontrera, nous pouvons énumérer toutes les variantes possibles, ce qui est l’origine du nom « énumération ».
Toute adresse IP peut être soit une adresse en version quatre, soit une adresse en 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 d’enum ne peut être que l’une de ses variantes. Les adresses en version quatre et en version six restent fondamentalement des adresses IP, elles doivent donc ê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 d’adresse IP, V4 et V6. Ce sont les variantes de l’enum : rust {{#rustdoc_include ../listings/ch06-enums-and-pattern-matching/no-listing-01-defining-enums/src/main.rs:def}}
enum IpAddrKind {
V4,
V6,
}
fn main() {
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
route(IpAddrKind::V4);
route(IpAddrKind::V6);
}
fn route(ip_kind: IpAddrKind) {}
IpAddrKind est maintenant un type de données personnalisé que nous pouvons utiliser ailleurs dans notre code.
Valeurs d’enum
Nous pouvons créer des instances de chacune des deux variantes de IpAddrKind comme ceci : rust {{#rustdoc_include ../listings/ch06-enums-and-pattern-matching/no-listing-01-defining-enums/src/main.rs:instance}}
enum IpAddrKind {
V4,
V6,
}
fn main() {
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
route(IpAddrKind::V4);
route(IpAddrKind::V6);
}
fn route(ip_kind: IpAddrKind) {}
Remarquez que les variantes de l’enum sont dans l’espace de noms de son identifiant, et nous utilisons un double deux-points pour les séparer. C’est utile car maintenant les deux valeurs IpAddrKind::V4 et IpAddrKind::V6 sont du même type : IpAddrKind. Nous pouvons alors, par exemple, définir une fonction qui accepte n’importe quel IpAddrKind : rust {{#rustdoc_include ../listings/ch06-enums-and-pattern-matching/no-listing-01-defining-enums/src/main.rs:fn}}
enum IpAddrKind {
V4,
V6,
}
fn main() {
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
route(IpAddrKind::V4);
route(IpAddrKind::V6);
}
fn route(ip_kind: IpAddrKind) {}
Et nous pouvons appeler cette fonction avec l’une où l’autre variante : rust {{#rustdoc_include ../listings/ch06-enums-and-pattern-matching/no-listing-01-defining-enums/src/main.rs:fn_call}}
enum IpAddrKind {
V4,
V6,
}
fn main() {
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
route(IpAddrKind::V4);
route(IpAddrKind::V6);
}
fn route(ip_kind: IpAddrKind) {}
Utiliser des enums présente encore plus d’avantages. En réfléchissant davantage à notre type d’adresse IP, pour l’instant nous n’avons pas de moyen de stocker les données réelles de l’adresse IP ; nous savons seulement de quel type elle est. Étant donné que vous venez d’apprendre les structures au chapitre 5, vous pourriez être tenté de résoudre ce problème avec des structures comme le montre l’encart 6-1.
fn main() {
enum IpAddrKind {
V4,
V6,
}
struct IpAddr {
kind: IpAddrKind,
address: String,
}
let home = IpAddr {
kind: IpAddrKind::V4,
address: String::from("127.0.0.1"),
};
let loopback = IpAddr {
kind: IpAddrKind::V6,
address: String::from("::1"),
};
}
IpAddrKind variant of an IP address using a structIci, nous avons défini une structure IpAddr qui possède deux champs : un champ kind de type IpAddrKind (l’enum que nous avons définie précédemment) et un champ address de type String. Nous avons deux instances de cette structure. La première est home, et elle à la valeur IpAddrKind::V4 comme kind avec les données d’adresse associées 127.0.0.1. La seconde instance est loopback. Elle à l’autre variante de IpAddrKind comme valeur de kind, V6, et à l’adresse ::1 associée. Nous avons utilisé une structure pour regrouper les valeurs kind et address, donc maintenant la variante est associée à la valeur.
Cependant, représenter le même concept en utilisant uniquement une enum est plus concis : plutôt qu’une enum à l’intérieur d’une structure, nous pouvons placer les données directement dans chaque variante de l’enum. Cette nouvelle définition de l’enum IpAddr indique que les variantes V4 et V6 auront toutes deux des valeurs String associées : rust {{#rustdoc_include ../listings/ch06-enums-and-pattern-matching/no-listing-02-enum-with-data/src/main.rs:here}}
fn main() {
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 les données directement à chaque variante de l’enum, il n’y a donc pas besoin d’une structure supplémentaire. Ici, il est aussi plus facile de voir un autre détail du fonctionnement des enums : le nom de chaque variante d’enum que nous définissons devient aussi une fonction qui construit une instance de l’enum. 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 en définissant l’enum.
Il y à un autre avantage à utiliser une enum plutôt qu’une structure : chaque variante peut avoir des types et des quantités différentes de données associées. Les adresses IP en version quatre auront toujours quatre composants numériques dont les valeurs seront comprises entre 0 et 255. Si nous voulions stocker les adresses V4 comme quatre valeurs u8 tout en exprimant les adresses V6 comme une seule valeur String, nous ne pourrions pas le faire avec une structure. Les enums gèrent ce cas avec facilité : rust {{#rustdoc_include ../listings/ch06-enums-and-pattern-matching/no-listing-03-variants-with-different-data/src/main.rs:here}}
fn main() {
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 en version quatre et en version six. Cependant, il s’avère que vouloir stocker des adresses IP et encoder leur type est si courant que [la bibliothèque standard à une définition que nous pouvons utiliser !][IpAddr] Voyons comment la bibliothèque standard définit IpAddr. Elle possède exactement l’enum et les variantes que nous avons définies et utilisées, mais elle intègre les données d’adresse dans les variantes sous la forme de deux structures différentes, qui sont définies différemment pour chaque variante :
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
Ce code illustre que vous pouvez mettre n’importe quel type de données dans une variante d’enum : des chaînes de caractères, des types numériques, ou des structures, par exemple. Vous pouvez même inclure une autre enum ! De plus, les types de la bibliothèque standard ne sont souvent pas beaucoup plus compliqués que ce que vous pourriez concevoir vous-même.
Notez 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 parlerons davantage de l’importation de types dans la portée au chapitre 7.
Examinons un autre exemple d’enum dans l’encart 6-2 : celle-ci à une grande variété de types intégrés dans ses variantes.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {}
Message enum whose variants each store different amounts and types of valuesCette enum a quatre variantes avec des types différents :
Quit: n’à aucune donnée associéeMove: à des champs nommés, comme une structureWrite: inclut une seuleStringChangeColor: inclut trois valeursi32
Définir une enum avec des variantes telles que celles de l’encart 6-2 est similaire à définir différents types de structures, sauf que l’enum n’utilise pas le mot-clé struct et que toutes les variantes sont regroupées sous le type Message. Les structures suivantes pourraient contenir les mêmes données que les variantes de l’enum précédente : rust {{#rustdoc_include ../listings/ch06-enums-and-pattern-matching/no-listing-04-structs-similar-to-message-enum/src/main.rs:here}}
struct QuitMessage; // unit struct
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct
fn main() {}
Mais si nous utilisions les différentes structures, chacune ayant son propre type, nous ne pourrions pas aussi facilement définir une fonction acceptant n’importe lequel de ces types de messages que nous le pourrions avec l’enum Message définie dans l’encart 6-2, qui est un type unique.
Il y a encore une similarité entre les enums et les structures : tout comme nous pouvons définir des méthodes sur les structures avec impl, nous pouvons aussi définir des méthodes sur les enums. Voici une méthode nommée call que nous pourrions définir sur notre enum Message : rust {{#rustdoc_include ../listings/ch06-enums-and-pattern-matching/no-listing-05-methods-on-enums/src/main.rs:here}}
fn main() {
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&self) {
// method body would be defined here
}
}
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 qui à la valeur Message::Write(String::from("hello")), et c’est ce que self sera dans le corps de la méthode call lorsque m.call() s’exécute.
Examinons une autre enum de la bibliothèque standard qui est très courante et utile : Option.
L’enum Option
Cette section explore une étude de cas sur Option, qui est une autre enum 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 peut être rien.
Par exemple, si vous demandez le premier élément d’une liste non vide, vous obtiendrez une valeur. Si vous demandez le premier élément d’une liste vide, vous n’obtiendrez rien. Exprimer ce concept en termes de système de types signifie que le compilateur peut vérifier que vous avez géré tous les cas que vous devriez gérer ; cette fonctionnalité peut prévenir des bogues qui sont extrêmement courants dans d’autres langages de programmation.
La conception de langages de programmation est souvent pensée en termes de fonctionnalités que vous incluez, mais les fonctionnalités que vous excluez sont importantes aussi. Rust n’a pas la fonctionnalité null que beaucoup d’autres langages ont. Null est une valeur qui signifie qu’il n’y a pas de valeur. Dans les langages avec null, les variables peuvent toujours être dans l’un de ces deux états : null ou non-null.
Dans sa présentation de 2009 intitulée « Null Références: The Billion Dollar Mistake » (Les références nulles : l’erreur à un milliard de dollars), Tony Hoare, l’inventeur de null, a déclaré :
Je l’appelle mon erreur à un milliard de dollars. À cette é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 une vérification effectuée automatiquement par le compilateur. Mais je n’ai pas pu résister à la tentation d’ajouter une référence nulle, simplement parce que c’était si facile à implémenter. Cela a conduit à d’innombrables erreurs, vulnérabilités et plantages système, qui ont probablement causé un milliard de dollars de douleur et de dommages au cours des quarante dernières années.
Le problème avec les valeurs null est que si vous essayez d’utiliser une valeur null comme si c’était une valeur non-null, vous obtiendrez une erreur quelconque. Parce que cette propriété null ou non-null est omniprésente, il est extrêmement facile de faire ce type d’erreur.
Cependant, le concept que null essaie d’exprimer reste utile : un null est une valeur qui est actuellement invalide ou absente pour une raison quelconque.
as follows: –> Le problème n’est pas vraiment le concept mais l’implémentation particulière. En tant que tel, Rust n’a pas de null, mais il possède une enum qui peut encoder le concept d’une valeur étant présente ou absente. Cette enum est Option<T>, et elle est [définie par la bibliothèque standard][option] comme suit :
#![allow(unused)]
fn main() {
enum Option<T> {
None,
Some(T),
}
}
L’enum Option<T> est si utile qu’elle est même incluse dans le prelude ; vous n’avez pas besoin de l’importer explicitement dans la portée. Ses variantes sont également incluses dans le prelude : vous pouvez utiliser Some et None directement sans le préfixe Option::. L’enum Option<T> reste simplement une enum ordinaire, 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 couvrirons les génériques plus en détail au chapitre 10. Pour l’instant, tout ce que vous devez savoir est que <T> signifie que la variante Some de l’enum Option peut contenir un élément de données de n’importe quel type, et que chaque type concret utilisé à la place de T fait du type Option<T> global un type différent. Voici quelques exemples d’utilisation de valeurs Option pour contenir des types numériques et des types char : rust {{#rustdoc_include ../listings/ch06-enums-and-pattern-matching/no-listing-06-option-examples/src/main.rs:here}}
fn main() {
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 inférer ces types parce que nous avons spécifié une valeur à l’intérieur de la variante Some. Pour absent_number, Rust nous demande d’annoter le type Option global : le compilateur ne peut pas inférer le type que la variante Some correspondante contiendra en regardant uniquement une valeur None. Ici, nous indiquons à Rust que nous voulons que absent_number soit de type Option<i32>.
Quand nous avons une valeur Some, nous savons qu’une valeur est présente, et la valeur est contenue dans le Some. Quand nous avons une valeur None, dans un certain sens, cela signifie la même chose que null : nous n’avons pas de valeur valide. Alors, pourquoi Option<T> est-il meilleur que null ?
En bref, parce que Option<T> et T (où T peut être n’importe quel type) sont des types différents, le compilateur ne nous laissera pas utiliser une valeur Option<T> comme si c’était assurément une valeur valide. Par exemple, ce code ne compilera pas, parce qu’il essaie d’additionner un i8 et un Option<i8> : rust,ignore,does_not_compile {{#rustdoc_include ../listings/ch06-enums-and-pattern-matching/no-listing-07-cant-use-option-directly/src/main.rs:here}}
fn main() {
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 : console {{#include ../listings/ch06-enums-and-pattern-matching/no-listing-07-cant-use-option-directly/output.txt}}
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
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`
= help: the following other types implement trait `Add<Rhs>`:
`&i8` implements `Add<i8>`
`&i8` implements `Add`
`i8` implements `Add<&i8>`
`i8` implements `Add`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error
Intense ! En fait, ce message d’erreur signifie que Rust ne comprend pas comment additionner un i8 et un Option<i8>, parce que ce sont des types différents. Quand nous avons une valeur d’un type comme i8 en Rust, le compilateur s’assuré que nous avons toujours une valeur valide. Nous pouvons procéder en toute confiance sans avoir à vérifier null avant d’utiliser cette valeur. Ce n’est que lorsque nous avons un Option<i8> (ou quel que soit le type de valeur avec lequel nous travaillons) que nous devons nous soucier de la possibilité de ne pas avoir de valeur, et le compilateur s’assurera que nous gérons ce cas avant d’utiliser la valeur.
En d’autres termes, vous devez convertir un Option<T> en T avant de pouvoir effectuer des opérations de type T avec. 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 alors qu’en réalité il l’est.
Éliminer le risque de supposer incorrectement une valeur non-null vous aide à avoir plus confiance en votre code. Pour avoir une valeur qui peut possiblement être nulle, vous devez explicitement opter pour cela en faisant du type de cette valeur Option<T>. Ensuite, quand vous utilisez cette valeur, vous êtes obligé de gérer explicitement le cas où la valeur est nulle. Partout où une valeur à un type qui n’est pas un Option<T>, vous pouvez supposer en toute sécurité que la valeur n’est pas nulle. C’était une décision de conception délibérée de Rust pour limiter l’omniprésence du null et augmenter la sécurité du code Rust.
Alors, comment obtenir la valeur T d’une variante Some quand vous avez une valeur de type Option<T> pour pouvoir utiliser cette valeur ? L’enum Option<T> possède un grand nombre de méthodes utiles dans diverses situations ; vous pouvez les consulter dans [sa documentation][docs]. Se familiariser avec les méthodes de 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 gère 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 qu’un autre code ne s’exécute que si vous avez une valeur None, et ce code n’a pas de valeur T disponible. L’expression match est une construction de flux de contrôle qui fait exactement cela lorsqu’elle est utilisée avec des enums : elle exécutera un code différent selon la variante de l’enum, et ce code peut utiliser les données à l’intérieur de la valeur correspondante.