Aller au contenu

Pattern Matching en Rust : gérer la complexité

Définition

Un pattern matching est un pattern de programmation hyper puissant qui va nous permettre d’économiser beaucoup de lignes de code et surtout rendre le code compréhensible !

Pour te montrer comment ça fonctionne on va prendre un exemple avec le jeu Mario.

Dans un jeu Mario, Mario va changer d’état en fonction de :

  • son état actuel
  • et de ce qu’il va prendre comme objet (champignon, fleur, plumes…)

pattern matching en Rust

On peut voir sur ce schéma les différents états de Mario :

  • Si Mario est petit et prend un champignon => Il devient SuperMario
  • Si Mario est petit et prend une plume => Il devient CapeMario
  • etc

On va donc d’abord créer un enum avec les différents états de mario :

enum MarioState{
Mario,
SuperMario,
FireMario,
CapeMario
}

Puis même chose avec les différents objets :

enum Item{
Mushroom,
Flower,
Feather,
}

Écrire tout ça avec des if, ce serait vraiment long et assez obscure, ça donnerait quelque chose comme ça :

enum MarioState{
Mario,
SuperMario,
FireMario,
CapeMario
}
enum Item{
Mushroom,
Flower,
Feather,
}
fn new_state(current_state:MarioState, item:Item) -> MarioState{
if current_state == MarioState::Mario && item == Item::Mushroom {
return MarioState::SuperMario
}
else if current_state == MarioState::Mario && item == Item::Flower {
return MarioState::FireMario
}
...
}

Il existe une façon BIEN plus belle d’écrire tout ça, les pattern matching :

enum MarioState{
Mario,
SuperMario,
FireMario,
CapeMario
}
enum Item{
Mushroom,
Flower,
Leather,
}
fn new_state(current_state:MarioState, item:Item) -> MarioState{
match (current_state, item){
(MarioState::Mario, Item::Mushroom) => MarioState::SuperMario,
(MarioState::Mario, Item::Flower) => MarioState::FireMario,
...
}
}

Voilà comment se lit un pattern matching :

  • si (current_state, item) “match”, c’est-à-dire vaut : (MarioState::Mario, Item::Mushroom), le match renvoie la valeur MarioState::SuperMario
  • si (current_state, item) “match” (MarioState::Mario, Item::Flower), le match renvoie la valeur MarioState::FireMario
  • etc

Comme match est une expression qui retourne une valeur cela devient la valeur de retour de la fonction.

Petite précision très importante, il faut absolument couvrir tous les cas de figures possibles de notre pattern matching, car si Rust rencontre une combinaison qu’on n’a pas couverte, il sera incapable de déterminer la valeur du match…

Maintenant on va voir comment écrire tous les états possibles de Mario avec un pattern matching ;)

Pour ça on va partir de chaque état de Mario (Mario, SuperMario, FireMario, CapMario) et on va écrire ce qu’il devient pour chaque item (Fleur, Champignon, plume)

fn new_state(current_state:MarioState, item:Item) -> MarioState{
match (current_state, item){
// Tous les états quand on part de l'état Mario
(MarioState::Mario, Item::Mushroom) => MarioState::SuperMario,
(MarioState::Mario, Item::Flower) => MarioState::FireMario,
(MarioState::Mario, Item::Feather) => MarioState::CapeMario,
// Tous les états quand on part de l'état SuperMario
(MarioState::SuperMario, Item::Flower) => MarioState::FireMario,
(MarioState::SuperMario, Item::Feather) => MarioState::CapeMario,
// Tous les états quand on part de l'état FireMario
(MarioState::FireMario, Item::Feather) => MarioState::CapeMario,
// Tous les états quand on part de l'état CapeMario
(MarioState::CapeMario, Item::Flower) => MarioState::FireMario,
// Si on rentre dans aucun cas de figure précédent, alors
// l'état reste inchangé
(_, _) => current_state
}
}

Le code est bien plus clair que si on avait dû le faire avec des if, mais on peut encore l’améliorer !

Pour cela on va juste regrouper ensemble les différentes conditions qui donnent le même résultat, grâce au OU logique qu’on va écrire à l’aide du caractère | (et non pas || comme dans les if), ça donne le résultat suivant :

fn new_state(current_state:MarioState, item:Item) -> MarioState{
match (current_state, item){
// Toutes les conditions qui retournent SuperMario
(MarioState::Mario, Item::Mushroom) => MarioState::SuperMario,
// Toutes les conditions qui retournent FireMario
(MarioState::Mario, Item::Flower) |
(MarioState::SuperMario, Item::Flower) |
(MarioState::CapeMario, Item::Flower) => MarioState::FireMario,
// Toutes les conditions qui retournent CapeMario
(MarioState::Mario, Item::Feather) |
(MarioState::SuperMario, Item::Feather) |
(MarioState::FireMario, Item::Feather) => MarioState::CapeMario,
// Tous les autres cas laissent l'état inchangé
(_, _) => current_state
}
}

Et là on se rend compte une nouvelle fois qu’on peut simplifier ! On voit que peu importe l’état de mario, s’il prend une fleur il devient FireMario, même chose pour la plume qui le transforme en CapeMario, on peut écrire ça sous cette forme :

fn new_state(current_state:MarioState, item:Item) -> MarioState{
match (current_state, item){
(MarioState::Mario, Item::Mushroom) => MarioState::SuperMario,
(_, Item::Flower) => MarioState::FireMario,
(_, Item::Feather) => MarioState::CapeMario,
(_, _) => current_state
}
}

Le underscore signifie “peu importe la valeur”, donc (_, Item::Flower) signifie, “peu importe la valeur de current_state si item vaut Item::Flower

Le code est très élégant avec aucune répétition !

On va pouvoir ajouter une dernière petite touche pour éviter de répéter Item:: pour chaque item et Mario:: pour chaque état de mario, pour ça on va ajouter use MarioState::*; et use Item::*;.

use MarioState::*;
use Item::*;
enum MarioState{
Mario,
SuperMario,
FireMario,
CapeMario
}
enum Item{
Mushroom,
Flower,
Leather,
}
fn new_state(current_state:MarioState, item:Item) -> MarioState{
match (current_state, item){
(Mario, Mushroom) => SuperMario,
(_, Flower) => FireMario,
(_, Feather) => CapeMario,
(_, _) => current_state
}
}

Et voilà je crois qu’on ne peut pas faire mieux ! Tu vois à quel point les pattern matching nous permettent d’écrire du code complexe très facilement ?

C’est une des fonctionnalités qui font que Rust est très apprécié des devs.

Règles du pattern matching

Lorsqu’on utilise un pattern matching il y a quelques règles à respecter,

match est une expression

Le mot clé match est une expression, autrement dit il renvoie une valeur, et donc si on ne fait pas suivre de ; après un match, la valeur retournée par le match sera celui de la fonction actuelle.

Les branches peuvent tenir sur plusieurs lignes

Jusqu’à maintenant on a seulement vu les cas ou les branches du pattern matching tiennent sur une seule ligne, mais il est possible d’utiliser plusieurs lignes, pour ça on va utiliser les accolades :

fn digit_to_text(number:u8)-> String{
match number{
0 => String::from("Zéro"),
1 => String::from("Un"),
2 => String::from("Deux"),
_ => {
println!("Bon on va pas tous les faire...");
// Ici la ligne sans ; sera la valeur de retour de la branche
String::from("Plus que Deux")
},
}
}

Ne pas en abuser du pattern matching

Comme toutes les bonnes choses, il ne faut en abuser, il y a certains cas de figure ou on préférera un simple if.

C’est surtout le cas lorsqu’on veut tester une condition booléenne, même si on peut très bien le faire avec un match.

On utilisera surtout le match lorsqu’on veut tester des conditions plus complexes qu’une simple condition booléenne.

Pour te montrer la différence voici ce qui se passe avec un match

fn is_major(age:u8){
match age >= 18 {
true => true,
false => false
}
}

Avec un if on aurait fait la même chose de cette façon :

fn is_major(age:u8){
if age >= 18 {
return true
}
false
}

Le if est plus pertinent ici puisqu’en lisant le code c’est beaucoup plus clair de se dire : “si age est supérieur ou égal à 18 alors retourne vrai”, plutôt que “si age >= 18 match true”.

D’ailleurs on peut même faire sans if en écrivant juste :

fn is_major(age:u8){
age >= 18
}

Comme ça on renvoie simplement si l’égalité est vraie ou non et même pas besoin de if !

Vérifie bien à l’avance si tu ne peux pas écrire tes conditions aussi simplement avant d’utiliser un if ou bien même un match.

Chaque branche doit retourner une valeur de même type

Imaginons qu’on utilise un pattern matching pour vérifier la valeur d’un montant, et si le montant est zéro le match retourne une String, sinon il retourne le montant, c’est-à-dire un u8

/* ❌ NE COMPILE PAS */
fn check_amount(amount:u32){
match amount{
0 => String::from("Oups... Plus d'argent"),
_ => amount
};
}

Eh bien ça, c’est interdit ! Puisque Rust a besoin de connaître le type qui sera retourné par le match et donc un pattern matching doit retourner le même type dans chacune de ses branches.

Sinon, ça ne compile pas.

pattern matching & Option

Parfois on doit utiliser un pattern matching sur une valeur de type Option, voilà comment on va faire ça :

fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
// Ici on va définir i comme la valeur contenue dans le Some
// On pourra s'en servir de l'autre côté de la flèche
Some(i) => Some(i + 1),
None => None,
}
}

Imaginons qu’on veuille faire des choses plus complexes avec le i voilà comment on s’y prendrait :

fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => {
let final = i*(i+2)/12;
Some(final)
},
None => None,
}
}