É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 :
Pendingest une variante unitaire, sans données associées.Paid(u64)est une variante tuple, portant un montant selon sa position.Shipped { .. }etCancelled { .. }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 :
statusa le type&OrderStatus. Grâce aux default binding modes, vous écrivez un pattern comme avec une valeur normale. Les variablesamount,carrier,trackingseront des références (&u64,&String), donc aucune donnée n’est déplacée.matchest une expression. Toutes les branches doivent retourner le même type, iciString.
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 quetotalest supérieur ou égal à 1 million, tout en liant la valeur àt.CustomerTier::Silver | CustomerTier::Regularutilise|pour grouper plusieurs patterns dans une branche.if t >= 2_000_000est une clause de garde (match guard). C’est une condition supplémentaire évaluée après que le pattern ait correspondu._ => 0est la branche par défaut. Elle est obligatoire caru64a 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 brancheShippedsans 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 unbool. Cette macro est très pratique dansfilterouassert!.while let Some(status) = queue.pop()boucle jusqu’à ce quepop()retourneNone.
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 :
shipetcancelprennentselfpar 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 àotherpour l’inclure dans le message d’erreur. is_finalne 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.
matchavec binding,|, guards, ranges,@et...OptionetResultcomme 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.