Énums et pattern matching en Rust

1. Aperçu

Dans de nombreux langages, une énumération n’est qu’une liste de constantes nommées. En Rust, chaque variante d’une énumération peut porter ses propres données. Associée à match, le compilateur vous force à traiter tous les cas possibles.

Cet article couvre la définition d’énums, match, les guards, le binding @, Option, Result, if let, let else et matches!. À la fin, vous pourrez modéliser des états métier avec des énums et éviter les erreurs courantes.

L’article suppose Rust 1.65 ou plus récent (pour let else) et ne requiert aucune dépendance externe.

2. Définir une énumération avec données

Supposons que vous devez décrire l’état d’une commande :

#[derive(Debug, Clone, PartialEq)]
enum OrderStatus {
    Pending,
    Paid(u64),
    Shipped { carrier: String, tracking: String },
    Cancelled { reason: String },
}

fn main() {
    let statuses = vec![
        OrderStatus::Pending,
        OrderStatus::Paid(250_000),
        OrderStatus::Shipped {
            carrier: "GHN".to_string(),
            tracking: "GHN123".to_string(),
        },
        OrderStatus::Cancelled { reason: "Stock épuisé".to_string() },
    ];

    for s in &statuses {
        println!("{:?}", s);
    }
}

Cette énumération a trois sortes de variantes :

  • Pending est une variante unitaire, sans données associées.
  • Paid(u64) est une variante tuple, portant un montant selon sa position.
  • Shipped { .. } et Cancelled { .. } sont des variantes struct, portant des données avec des champs nommés.

#[derive(Debug, Clone, PartialEq)] permet d’afficher avec {:?}, de cloner et de comparer avec ==. La comparaison sera utile pour écrire des tests.

Pending
Paid(250000)
Shipped { carrier: "GHN", tracking: "GHN123" }
Cancelled { reason: "Stock épuisé" }

Le point clé : une commande Pending ne peut pas “accidentellement” avoir un numéro de suivi. Les données n’existent que dans la variante qui en a besoin. Avec un struct contenant plusieurs champs Option, vous n’auriez pas cette garantie.

3. match basique et exhaustivité

match compare une valeur avec chaque pattern dans l’ordre. La première branche qui correspond s’exécute :

fn describe(status: &OrderStatus) -> String {
    match status {
        OrderStatus::Pending => "En attente de paiement".to_string(),
        OrderStatus::Paid(amount) => format!("Payé {} VND", amount),
        OrderStatus::Shipped { carrier, tracking } => {
            format!("Expédié via {}, numéro {}", carrier, tracking)
        }
        OrderStatus::Cancelled { reason } => format!("Annulé : {}", reason),
    }
}

Quelques points importants :

  • status a le type &OrderStatus. Grâce aux default binding modes, vous écrivez un pattern comme avec une valeur normale. Les variables amount, carrier, tracking seront des références (&u64, &String), donc aucune donnée n’est déplacée.
  • match est une expression. Toutes les branches doivent retourner le même type, ici String.

Supprimez maintenant la branche Cancelled. Le compilateur refuse la compilation :

error[E0004]: non-exhaustive patterns: `&OrderStatus::Cancelled { .. }` not covered
 --> src/main.rs:12:11
   |
12 |     match status {
   |           ^^^^^^ pattern `&OrderStatus::Cancelled { .. }` not covered

C’est l’avantage majeur de match. Quand vous ajoutez une variante, par exemple Refunded, tous les match qui ne la traitent pas génèrent une erreur. Vous n’avez pas besoin de chercher manuellement où le changement est nécessaire.

4. Patterns avancés : tuples, |, guards, ranges et @

Les patterns ne s’appliquent qu’aux énums. Vous pouvez matcher un tuple pour tester plusieurs conditions à la fois :

enum CustomerTier {
    Regular,
    Silver,
    Gold,
}

fn discount(tier: &CustomerTier, total: u64) -> u64 {
    match (tier, total) {
        (CustomerTier::Gold, _) => total / 10,
        (CustomerTier::Silver, t @ 1_000_000..) => t / 20,
        (CustomerTier::Silver | CustomerTier::Regular, t) if t >= 2_000_000 => 50_000,
        _ => 0,
    }
}

fn main() {
    println!("{}", discount(&CustomerTier::Gold, 300_000));
    println!("{}", discount(&CustomerTier::Silver, 1_200_000));
    println!("{}", discount(&CustomerTier::Regular, 2_500_000));
    println!("{}", discount(&CustomerTier::Regular, 100_000));
}

Chaque branche fonctionne comme suit :

  • _ correspond à n’importe quelle valeur et ne lie aucune variable.
  • t @ 1_000_000.. vérifie que total est supérieur ou égal à 1 million, tout en liant la valeur à t.
  • CustomerTier::Silver | CustomerTier::Regular utilise | pour grouper plusieurs patterns dans une branche.
  • if t >= 2_000_000 est une clause de garde (match guard). C’est une condition supplémentaire évaluée après que le pattern ait correspondu.
  • _ => 0 est la branche par défaut. Elle est obligatoire car u64 a un nombre infini de valeurs possibles.
30000
60000
50000
0

Avec une variante struct, utilisez .. pour ignorer les champs qui ne vous intéressent pas :

fn shipping_fee(status: &OrderStatus) -> u64 {
    match status {
        OrderStatus::Shipped { carrier, .. } if carrier == "GHN" => 22_000,
        OrderStatus::Shipped { .. } => 30_000,
        _ => 0,
    }
}

Attention : Le compilateur n’utilise pas les gardes pour vérifier l’exhaustivité. Si vous écrivez seulement Shipped { .. } if ... sans une branche Shipped sans guard, vous devez toujours ajouter une autre branche pour couvrir tous les cas.

5. Option et Result sont aussi des énums

La bibliothèque standard définit Option<T> (contenant Some(T) ou None) et Result<T, E> (contenant Ok(T) ou Err(E)). Puisque ce sont des énums, vous les gérez avec les mêmes outils :

fn find_customer(id: u32) -> Option<&'static str> {
    match id {
        1 => Some("Alice"),
        2 => Some("Bob"),
        _ => None,
    }
}

fn parse_quantity(input: &str) -> Result<u32, String> {
    match input.trim().parse::<u32>() {
        Ok(0) => Err("La quantité doit être supérieure à 0".to_string()),
        Ok(n) => Ok(n),
        Err(e) => Err(format!("Invalide : {}", e)),
    }
}

fn main() {
    println!("{:?}", find_customer(2));
    println!("{:?}", find_customer(9));
    println!("{:?}", parse_quantity(" 3 "));
    println!("{:?}", parse_quantity("0"));
    println!("{:?}", parse_quantity("abc"));
}

Ok(0) est un pattern imbriqué. Il correspond quand le résultat est Ok et que la valeur à l’intérieur est 0. Puisque Ok(0) vient avant Ok(n), la valeur 0 est capturée avant d’atteindre le pattern générique.

Some("Bob")
None
Ok(3)
Err("La quantité doit être supérieure à 0")
Err("Invalide : invalid digit found in string")

Rust n’a pas de null. Toute valeur “potentiellement absente” doit passer par Option, et le compilateur vous oblige à traiter le cas None.

6. Syntaxe concise : if let, let else, while let, matches!

Écrire un match complet est verbeux quand vous ne vous intéressez qu’à une seule variante. Rust offre quelques raccourcis pour ce cas.

6.1. if let

fn tracking_code(status: &OrderStatus) -> Option<&str> {
    if let OrderStatus::Shipped { tracking, .. } = status {
        Some(tracking.as_str())
    } else {
        None
    }
}

if let exécute le bloc si le pattern correspond. La partie else est optionnelle. Cette syntaxe ne vous force pas à traiter tous les cas, donc utilisez-la quand les autres variantes ne vous intéressent vraiment pas.

6.2. let else

let else convient au motif “early return” : capturer les données si elles correspondent, sinon quitter immédiatement.

fn charge(status: &OrderStatus) -> Result<u64, String> {
    let OrderStatus::Paid(amount) = status else {
        return Err("Commande non payée".to_string());
    };
    Ok(*amount)
}

Après la ligne let, la variable amount est disponible dans le reste de la fonction sans bloc imbriqué supplémentaire. Le bloc else doit quitter le flux actuel avec return, break, continue ou panic!.

6.3. while let et matches!

fn main() {
    let mut queue = vec![
        OrderStatus::Paid(100_000),
        OrderStatus::Pending,
        OrderStatus::Paid(400_000),
    ];

    let paid = queue
        .iter()
        .filter(|s| matches!(s, OrderStatus::Paid(_)))
        .count();
    println!("Nombre de commandes payées : {}", paid);

    while let Some(status) = queue.pop() {
        println!("Traitement : {:?}", status);
    }
}
  • matches!(valeur, pattern) retourne un bool. Cette macro est très pratique dans filter ou assert!.
  • while let Some(status) = queue.pop() boucle jusqu’à ce que pop() retourne None.
Nombre de commandes payées : 2
Traitement : Paid(400000)
Traitement : Pending
Traitement : Paid(100000)

7. Attacher des méthodes aux énums : modéliser les transitions d’état

Les énums ont aussi impl comme les structs. Un bon usage est de représenter les transitions d’état valides :

impl OrderStatus {
    fn ship(self, carrier: &str, tracking: &str) -> Result<OrderStatus, String> {
        match self {
            OrderStatus::Paid(_) => Ok(OrderStatus::Shipped {
                carrier: carrier.to_string(),
                tracking: tracking.to_string(),
            }),
            other => Err(format!("Impossible d'expédier une commande à l'état {:?}", other)),
        }
    }

    fn cancel(self, reason: &str) -> Result<OrderStatus, String> {
        match self {
            OrderStatus::Pending | OrderStatus::Paid(_) => Ok(OrderStatus::Cancelled {
                reason: reason.to_string(),
            }),
            other => Err(format!("Impossible d'annuler une commande à l'état {:?}", other)),
        }
    }

    fn is_final(&self) -> bool {
        matches!(self, OrderStatus::Cancelled { .. })
    }
}

Quelques points importants :

  • ship et cancel prennent self par valeur, donc l’ancien état est consommé. Vous ne pouvez pas réutiliser la variable après la transition.
  • Le pattern other => ... lie toute la valeur restante à other pour l’inclure dans le message d’erreur.
  • is_final ne fait que lire, donc prend &self.

8. Écrire des tests pour le comportement

Rust a un framework de test intégré. Vous placez les tests dans un module #[cfg(test)] du même fichier :

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn paid_order_can_be_shipped() {
        let result = OrderStatus::Paid(250_000).ship("GHN", "GHN123");

        assert_eq!(
            result,
            Ok(OrderStatus::Shipped {
                carrier: "GHN".to_string(),
                tracking: "GHN123".to_string(),
            })
        );
    }

    #[test]
    fn pending_order_cannot_be_shipped() {
        let result = OrderStatus::Pending.ship("GHN", "X");

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Pending"));
    }

    #[test]
    fn cancelled_order_is_final() {
        let status = OrderStatus::Pending.cancel("Le client a changé d'avis").unwrap();

        assert!(status.is_final());
        assert!(matches!(status, OrderStatus::Cancelled { ref reason } if reason == "Le client a changé d'avis"));
    }
}

assert_eq! fonctionne parce que l’énumération a dérivé PartialEq et Debug. Quand un test échoue, Rust affiche la valeur attendue et la valeur réelle. Dans le dernier test, ref reason lie le champ par référence, donc status n’est pas déplacé dans matches!.

cargo test
running 3 tests
test tests::cancelled_order_is_final ... ok
test tests::paid_order_can_be_shipped ... ok
test tests::pending_order_cannot_be_shipped ... ok

test result: ok. 3 passed; 0 failed; 0 ignored

9. Erreurs courantes et quand ne pas utiliser les énums

Abuser de _ et perdre l’exhaustivité. Si vous écrivez _ => ... pour une énumération que vous contrôlez, ajouter une variante ultérieurement, le compilateur ne dira rien. La nouvelle variante tombera dans le cas par défaut, ce qui n’est généralement pas souhaitable. Pour les énums métier, énumérez chaque variante explicitement.

Déplacer des données hors d’une référence. Écrire match *status { OrderStatus::Cancelled { reason } => ... } cause une erreur :

error[E0507]: cannot move out of `status` as enum variant `Cancelled` which is behind a shared reference

Trois solutions : matcher directement sur status (la référence), utiliser ref reason, ou cloner les données si vous en avez vraiment besoin.

Placer le pattern générique avant les patterns spécifiques. Si Ok(n) vient avant Ok(0), la branche Ok(0) ne sera jamais exécutée. Le compilateur affiche unreachable pattern, pas une erreur, donc vous pouvez facilement le manquer.

Variantes trop grandes. La taille d’une énumération est celle de la plus grande variante plus le tag. Si une variante contient un grand tableau, chaque valeur de l’énumération occupera cette même mémoire. Enrobez cette variante avec Box<...>. Le lint clippy::large_enum_variant vous l’indiquera.

Remarque : Si vous écrivez une bibliothèque et voulez ajouter des variantes sans casser le code des utilisateurs, marquez l’énumération avec #[non_exhaustive]. Le code dans d’autres crates sera obligé d’avoir un cas _.

Quand ne pas utiliser les énums ? Les énums conviennent quand l’ensemble des cas est fermé et sous votre contrôle, par exemple les états d’une commande ou les types d’erreur. Si l’ensemble de types est ouvert, comme lorsque les utilisateurs de votre bibliothèque doivent ajouter leurs propres méthodes de paiement, utilisez un trait (Box<dyn PaymentMethod> ou generics). Ajouter une variante exige de modifier l’énumération source, alors qu’un trait permet l’extension de l’extérieur.

10. Conclusion

Les énums en Rust permettent à chaque cas de porter précisément ses données. match vous oblige à traiter tous les cas à la compilation. Vous avez vu :

  • Les variantes unitaires, tuples et structs.
  • match avec binding, |, guards, ranges, @ et ...
  • Option et Result comme des énums ordinaires.
  • Les syntaxes concises if let, let else, while let, matches!.
  • Comment utiliser les méthodes sur énums pour modéliser les transitions d’état, avec tests.

La règle la plus facile à retenir : pour une énumération que vous possédez, énumérez tous les cas plutôt que d’utiliser _. Ainsi, chaque fois que vous ajoutez une variante, le compilateur indiquera tous les endroits où vous devez intervenir.

rustenumpattern matchingmatchoption