Aller au contenu

Comprendre les enums en Rust

Définition

Un Enum (abbréviation de énumération) permet de définir un nouveau type qui va contenir ce qu’on appelle des variantes.

On va prendre un exemple avec les pokémons, imaginons qu’on veuille regrouper tous les différents genre de Pokemon (Feu, Eau, Électrique …) qui existent au même endroit, on va pouvoir le faire grâce à un enum.

// Un nouveau type
enum PokemonKind {
// Chaque variante de PokemonKind
Fire,
Water,
Electric,
}

Comme tu peux le voir ici on crée un enum PokemonKind, on a donc créé un nouveau type PokemonKind.

Ensuite on va définir les variantes de notre enum à l’intérieur de celui-ci.

Pour faire simple, on peut lire le code précédent comme : “Création d’un type PokemonKind dont les variantes sont Fire, Water et Electric”

Instanciation

Cela va nous permettre “d’énumérer” les différents genres de Pokemon (Feu, Eau, Électrique etc) afin de les regrouper dans un type (Le type PokemonKind)

Okay c’est bien beau tout ça, mais concrètement à quoi ça sert ?!

On va continuer notre exemple avec les pokemons.

Imaginons qu’on veuille créer un pokemon avec un champ name, et un champ kind qui sera de type PokemonKind (car je te rappelle qu’un enum est un type).

enum PokemonKind {
Fire,
Water,
Electric,
}
struct Pokemon {
name: String,
kind: PokemonKind,
}
fn main() {
let pikachu_name = String::from("Pikachu");
let pikachu_kind = PokemonKind::Electric;
let pikachu = Pokemon { name: pikachu_name, kind: pikachu_kind };
}

Grâce à la syntaxe PokemonKind::Electric on a instancié l’enum PokemonKind avec la variante Electric.

On a ensuite créé une instance de Pokemon avec comme champ kind la variante Electric.

Bon… on a stocké de l’information de façon propre, mais ça ne nous dit toujours pas vraiment à quoi ça sert ?

J’y arrive.

Imaginons qu’on veuille maintenant ajouter une méthode attack à tous les pokemons, on veut qu’en fonction du type de notre pokemon (Fire, Water, Electric) la méthode attack affiche :

  • “Attaque Feu !🔥” pour les pokemons de type Fire
  • “Attaque Eau !🌊” pour les pokemons de type Water
  • “Attaque Électrique! ⚡” pour les pokemons de type Electric

On va pouvoir le faire en regardant la variante de PokemonKind que porte notre pokemon.

enum PokemonKind {
Fire,
Water,
Electric,
}
struct Pokemon {
name: String,
kind: PokemonKind,
}
impl Pokemon {
fn attack(&self) {
if self.kind == PokemonKind::Fire {
println!("Attaque Feu !🔥");
} else if self.kind == PokemonKind::Water {
println!("Attaque Eau !🌊");
} else if self.kind == PokemonKind::Electric {
println!("Attaque Electrique !⚡");
}
}
}
fn main() {
let pikachu_name = String::from("Pikachu");
let pikachu_kind = PokemonKind::Electric;
let pikachu = Pokemon { name: pikachu_name, kind: pikachu_kind };
pikachu.attack(); // "Attaque Electrique !⚡"
let dracaufeu_name = String::from("Dracaufeu");
let dracaufeu_kind = PokemonKind::Fire;
let dracaufeu = Pokemon { name: dracaufeu_name, kind: dracaufeu_kind };
dracaufeu.attack(); // "Attaque Feu !🔥"
}

Tu vois que les enums nous ont permis non seulement d’organiser notre code plus proprement, en regroupant tous les différents types de pokemons dans le même enum, mais ils nous ont surtout permis d’afficher la bonne attaque en fonction du type du pokemon.

C’est donc un pattern très utile pour gagner en clarté dans le code et pour filtrer des conditions.

Matching pattern

Dans l’exemple précédent, tu as peut-être remarqué que nos conditions sont assez répétitives, en effet on doit à chaque fois ajouter un self.kind == ... et ce n’est pas très élégant.

Lorsqu’on est dans le cas de figure ou on teste les différentes valeurs que prend une même variable on va pouvoir utiliser ce qu’on appelle un matching pattern.

Voici à quoi ressemblerait l’exemple précédent avec un matching pattern :

enum PokemonKind {
Fire,
Water,
Electric,
}
struct Pokemon {
name: String,
kind: PokemonKind,
}
impl Pokemon {
fn attack(&self) {
match self.kind {
PokemonKind::Fire => { println!("Attaque Feu !🔥"); }
PokemonKind::Water => { println!("Attaque Eau !🌊"); }
PokemonKind::Electric => { println!("Attaque Electrique !⚡"); }
}
}
}
fn main() {
let pikachu_name = String::from("Pikachu");
let pikachu_kind = PokemonKind::Electric;
let pikachu = Pokemon { name: pikachu_name, kind: pikachu_kind };
pikachu.attack(); // "Attaque Electrique !⚡"
let dracaufeu_name = String::from("Dracaufeu");
let dracaufeu_kind = PokemonKind::Fire;
let dracaufeu = Pokemon { name: dracaufeu_name, kind: dracaufeu_kind };
dracaufeu.attack(); // "Attaque Feu !🔥"
}

C’est déjà beaucoup plus clair !

Le matching pattern fonctionne comme les if, simplement il va tester uniquement la valeur de l’élément qu’on mentionne après le match, ici self.kind.

Ensuite après chaque valeur à tester, on a une flèche =>, suivi d’un bloc d’instruction, ici c’est comme pour les if, on va lancer le bloc si la variable est égale à la valeur à tester.

Point très important : un matching pattern doit toujours gérer tous les cas possibles…

Imaginons dans l’exemple précédent si on avait eu juste le cas ou le PokemonKind est Feu, Rust va crasher en précisant que les autres cas possibles ne sont pas gérés dans le matching pattern.

impl Pokemon {
fn attack(&self) {
match self.kind {
PokemonKind::Fire => { println!("Attaque Feu !🔥"); }
}
}
}

Si on veut gérer seulement un cas et agir de la même façon pour tous les autres il suffit de faire :

impl Pokemon {
fn attack(&self) {
match self.kind {
PokemonKind::Fire => { println!("Attaque Feu !🔥"); }
_ => { println!("Je n'ai pas d'attaque Feu..."); }
}
}
}

Ainsi si le pokemon est de type Eau ou Électrique, il va afficher “Je n’ai pas d’attaque Feu…”

Variantes contenant des valeurs

Les variantes d’enum peuvent contenir des valeurs ce qui peut être très pratique.

Valeurs sans nom

Les variantes d’enum peuvent prendre des valeurs sans nom, un peu comme un tuple.

Imaginons qu’on veuille regrouper toutes les actions possibles d’un pokemon sous un enum nommé Action.

On va alors créer une variante TakeDamage qui va prendre une valeur sans nom, qui correspondra aux dommages qu’a reçus notre pokemon.

Pour compter les vies de notre pokemon, il faut lui donner un champ “life” qui vaudra 100 quand le pokemon a sa vie à fond et 0 quand il est mort.

Il faudra bien penser à déclarer notre pokemon mutable pour pouvoir changer ses vies.

Enfin il faut aussi créer une fonction handle_action qui va gérer chaque action à réaliser avec un matching pattern, car pour l’instant on a qu’une action, mais on va vite en rajouter.

Voici le code :

enum Action {
TakeDamage(u32),
}
struct Pokemon {
name: String,
life: u32,
}
impl Pokemon {
// on oublie pas le &mut pour modifier le champ life
fn handle_action(&mut self, action: Action) {
match action {
Action::TakeDamage(dmg) => {
// on modifie la vie du pokémon
self.life -= dmg;
println!("Ouch! J'ai reçus {}pts de dégats", dmg);
println!("Ouch! Il me reste {}pts de vie", self.life);
}
}
}
}
fn main() {
let mut pikachu = Pokemon { name: String::from("Pikachu"), life: 100 };
let take_damage_action = Action::TakeDamage(25); // 25pts de dégats
pikachu.handle_action(take_damage_action);
}

Ce qui est très important ici c’est que lors du matching pattern on on écrit Action::TakeDamage(dmg), le dmg correspond au nom qu’on va donner à la valeur de la variante dans la suite du bloc.

On aurait pu donner n’importe quel nom à la place de dmg.

Ca nous permet de travailler avec la valeur de la variante, il nous suffit ensuite de faire print dmg.

Tu noteras ici que notre fonction handle_action prend en paramètre &mut self, car on va modifier les vies de notre pokémon donc l’instance de Pokemon doit être mutable.

Valeurs nommées

Les variantes d’enum peuvent avoir des valeurs nommées exactement comme les structs.

Imaginons qu’on veuille maintenant ajouter l’action Shoot ou nos pokemons pourront tirer à un endroit précis, pour ça la variante Shoot doit posséder des paramètres x,y et z qui correspondent aux coordonnées du tir.

La syntaxe des variantes nommées est exactement la même que celle des structs, accolades avec à chaque fois le nom du champ et son type.

enum Action {
TakeDamage(u32),
Shoot { x: u32, y: u32, z: u32 },
}
struct Pokemon {
name: String,
life: u32,
}
impl Pokemon {
fn handle_action(&mut self, action: Action) {
match action {
Action::TakeDamage(dmg) => {
self.life -= dmg;
println!("Ouch! J'ai reçus {}pts de dégats", dmg);
println!("Ouch! Il me reste {}pts de vie", self.life);
}
Action::Shoot { x, y, z } => {
println!("Je tire en x:{}, y:{}, z:{}", x, y, z);
}
}
}
}
fn main() {
let mut pikachu = Pokemon { name: String::from("Pikachu"), life: 100 };
let shoot_action = Action::Shoot { x: 20, y: 14, z: 30 }; // 25pts de dégats
pikachu.handle_action(shoot_action);
}

Dans la matching pattern tu vois qu’on a écrit Action::Shoot{x,y,z}, on a ainsi précisé les champs qu’on voulait récupérer.

Grâce à cette syntaxe, on peut maintenant utiliser x, y et z dans le bloc.

Ici on est obligé de préciser le nom des champs, on ne peut pas leur donner un autre nom, alors que pour les variantes qui ont des valeurs sans noms on pouvait donner le nom qu’on veut aux valeurs.