Acheter version papier

Rust

Fork me on GitHub

II. Spécificités de Rust

10. Les unions

Les unions ressemblent beaucoup aux structures tout en étant très différentes : tous les champs d'une union partagent le même espace mémoire. Si la valeur d'un champ d'une union est changé, cela peut écrire par-dessus un autre champ. Autre information importante : la taille d'une union est la taille de son champ avec la plus grande taille.

Bien évidemment, vous vous doutez bien qu'avec toutes ces restrictions, les types des champs d'une union doivent suivre certaines règles : ils doivent implémenter le trait Copy ou bien être wrappés dans le type ManuallyDrop.

Chaque accès à un champ d'une union est considéré comme unsafe et vous ne pourrez pas faire des emprunts mutable sur plus d'un champ à la fois car ils sont considérés comme faisant tous parties du même espace mémoire.

La plupart des derive traits ne peuvent pas être utilisés non plus (par exemple #[derive(Debug)]. Cela ne veut pas dire qu'une union ne peut pas implémenter ces traits, juste qu'il vous faudra les implémenter vous-même.

Enfin, dernier point : quand on instancie une union, on ne doit spécifier qu'un seul champ.

Mise en pratique

Prenons un exemple :

Rununion Foo {
    a: u16,
    b: u8,
}

let f = Foo { a: 1 };
unsafe { // Nécessaire pour pouvoir accéder aux champs.
    println!("a: {} b: {}", f.a, f.b);
}

Ce qui affichera :

a: 1 b: 1

Et oui, souvenez-vous : les champs partagent le même espace mémoire. Par-contre que se passe-t-il pour le champ b si on assigne au champ a une valeur plus grande que ce que peut contenir un u8 ?

Runlet f = Foo { a: u16::MAX };
unsafe {
    println!("a: {} b: {}", f.a, f.b);
}

Ce qui affichera :

a: 65535 b: 255

Donc b représente la partie "basse" de a. Ce qui illustre parfaitement l'espace mémoire partagé.

Que se passe-t-il si on change l'ordre des types et que l'on commence par le u8 à la place du u16 ?

Rununion Foo {
    a: u8,
    b: u16,
}

// Ce sera maintenant le champ `b` qu'on va initialiser.
let f = Foo { b: u16::MAX };
unsafe {
    println!("a: {} b: {}", f.a, f.b);
}

Ce qui affichera :

a: 255 b: 65535

Donc rien n'a changé, le u8 représente toujours la partie "basse" du u16. Et que se passe-t-il si on ajoute un autre champ de type u8 ?

Rununion Foo {
    a: u16,
    b: u8,
    c: u8,
}

let f = Foo { a: 10 };
unsafe {
    println!("a: {} b: {} c: {}", f.a, f.b, f.c);
}

Ce qui affichera :

a: 10 b: 10 c: 10

Donc un type plus petit représentera toujours la partie basse d'un type plus grand, même s'il y en a plusieurs.

Regardons maintenant un exemple un peu concret : manipuler une couleur. Une couleur est composée de 4 valeurs :

  • rouge
  • vert
  • bleu
  • transparence

Chacune de ces valeurs peut aller de 0 à 255 inclus (un u8 donc). Cependant, il est assez fréquent de vouloir passer un u32 pour représenter une couleur plutôt que chaque composant. Les unions sont donc un excellent moyen de faire ça :

Run#[derive(Default, Clone, Copy)]
struct Color {
    red: u8,
    green: u8,
    blue: u8,
    alpha: u8,
}

union ColorUnion {
    color: Color,
    value: u32,
}

let mut color = ColorUnion { value: 0 };
unsafe {
    assert_eq!(color.color.green, 0);
    // Une couleur verte à moitié transparente.
    color.color.green = 255;
    color.color.alpha = 128;
    // On peut comparer la valeur avec des décalages binaires pour se faciliter la vie :
    assert_eq!(color.value, (255 << 8) + (128 << 24));
    // Ou bien directement avec la valeur du `u32`, mais plus difficile à lire :
    assert_eq!(color.value, 2_147_548_928);
}

Pattern matching

Maintenant regardons rapidement comment le pattern matching fonctionne avec une union. Tout comme lorsque l'on initialise une union, il ne faut spécifier qu'un seul champ. Et bien évidemment, un block unsafe est nécessaire pour pouvoir accéder au champ. Exemple :

Runlet f = Foo { a: 10 };
unsafe {
    match f {
        Foo { a: 10 } => println!("ok"),
        _ => println!("not ok"),
    }
}

Voilà qui conclut ce chapitre sur les unions.