Définir une énumération
Les énumérations permettent de définir des types de données personnalisés de manière différente que vous l'avez fait avec les structures. Imaginons une situation que nous voudrions exprimer avec du code et regardons pourquoi les énumérations sont utiles et plus appropriées que les structures dans ce cas. Disons que nous avons besoin de travailler avec des adresses IP. Pour le moment, il existe deux normes principales pour les adresses IP : la version quatre et la version six. Comme ce seront les seules possibilités d'adresse IP que notre programme va rencontrer, nous pouvons énumérer toutes les variantes possibles, d'où vient le nom de l'énumération.
N'importe quelle adresse IP peut être soit une adresse en version quatre, soit en version six, mais pas les deux en même temps. Cette propriété des adresses IP est appropriée à la structure de données d'énumérations, car une valeur de l'énumération ne peut être qu'une de ses variantes. Les adresses en version quatre et six sont toujours fondamentalement des adresses IP, donc elles doivent être traitées comme étant du même type lorsque le code travaille avec des situations qui s'appliquent à n'importe quelle sorte d'adresse IP.
Nous pouvons exprimer ce concept dans le code en définissant une énumération
SorteAdresseIp
et en listant les différentes sortes possibles d'adresses IP
qu'elle peut avoir, V4
et V6
. Ce sont les variantes de l'énumération :
enum SorteAdresseIp { V4, V6, } fn main() { let quatre = SorteAdresseIp::V4; let six = SorteAdresseIp::V6; router(SorteAdresseIp::V4); router(SorteAdresseIp::V6); } fn router(sorte_ip: SorteAdresseIp) { }
SorteAdresseIp
est maintenant un type de données personnalisé que nous pouvons
utiliser n'importe où dans notre code.
Les valeurs d'énumérations
Nous pouvons créer des instances de chacune des deux variantes de
SorteAdresseIp
de cette manière :
enum SorteAdresseIp { V4, V6, } fn main() { let quatre = SorteAdresseIp::V4; let six = SorteAdresseIp::V6; router(SorteAdresseIp::V4); router(SorteAdresseIp::V6); } fn router(sorte_ip: SorteAdresseIp) { }
Remarquez que les variantes de l'énumération sont dans un espace de nom qui se
situe avant leur nom, et nous utilisons un double deux-points pour les séparer
tous les deux. C'est utile car maintenant les deux valeurs SorteAdresseIp::V4
et SorteAdresseIp::V6
sont du même type : SorteAdresseIp
. Ensuite, nous
pouvons, par exemple, définir une fonction qui accepte n'importe quelle
SorteAdresseIp
:
enum SorteAdresseIp { V4, V6, } fn main() { let quatre = SorteAdresseIp::V4; let six = SorteAdresseIp::V6; router(SorteAdresseIp::V4); router(SorteAdresseIp::V6); } fn router(sorte_ip: SorteAdresseIp) { }
Et nous pouvons appeler cette fonction avec chacune des variantes :
enum SorteAdresseIp { V4, V6, } fn main() { let quatre = SorteAdresseIp::V4; let six = SorteAdresseIp::V6; router(SorteAdresseIp::V4); router(SorteAdresseIp::V6); } fn router(sorte_ip: SorteAdresseIp) { }
L'utilisation des énumérations a encore plus d'avantages. En étudiant un peu plus notre type d'adresse IP, nous constatons que pour le moment, nous ne pouvons pas stocker la donnée de l'adresse IP ; nous savons seulement de quelle sorte elle est. Avec ce que vous avez appris au chapitre 5, vous pourriez être tenté de résoudre ce problème avec des structures comme dans l'encart 6-1.
fn main() { enum SorteAdresseIp { V4, V6, } struct AdresseIp { sorte: SorteAdresseIp, adresse: String, } let local = AdresseIp { sorte: SorteAdresseIp::V4, adresse: String::from("127.0.0.1"), }; let rebouclage = AdresseIp { sorte: SorteAdresseIp::V6, adresse: String::from("::1"), }; }
Ainsi, nous avons défini une structure AdresseIp
qui a deux champs : un champ
sorte
qui est du type SorteAdresseIp
(l'énumération que nous avons définie
précédemment) et un champ adresse
qui est du type String
. Nous avons deux
instances de cette structure. La première est local
, et a la valeur
SorteAdresseIp::V4
pour son champ sorte
, associé à la donnée d'adresse qui
est 127.0.0.1
. La seconde instance est rebouclage
. Elle a comme valeur de
champ sorte
l'autre variante de SorteAdresseIp
, V6
, et a l'adresse::1
qui lui est associée. Nous avons utilisé une structure pour relier ensemble la
sorte
et l'adresse
, donc maintenant la variante est liée à la valeur.
Cependant, suivre le même principe en utilisant uniquement une énumération est
plus concis : plutôt que d'utiliser une énumération dans une structure, nous
pouvons insérer directement la donnée dans chaque variante de l'énumération.
Cette nouvelle définition de l'énumération AdresseIp
indique que chacune des
variantes V4
et V6
auront des valeurs associées de type String
:
fn main() { enum AdresseIp { V4(String), V6(String), } let local = AdresseIp::V4(String::from("127.0.0.1")); let rebouclage = AdresseIp::V6(String::from("::1")); }
Nous relions les données de chaque variante directement à l'énumération, donc il
n'est pas nécessaire d'avoir une structure en plus. Ceci nous permet de voir
plus facilement un détail de fonctionnement des énumérations : le nom de chaque
variante d'énumération que nous définissons devient aussi une fonction qui
construit une instance de l'énumération. Ainsi, AdresseIp::V4()
est un appel
de fonction qui prend une String
en argument et qui retourne une instance du
type AdresseIp
. Nous obtenons automatiquement cette fonction de constructeur
qui est définie lorsque nous définissons l'énumération.
Il y a un autre avantage à utiliser une énumération plutôt qu'une structure :
chaque variante peut stocker des types différents, et aussi avoir une quantité
différente de données associées. Les adresses IP version quatre vont toujours
avoir quatre composantes numériques qui auront une valeur entre 0 et 255. Si
nous voulions stocker les adresses V4
avec quatre valeurs de type u8
mais
continuer à stocker les adresses V6
dans une String
, nous ne pourrions pas
le faire avec une structure. Les énumérations permettent de faire cela
facilement :
fn main() { enum AdresseIp { V4(u8, u8, u8, u8), V6(String), } let local = AdresseIp::V4(127, 0, 0, 1); let rebouclage = AdresseIp::V6(String::from("::1")); }
Nous avons vu différentes manières de définir des structures de données pour
enregistrer des adresses IP en version quatre et version six. Cependant, il
s'avère que vouloir stocker des adresses IP et identifier de quelle sorte elles
sont est si fréquent que la bibliothèque standard a une définition que nous
pouvons utiliser ! Analysons comment la bibliothèque
standard a défini IpAddr
(l'équivalent de notre AdresseIp
) : nous retrouvons
la même énumération et les variantes que nous avons définies et utilisées, mais
stocke les données d'adresse dans des variantes dans deux structures
différentes, qui sont définies chacune pour chaque variante :
#![allow(unused)] fn main() { struct Ipv4Addr { // -- code masqué ici -- } struct Ipv6Addr { // -- code masqué ici -- } enum IpAddr { V4(Ipv4Addr), V6(Ipv6Addr), } }
Ce code montre comment vous pouvez insérer n'importe quel type de données dans une variante d'énumération : des chaînes de caractères, des nombres ou des structures, par exemple. Vous pouvez même y intégrer d'autres énumérations ! Par ailleurs, les types de la bibliothèque standard ne sont parfois pas plus compliqués que ce que vous pourriez inventer.
Notez aussi que même si la bibliothèque standard embarque une définition de
IpAddr
, nous pouvons quand même créer et utiliser notre propre définition de
ce type sans avoir de conflit de nom car nous n'avons pas importé cette
définition de la bibliothèque standard dans la portée. Nous verrons plus en
détail comment importer les types dans la portée au chapitre 7.
Analysons un autre exemple d'une énumération dans l'encart 6-2 : celle-ci a une grande diversité de types dans ses variantes.
enum Message { Quitter, Deplacer { x: i32, y: i32 }, Ecrire(String), ChangerCouleur(i32, i32, i32), } fn main() {}
Cette énumération a quatre variantes avec des types différents :
Quitter
n'a pas du tout de donnée associée.Deplacer
intègre une structure anonyme en son sein.Ecrire
intègre une seuleString
.ChangerCouleur
intègre trois valeurs de typei32
.
Définir une énumération avec des variantes comme celles dans l'encart 6-2
ressemble à la définition de différentes sortes de structures, sauf que
l'énumération n'utilise pas le mot-clé struct
et que toutes les variantes sont
regroupées ensemble sous le type Message
. Les structures suivantes peuvent
stocker les mêmes données que celles stockées par les variantes précédentes :
struct MessageQuitter; // une structure unité struct MessageDeplacer { x: i32, y: i32, } struct MessageEcrire(String); // une structure tuple struct MessageChangerCouleur(i32, i32, i32); // une structure tuple fn main() {}
Mais si nous utilisions les différentes structures, qui ont chacune leur propre
type, nous ne pourrions pas définir facilement une fonction qui prend en
paramètre toutes les sortes de messages, tel que nous pourrions le faire avec
l'énumération Message
que nous avons définie dans l'encart 6-2, qui est un
seul type.
Il y a un autre point commun entre les énumérations et les structures : tout
comme on peut définir des méthodes sur les structures en utilisant impl
, on
peut aussi définir des méthodes sur des énumérations. Voici une méthode appelée
appeler
que nous pouvons définir sur notre énumération Message
:
fn main() { enum Message { Quitter, Deplacer { x: i32, y: i32 }, Ecrire(String), ChangerCouleur(i32, i32, i32), } impl Message { fn appeler(&self) { // le corps de la méthode sera défini ici } } let m = Message::Ecrire(String::from("hello")); m.appeler(); }
Le corps de la méthode va utiliser self
pour obtenir la valeur sur laquelle
nous avons utilisé la méthode. Dans cet exemple, nous avons créé une variable
m
qui a la valeur Message::Ecrire(String::from("hello"))
, et cela sera ce
que self
aura comme valeur dans le corps de la méthode appeler
quand nous
lancerons m.appeler()
.
Regardons maintenant une autre énumération de la bibliothèque standard qui est
très utilisée et utile : Option
.
L'énumération Option
et ses avantages par rapport à la valeur null
Cette section étudie le cas de Option
, qui est une autre énumération définie
dans la bibliothèque standard. Le type Option
décrit un scénario très courant
où une valeur peut être soit quelque chose, soit rien du tout. Par exemple, si
vous demandez le premier élément dans une liste non vide, vous devriez obtenir
une valeur. Si vous demandez le premier élément d'une liste vide, vous ne
devriez rien obtenir. Exprimer ce concept avec le système de types implique que
le compilateur peut vérifier si vous avez géré tous les cas que vous pourriez
rencontrer ; cette fonctionnalité peut éviter des bogues qui sont très courants
dans d'autres langages de programmation.
La conception d'un langage de programmation est souvent pensée en fonction des fonctionnalités qu'on inclut, mais les fonctionnalités qu'on refuse sont elles aussi importantes. Rust n'a pas de fonctionnalité null qu'ont de nombreux langages. Null est une valeur qui signifie qu'il n'y a pas de valeur à cet endroit. Avec les langages qui utilisent null, les variables peuvent toujours être dans deux états : null ou non null.
Dans sa thèse de 2009 “Null References: The Billion Dollar Mistake” (les références nulles : l'erreur à un milliard de dollars), Tony Hoare, l'inventeur de null, a écrit ceci :
Je l'appelle mon erreur à un milliard de dollars. À cette époque, je concevais le premier système de type complet pour des références dans un langage orienté objet. Mon objectif était de garantir que toutes les utilisations des références soient totalement sûres, et soient vérifiées automatiquement par le compilateur. Mais je n'ai pas pu résister à la tentation d'inclure la référence nulle, simplement parce que c'était si simple à implémenter. Cela a conduit à d'innombrables erreurs, vulnérabilités, et pannes systèmes, qui ont probablement causé un milliard de dollars de dommages au cours des quarante dernières années.
Le problème avec les valeurs nulles, c'est que si vous essayez d'utiliser une valeur nulle comme si elle n'était pas nulle, vous obtiendrez une erreur d'une façon ou d'une autre. Comme cette propriété nulle ou non nulle est omniprésente, il est très facile de faire cette erreur.
Cependant, le concept que null essaye d'exprimer reste utile : une valeur nulle est une valeur qui est actuellement invalide ou absente pour une raison ou une autre.
Le problème ne vient pas vraiment du concept, mais de son implémentation. C'est
pourquoi Rust n'a pas de valeurs nulles, mais il a une énumération qui décrit le
concept d'une valeur qui peut être soit présente, soit absente. Cette
énumération est Option<T>
, et elle est définie dans la bibliothèque
standard comme ci-dessous :
#![allow(unused)] fn main() { enum Option<T> { None, Some(T), } }
L'énumération Option<T>
est tellement utile qu'elle est intégrée dans l'étape
préliminaire ; vous n'avez pas besoin de l'importer explicitement dans la
portée. Ses variantes sont aussi intégrées dans l'étape préliminaire : vous
pouvez utiliser directement Some
(quelque chose) et None
(rien) sans
les préfixer par Option::
. L'énumération Option<T>
reste une énumération
normale, et Some(T)
ainsi que None
sont toujours des variantes de type
Option<T>
.
La syntaxe <T>
est une fonctionnalité de Rust que nous n'avons pas encore
abordée. Il s'agit d'un paramètre de type générique, et nous verrons la
généricité plus en détail au chapitre 10. Pour le moment, dites-vous que ce
<T>
signifie que la variante Some
de l'énumération Option
peut stocker un
élément de donnée de n'importe quel type, et que chaque type concret qui est
utilisé à la place du T
transforme tout le type Option<T>
en un type
différent. Voici quelques exemples d'utilisation de valeurs de Option
pour
stocker des types de nombres et des types de chaînes de caractères :
fn main() { let un_nombre = Some(5); let une_chaine = Some("une chaîne"); let nombre_absent: Option<i32> = None; }
La variable un_nombre
est du type Option<i32>
. Mais la variable une_chaine
est du type Option<&str>
, qui est un tout autre type. Rust peut déduire ces
types car nous avons renseigné une valeur dans la variante Some
. Pour
nombre_absent
, Rust nécessite que nous annotions le type de tout le Option
:
le compilateur ne peut pas déduire le type qui devrait être stocké dans la
variante Some
à partir de la valeur None
. Ici, nous avons renseigné à Rust
que nous voulions que nombre_absent
soit du type Option<i32>
.
Lorsque nous avons une valeur Some
, nous savons que la valeur est présente et
que la valeur est stockée dans le Some
. Lorsque nous avons une valeur None
,
en quelque sorte, cela veut dire la même chose que null : nous n'avons pas une
valeur valide. Donc pourquoi obtenir Option<T>
est meilleur que d'avoir null ?
En bref, comme Option<T>
et T
(où T
représente n'importe quel type) sont
de types différents, le compilateur ne va pas nous autoriser à utiliser une
valeur Option<T>
comme si cela était bien une valeur valide. Par exemple, le
code suivant ne se compile pas car il essaye d'additionner un i8
et une
Option<i8>
:
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);
let somme = x + y;
}
Si nous lançons ce code, nous aurons un message d'erreur comme celui-ci :
$ 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 somme = x + y;
| ^ no implementation for `i8 + Option<i8>`
|
= help: the trait `Add<Option<i8>>` is not implemented for `i8`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` due to previous error
Intense ! Effectivement, ce message d'erreur signifie que Rust ne comprend pas
comment additionner un i8
et une Option<i8>
, car ils sont de types
différents. Quand nous avons une valeur d'un type comme i8
avec Rust, le
compilateur va s'assurer que nous avons toujours une valeur valide. Nous pouvons
continuer en toute confiance sans avoir à vérifier que cette valeur n'est pas
nulle avant de l'utiliser. Ce n'est que lorsque nous avons une Option<i8>
(ou
tout autre type de valeur avec lequel nous travaillons) que nous devons nous
inquiéter de ne pas avoir de valeur, et le compilateur va s'assurer que nous
gérons ce cas avant d'utiliser la valeur.
Autrement dit, vous devez convertir une Option<T>
en T
pour pouvoir faire
avec elle des opérations du type T
. Généralement, cela permet de résoudre l'un
des problèmes les plus courants avec null : supposer qu'une valeur n'est pas
nulle alors qu'en réalité, elle l'est.
Eliminer le risque que des valeurs nulles puissent être mal gérées vous aide à
être plus confiant en votre code. Pour avoir une valeur qui peut
potentiellement être nulle, vous devez l'indiquer explicitement en déclarant
que le type de cette valeur est Option<T>
. Ensuite, quand vous utiliserez
cette valeur, il vous faudra gérer explicitement le cas où cette valeur est
nulle. Si vous utilisez une valeur qui n'est pas une Option<T>
, alors vous
pouvez considérer que cette valeur ne sera jamais nulle sans prendre de
risques. Il s'agit d'un choix de conception délibéré de Rust pour limiter
l'omniprésence de null et augmenter la sécurité du code en Rust.
Donc, comment récupérer la valeur de type T
d'une variante Some
quand vous
avez une valeur de type Option<T>
afin de l'utiliser ? L'énumération
Option<T>
a un large choix de méthodes qui sont plus ou moins utiles selon les
cas ; vous pouvez les découvrir dans sa documentation. Se
familiariser avec les méthodes de Option<T>
peut être très utile dans votre
aventure avec Rust.
De manière générale, pour pouvoir utiliser une valeur de Option<T>
, votre code
doit gérer chaque variante. On veut que du code soit exécuté uniquement quand on
a une valeur Some(T)
, et que ce code soit autorisé à utiliser la valeur de
type T
à l'intérieur. On veut aussi qu'un autre code soit exécuté si on a une
valeur None
, et ce code n'aura pas de valeur de type T
de disponible.
L'expression match
est une structure de contrôle qui fait bien ceci
lorsqu'elle est utilisée avec les énumérations : elle va exécuter du code
différent en fonction de quelle variante de l'énumération elle obtient, et ce
code pourra utiliser la donnée présente dans la valeur correspondante.