Types sommes
En théorie des types, si on a T et U deux types, on appelle type somme le type T + U dont les valeurs contiennent une valeur de type T ou une valeur de type U. Le nom de type somme vient du fait que cette construction est équivalente à celle d’union en théorie des ensembles, or l’union joue un rôle de somme dans l’algèbre des ensembles.
Rust fournit deux implémentations du concept de type somme, les enums et les
unions. Comme nous allons le voir, les unions de Rust sont très proches de
celles de C et C++, mais les enums de Rust sont très différentes de celles de
C++. On devrait plutôt les comparer au type std::variant
qui est enfin
arrivé en C++17, avec toutefois de nombreuses différences.
Enums
Introduction
En Rust, on définit une enum en spécifiant une série de variantes, dont chacune est définie à peu près comme une struct. Le type énuméré ainsi défini se comporte de la façon suivante :
- Le type énuméré se comporte un peu comme un namespace contenant des structs définies par les différentes variantes.
- Une variable du type énuméré peut contenir des valeurs de n’importe de laquelle des variantes ainsi définies, et passer d’une variante à l’autre au fil de son cycle de vie.
Voici un exemple qui illustre les principales possibilités. N’hésitez pas à jouer un peu avec pour vous familiariser avec le concept :
fn main() { #[derive(Debug)] enum Exemple { Rien, Entier(u32), Flottant { inner: f32 }, } let a = Exemple::Rien; let mut b = Exemple::Entier(123); b = Exemple::Flottant { inner: 4.2 }; println!("{a:?}\n{b:?}"); }
Vous noterez au passage que j’ai déclaré mon enum dans une fonction. C’est autorisé en Rust. On peut déclarer presque n’importe quoi dans une fonction, y compris d’autres fonctions.
Bien sûr, il vaut mieux vaut user de cette possibilité avec parcimonie pour ne pas rendre le code illisible, mais dans le cas de code à usage unique comme un garde RAII (type dont la fonction est de nettoyer l’état du programme en cas de panic), ce type de déclaration locale a totalement du sens.
Implémentation
L’implémentation correspond plus ou moins à une tagged union en C, c’est à dire une union C des différentes structs intérieures doublée d’un discriminant entier qui indique à quelle variante de l’union on a affaire actuellement.
Mais comme l’organisation des données en mémoire est flexible par défaut en Rust, selon le nombre de variantes et la nature des types utilisés, le compilateur peut souvent exploiter l’existence de valeurs interdites au sein des types de données intérieurs pour faire en sorte qu’une enum ne prenne pas plus de place en mémoire que la plus grande de ses variantes :
#![allow(unused)] fn main() { enum MaybeChar { Nothing, Some(char), } println!("size_of<char>: {}", std::mem::size_of::<char>()); println!("size_of<MaybeChar>: {}", std::mem::size_of::<MaybeChar>()); }
Discriminant explicite
La notion d’enum class
de C++ est traitée en Rust comme un cas particulier du
type enum général. Si aucune des variantes de l’enum ne contient des données,
on peut indiquer explicitement quel entier doit être utilisé pour stocker chaque
variante en mémoire. Une simple conversion as
permet ensuite de récupérer la
valeur de cet entier.
#![allow(unused)] fn main() { #[derive(Debug)] enum Numerique { Un = 1, Deux, // = 2 Trois, // = 3 } println!("{}", Numerique::Trois as usize); }
Mais la conversion inverse nécessite l’utilisation de unsafe car le compilateur ne peut pas, dans le cas général, vérifier que l’entier fourni en paramètre est une valeur valide pour l’enum cible.
#![allow(unused)] fn main() { #[derive(Debug)] enum Numerique { Un = 1, Deux, // = 2 Trois, // = 3 } // SAFETY: J'ai vérifié que c'est une valeur d'enum valide let num: Numerique = unsafe { std::mem::transmute(2u8) }; println!("{:?}", num); }
Enum vide et !
(Never)
L’enum vide, qui ne possède aucune variante, est un cas particulier intéressant :
#![allow(unused)] fn main() { enum Vide {} }
On ne peut pas créer de valeurs de ce type puisqu’il n’a pas de variante.
enum Vide {}
let x: Vide = /* ??? */;
Par conséquent, tout tuple ou structure contenant une valeur de type Vide
ou
équivalent ne peut pas être construit, et son existence peut être ignorée en
toute sécurité par le compilateur. Le code qui l’utilise peut être supprimé,
les variantes d’enum qui en contiennent peuvent être ignorées, etc.
Du point de vue mathématique, l’enum vide est l’élément neutre de l’ensemble des types énumérés, il ne change pas le type énuméré auquel on l’ajoute. C’est le zéro de l’addition des types.
Dans un système de type, ce genre de type sans valeur est utilisé pour représenter des situations impossibles. Par exemple la valeur retournée par une fonction qui arrête le programme :
let x: /* ??? */ = std::proces::abort();
Rust définit donc une enum vide standard qui s’écrit !
et se prononce “Never”
pour représenter ce genre de situation. Cette enum est “retournée” par toutes
les expressions qui ne retournent jamais de valeur à l’appelant : boucles
infinies, arrêt du programme, etc. Et puisque ce type n’a pas d’importance, le
compilateur la “convertit” librement vers n’importe quel autre type…
#![allow(unused)] fn main() { let n: u32 = std::process::abort(); // Ce code compile }
…ce qui prend tout son sens dans un contexte de gestion des erreurs, par exemple quand on se retrouve avec ce genre d’expression :
#![allow(unused)] fn main() { let suppose_vrai = true; // if .. else en Rust fonctionne comme l'opérateur ternaire ? : en C++ let reponse = if suppose_vrai { 42 } else { std::process::abort() }; println!("{reponse}"); }
Motifs
Introduction
Pour l’instant, je vous ai montré comment on déclare un type énuméré, et comment on définit des variables de ce type. Reste à savoir comment, muni d’une valeur de ce type, on peut déterminer à quelle variante du type on a affaire, et utiliser les données que cette variante contient.
La solution de Rust a se problème s’appelle les motifs (patterns). Ce terme exotique peut faire peur, mais en réalité nous en utilisons depuis la toute première déclaration de variable du cours :
#![allow(unused)] fn main() { let reponse = 42; // ^^^^^^^ // MOTIF }
En Rust, ce qui suit le mot-clé let
que nous utilisons pour déclarer nos
variables n’est pas forcé d’être un nom de variables, cela peut être toutes
sortes d’autres choses. Quelques exemples :
fn main() { #[derive(Debug)] struct S { x: i32, y: usize, } let obj = S { x: 12, y: 34 }; let tup = (123, 4.56); // Déstructuration d'un tuple let (a, b) = tup; println!("{tup:?} -> {a} et {b}"); // Déstructuration d'une structure let S { x: abc, y: def } = obj; println!("{obj:?} -> {abc} et {def}"); }
Ce que l’on met après let
et avant le signe =
s’appelle un motif
(pattern). Plus précisément, les motifs qui sont utilisés dans l’exemple
ci-dessus sont des motifs de
déstructuration.
Déstructuration
La syntaxe d’un motif de déstructuration est très similaire à celle qu’on utilise pour écrire des valeurs d’un type structuré dans le code, sauf que…
- Là où on écrirait normalement les valeurs des données membres, on écrit des
noms de variable. Cela crée des variables portant ces noms, qui “capturent”
les valeurs membres correspondantes dans l’expression à droite du signe
=
. - On peut ignorer un membre en utilisant
_
à la place d’un nom de variable :#![allow(unused)] fn main() { let (x, _) = (12, 34); println!("{x}"); }
- On peut ignorer tous les membres qui ne nous intéressent pas avec
..
:#![allow(unused)] fn main() { struct S { x: u8, y: u16, z: u32, t: u64 } let val = S { x: 1, y: 2, z: 3, t: 4 }; let S { z: coord_z, .. } = val; println!("{coord_z}"); }
- Quand les membres sont nommés (pas comme les tuples), on peut utiliser une
syntaxe raccourcie qui reprend les noms des membres :
#![allow(unused)] fn main() { struct S { x: u8, y: u16, z: u32, t: u64 } let val = S { x: 1, y: 2, z: 3, t: 4 }; let S { x, y, z, t } = val; println!("{x} {y} {z} {t}"); }
match
Tout ceci est très bien, mais il nous manque encore un ingrédient pour déstructurer nos enums. En effet, le code qui suit ne compile pas :
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Int(42); // ... beaucoup de code plus tard ... let IntFloat::Int(x) = val; // Erreur de compilation }
La raison pour laquelle ce code ne compile pas est qu’une valeur val
de type
IntFloat
ne contient pas forcément une valeur de la variante Int
de
IntFloat
. Elle peut aussi contenir une valeur de la variante Float
. Et hors
des cas triviaux, le compilateur ne sait pas ce qu’il en est. Pour qu’on n’ait
pas des motifs qui marchent ou pas selon ce que l’analyseur statique du
compilateur a réussi à prouver, ce type de code est donc systématiquement
rejeté de façon pessimiste.
En termes plus formels, les motifs utilisés après let
doivent être
irréfutables,
c’est à dire que le compilateur ne doit pas être capable de trouver une variante
du type à droite qui n’est pas couverte par le motif de gauche. Cela n’est pas
possible avec les types énumérés, sauf avec le motif trivial “affectation de
variable” :
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Int(42); let val2 = val; }
Pour gérer les types énumérés, il faut qu’on ait la possibilité de choisir un
motif parmi plusieurs. Ce travail est assuré par match
en Rust :
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Int(42); match val { IntFloat::Int(i) => println!("Entier {i}"), IntFloat::Float(f) => println!("Flotant {f}"), } }
Avec match
, c’est l’ensemble des alternatives proposées qui doit être
irréfutable. Autrement dit, on ne peut pas oublier une variante d’une enum
quand on utilise match
, sinon cela causera une erreur de compilation. Quand
on ne veut pas traiter tous les cas, on doit le dire explicitement en utilisant
un motif qui matche toujours en fin de liste :
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Float(4.2); match val { IntFloat::Int(i) => println!("Entier {i}"), a => println!("{a:?} n'est pas entier, je m'en fiche"), } }
Raccourcis
Pour le cas où on veut traiter un seul cas et on s’en fiche des autres, on peut
alléger la syntaxe avec le raccourci if let
. Ce code est équivalent au
précédent.
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Int(42); if let IntFloat::Int(i) = val { println!("Entier {i}"); } else { println!("{val:?} n'est pas entier, je m'en fiche"); } }
Souvent, on veut extraire les valeurs quand le motif attendu est présent et
arrêter le traitement sinon. On pourrait le faire avec if let
, mais ça reste
encore un peu laborieux…
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Int(42); // match et if let sont des expressions, qui peuvent retourner des valeurs let i = if let IntFloat::Int(i) = val { i } else { return }; println!("Entier {i}"); }
A la place, mieux vaut utiliser let .. else { .. }
:
#![allow(unused)] fn main() { #[derive(Debug)] enum IntFloat { Int(i32), Float(f32), } let mut val = IntFloat::Int(42); let IntFloat::Int(i) = val else { return }; println!("Entier {i}"); }
Autres motifs
La destructuration n’est pas le seul type de motif supporté par Rust. Voici quelques autres exemples de motifs supportés :
fn main() { let compteur = 42; let nombre = match compteur { // Litérale 1 => "Un", // Intervalle 2 <= x <= 6 2..=6 => "Plusieurs", // Plusieurs alternatives compatibles 24 | 42 => "Ce qu'il faut", // Conditions x if x < 42 => "Beaucoup", // Le premier motif qui colle est sélectionné, si aucune des // conditions ci-dessus n'est remplie ce sera celui-ci. _ => "Trop", }; println!("{nombre}"); }
On trouve la liste ce qui est possible avec plein d’autres exemples dans la doc de référence et les pages associées de Rust By Example, mais ces exemples utilisent plusieurs fonctionnalités que nous n’avons pas encore abordées, notamment les références, donc ne vous attendez pas à tout comprendre tout de suite.
Unions
Pour des raisons d’interopérabilité avec le C, Rust doit aussi supporter les unions “à la C”, celles-ci sont donc disponibles et utilisables via le code unsafe :
#![allow(unused)] fn main() { union IntFloatBool { i: u32, f: f32, b: bool, } let ifb = IntFloatBool { i: u32::MAX }; // Utilisation correcte : l'union contient une valeur de type u32 let x = unsafe { ifb.i }; // Utilisation correcte : les octets de u32 peuvent être réinterprété en f32 let y = unsafe { ifb.f }; // Equivalent standard du code ci-dessus, pas besoin d'unsafe let y2 = f32::from_bits(x); // Utilisation INCORRECTE : le premier octet de u32::MAX n'est pas une // représentation valide d'un booléen. Ce code cause du comportement indéfini. /* let z = unsafe { ifb.b }; */ }
Il n’est presque jamais nécessaire de s’en servir hors interaction avec le C/++ parce que tous les cas d’utilisation courants sont couverts par la bibliothèque standard avec une interface plus sûre.