Enumérations#

Les énumérations (enums) sont l’un des mécanismes les plus expressifs de Rust. Là où une struct regroupe plusieurs champs simultanément, une enum représente une valeur qui peut prendre l’une parmi plusieurs variantes mutuellement exclusives. Combinées au filtrage par motifs (chapitre 10), elles permettent de modéliser des domaines complexes avec une sécurité vérifiée à la compilation.

Définition et variantes simples#

Définition 49 (Enumération)

Une énumération (enum) définit un type dont les valeurs appartiennent à un ensemble fini de variantes. Chaque variante est un constructeur nommé. Sous sa forme la plus simple, une enum ressemble à un type énuméré classique, sans données associées.

#[derive(Debug)]
enum Direction {
    Nord,
    Sud,
    Est,
    Ouest,
}

let cap = Direction::Nord;
println!("Cap : {:?}", cap);
Cap : Nord

Les variantes vivent dans l’espace de noms de l’énumération : on les désigne par Direction::Nord, Direction::Sud, etc. On peut ramener les variantes dans la portée courante avec use.

#[derive(Debug)]
enum Saison {
    Printemps,
    Ete,
    Automne,
    Hiver,
}

use Saison::*;

let s = Automne;
println!("Saison : {:?}", s);
Saison : Automne

Variantes avec données#

La véritable puissance des enum en Rust réside dans la possibilité d’associer des données à chaque variante. Deux formes existent : la forme tuple et la forme struct.

Définition 50 (Variante avec données)

Une variante tuple contient des champs positionnels : Variante(T1, T2, ...). Une variante struct contient des champs nommés : Variante { champ1: T1, champ2: T2, ... }. Chaque variante peut porter des types différents ; une même enum peut mélanger des variantes simples, tuple et struct.

Exemple 48 (Enum avec données hétérogènes)

Voir le code ci-dessous.

#[derive(Debug)]
enum Forme {
    Cercle(f64),                          // variante tuple (rayon)
    Rectangle { largeur: f64, hauteur: f64 }, // variante struct
    Point,                                 // variante simple
}

let figures = [
    Forme::Cercle(3.0),
    Forme::Rectangle { largeur: 4.0, hauteur: 5.0 },
    Forme::Point,
];

for f in &figures {
    println!("{:?}", f);
}
Cercle(3.0)
Rectangle { largeur: 4.0, hauteur: 5.0 }
Point
()

Chaque variante agit comme un constructeur : Forme::Cercle(3.0) crée une valeur de type Forme contenant un flottant. Cette capacité à regrouper des données de structures différentes sous un même type est ce qui distingue les enum Rust des énumérations classiques du C.

Méthodes sur les énumérations#

Comme pour les structures, on définit des méthodes sur une enum à l’aide d’un bloc impl. Le filtrage par motifs permet d’adapter le comportement à chaque variante.

Exemple 49 (Bloc impl sur une enum)

Voir le code ci-dessous.

#[derive(Debug)]
enum Forme {
    Cercle(f64),
    Rectangle { largeur: f64, hauteur: f64 },
    Point,
}

impl Forme {
    fn aire(&self) -> f64 {
        match self {
            Forme::Cercle(r) => std::f64::consts::PI * r * r,
            Forme::Rectangle { largeur, hauteur } => largeur * hauteur,
            Forme::Point => 0.0,
        }
    }

    fn description(&self) -> String {
        match self {
            Forme::Cercle(r) => format!("Cercle de rayon {}", r),
            Forme::Rectangle { largeur, hauteur } =>
                format!("Rectangle {}×{}", largeur, hauteur),
            Forme::Point => String::from("Point"),
        }
    }
}

let c = Forme::Cercle(2.5);
println!("{} — aire = {:.2}", c.description(), c.aire());

let r = Forme::Rectangle { largeur: 3.0, hauteur: 7.0 };
println!("{} — aire = {:.2}", r.description(), r.aire());
Cercle de rayon 2.5 — aire = 19.63
Rectangle 3×7 — aire = 21.00

Option<T> : l’alternative à null#

Définition 51 (Option<T>)

Le type Option<T> est une énumération de la bibliothèque standard définie comme suit :
enum Option<T> { Some(T), None }
La variante Some(T) contient une valeur de type T ; la variante None représente l’absence de valeur. Option<T> est le mécanisme idiomatique de Rust pour exprimer qu’une valeur peut être absente.

Remarque 36

En 2009, Tony Hoare, inventeur de la référence nulle, a qualifié son invention de billion-dollar mistake. Le problème fondamental de null est qu’il habite tous les types : toute référence peut être nulle, et le compilateur ne peut pas garantir que le programmeur a vérifié la nullité avant l’accès. En Rust, l’absence de valeur est explicite grâce à Option<T> : le compilateur force le programmeur à traiter le cas None avant de pouvoir accéder à la valeur contenue dans Some.

Exemple 50 (Utilisation de base d’Option)

Voir le code ci-dessous.

fn trouver_premier_pair(nombres: &[i32]) -> Option<i32> {
    for &n in nombres {
        if n % 2 == 0 {
            return Some(n);
        }
    }
    None
}

let a = [1, 3, 5, 8, 11];
let b = [1, 3, 5, 7];

println!("Dans {:?} : {:?}", a, trouver_premier_pair(&a));
println!("Dans {:?} : {:?}", b, trouver_premier_pair(&b));
Dans [1, 3, 5, 8, 11] : Some(8)
Dans [1, 3, 5, 7] : None

Méthodes courantes sur Option<T>#

Les méthodes suivantes évitent de recourir systématiquement à un match explicite.

let quelque: Option<i32> = Some(42);
let rien: Option<i32> = None;

// is_some / is_none : test de présence
println!("quelque.is_some() = {}", quelque.is_some());
println!("rien.is_none()    = {}", rien.is_none());

// unwrap : extrait la valeur ou panique
println!("quelque.unwrap()  = {}", quelque.unwrap());

// unwrap_or : valeur par défaut si None
println!("rien.unwrap_or(0) = {}", rien.unwrap_or(0));
quelque.is_some() = true
rien.is_none()    = true
quelque.unwrap()  = 42
rien.unwrap_or(0) = 0
{
let valeur: Option<i32> = Some(5);

// map : transforme la valeur interne sans toucher à l'enveloppe
let double = valeur.map(|x| x * 2);
println!("map(*2) : {:?}", double); // Some(10)

// and_then (flatmap) : enchaîne une opération qui retourne elle-même un Option
let resultat = valeur.and_then(|x| {
    if x > 0 { Some(x * 10) } else { None }
});
println!("and_then : {:?}", resultat); // Some(50)
}
map(*2) : Some(10)
and_then : Some(50)
()

Remarque 37

L’appel à unwrap() provoque une panique si la valeur est None. En dehors des prototypes et des tests, on lui préfère unwrap_or, unwrap_or_else, ou le filtrage par motifs, qui garantissent un traitement explicite du cas absent.

Result<T, E> : gestion des erreurs typée#

Définition 52 (Result<T, E>)

Le type Result<T, E> est une énumération de la bibliothèque standard définie comme suit :
enum Result<T, E> { Ok(T), Err(E) }
La variante Ok(T) contient la valeur de succès ; la variante Err(E) contient la valeur d’erreur. Result est le mécanisme standard de Rust pour la gestion des erreurs récupérables.

Exemple 51 (Fonction retournant un Result)

Voir le code ci-dessous.

fn diviser(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("division par zéro"))
    } else {
        Ok(a / b)
    }
}

println!("10 / 3   = {:?}", diviser(10.0, 3.0));
println!("10 / 0   = {:?}", diviser(10.0, 0.0));
10 / 3   = Ok(3.3333333333333335)
10 / 0   = Err("division par zéro")

Méthodes courantes sur Result<T, E>#

{
let succes: Result<i32, String> = Ok(42);
let _echec: Result<i32, String> = Err(String::from("erreur"));

// unwrap / expect : extraire ou paniquer
println!("succes.unwrap() = {}", succes.clone().unwrap());
// echec.unwrap() provoquerait une panique

// expect : comme unwrap, avec un message personnalisé
let val = succes.expect("impossible d'obtenir la valeur");
println!("expect : {}", val);
}
succes.unwrap() = 42
expect : 42
()
{
let succes: Result<i32, String> = Ok(10);
let echec: Result<i32, String> = Err(String::from("erreur"));

// map : transforme la valeur Ok sans toucher à Err
let double = succes.as_ref().map(|x| x * 2);
println!("map : {:?}", double); // Ok(20)

// map_err : transforme la valeur Err sans toucher à Ok
let modifie = echec.map_err(|e| format!("ERREUR: {}", e));
println!("map_err : {:?}", modifie);

// and_then : enchaîne une opération faillible
let chaine = succes.and_then(|x| {
    if x > 0 { Ok(x + 100) } else { Err(String::from("valeur négative")) }
});
println!("and_then : {:?}", chaine); // Ok(110)
}
map : Ok(20)
map_err : Err("ERREUR: erreur")
and_then : Ok(110)
()

L’opérateur ?#

L’opérateur ?, placé après une expression de type Result, extrait la valeur Ok si elle est présente. Si l’expression est Err, il provoque un retour anticipé de la fonction englobante avec cette erreur. Il remplace avantageusement les match imbriqués.

Exemple 52 (Propagation d’erreurs avec ?)

Voir le code ci-dessous.

fn parser_et_doubler(texte: &str) -> Result<i32, std::num::ParseIntError> {
    let n: i32 = texte.parse()?; // retourne Err(...) si l'analyse échoue
    Ok(n * 2)
}

println!("\"21\" -> {:?}", parser_et_doubler("21"));
println!("\"abc\" -> {:?}", parser_et_doubler("abc"));
"21" -> Ok(42)
"abc" -> Err(ParseIntError { kind: InvalidDigit })

Remarque 38

L’opérateur ? ne peut être utilisé que dans une fonction dont le type de retour est compatible (Result, Option, ou tout type implémentant FromResidual). La gestion des erreurs en Rust sera approfondie au chapitre 12, où l’on abordera la conversion entre types d’erreurs, le trait Error et les stratégies de propagation à plus grande échelle.

Représentation mémoire des enum#

Proposition 7 (Taille en mémoire d’une enum)

La taille en mémoire d’une enum est déterminée par la formule :
\(\text{taille} = \text{taille de la plus grande variante} + \text{taille du discriminant}\)
Le discriminant est un entier (généralement un octet si l’enum a 256 variantes ou moins) qui identifie la variante active. Le compilateur peut appliquer des optimisations : par exemple, Option<&T> n’occupe que la taille d’un pointeur, car le compilateur utilise la valeur nulle du pointeur pour représenter None (niche optimization).

Exemple 53 (Tailles mémoire observées)

Voir le code ci-dessous.

use std::mem::size_of;

enum Simple { A, B, C }

enum AvecDonnees {
    Entier(i32),           // 4 octets
    Flottant(f64),         // 8 octets
    Texte([u8; 32]),       // 32 octets
}

println!("Simple          : {} octet(s)", size_of::<Simple>());
println!("AvecDonnees     : {} octets", size_of::<AvecDonnees>());
println!("Option<i32>     : {} octets", size_of::<Option<i32>>());
println!("Option<&i32>    : {} octets", size_of::<Option<&i32>>());
println!("&i32            : {} octets", size_of::<&i32>());
Simple          : 1 octet(s)
AvecDonnees     : 40 octets
Option<i32>     : 8 octets
Option<&i32>    : 8 octets
&i32            : 8 octets

La dernière ligne met en évidence l’optimisation de niche : Option<&i32> occupe exactement la même taille que &i32 (8 octets sur une architecture 64 bits). Aucun octet supplémentaire n’est nécessaire pour le discriminant, car le compilateur sait qu’une référence valide ne peut jamais être nulle et réserve la valeur zéro pour représenter None.

Remarque 39

L’optimisation de niche s’applique à tout type possédant des valeurs interdites. Elle concerne notamment les références (&T, &mut T), les pointeurs intelligents (Box<T>, Rc<T>, Arc<T>) et le type NonZeroU32 (et ses variantes). C’est l’une des raisons pour lesquelles Option est considéré comme une abstraction à coût zéro (zero-cost abstraction) dans la plupart des cas d’usage courants.