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…

  1. 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 =.
  2. On peut ignorer un membre en utilisant _ à la place d’un nom de variable :
    #![allow(unused)]
    fn main() {
    let (x, _) = (12, 34);
    println!("{x}");
    }
  3. 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}");
    }
  4. 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.