Chapitre 421 minIntermédiaire
Structs, enums et match
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- définir une struct, son constructeur
newet ses méthodes ; - choisir entre
&self,&mut selfetself; - modéliser des états avec une enum dont les variantes portent des données ;
- traiter tous les cas avec
match, ou un seul avecif let.
4 exercices corrigés
Une struct regroupe des données sous un nom, comme une classe sans héritage ; une enum décrit une valeur qui peut prendre plusieurs formes, chacune avec ses propres données ; et match traite chaque forme possible, sans en oublier aucune. Ces trois outils remplacent en Rust les classes et la hiérarchie d'héritage de C# ou de Java. Ce chapitre construit avec eux le modèle de données de l'outil parc : le poste, son type et son statut.
Définir une struct
#[derive(Debug, Clone, PartialEq)]
struct Poste {
numero: String,
marque: String,
prix_ht: u32, // en centimes
annee_achat: i32,
}
Une struct déclare des champs typés. Les champs sont des String et non des &str : la struct possède ses données, elle ne les emprunte pas (chapitre 3). La ligne #[derive(…)] demande au compilateur d'écrire du code pour nous :
| Dérivation | Ce qu'elle apporte |
|---|---|
Debug | Afficher la struct avec {:?} ou, sur plusieurs lignes, {:#?} |
Clone | Copier la struct avec .clone() |
PartialEq | Comparer deux structs avec == et !=, champ par champ |
Sans #[derive(Debug)], println!("{poste:?}") ne compile pas. C'est une différence avec C# : rien n'est disponible « gratuitement », chaque capacité se demande. Le chapitre 7 montre que ces dérivations sont des traits.
Ajouter des méthodes avec impl
Les méthodes se déclarent à part, dans un bloc impl :
impl Poste {
/// Fonction associée : le « constructeur » par convention.
fn new(numero: &str, marque: &str, prix_ht: u32, annee_achat: i32) -> Poste {
Poste {
numero: numero.to_string(),
marque: marque.to_string(),
prix_ht,
annee_achat,
}
}
/// Méthode qui lit le poste : &self.
fn age(&self, annee_courante: i32) -> i32 {
annee_courante - self.annee_achat
}
/// Méthode qui modifie le poste : &mut self.
fn appliquer_remise(&mut self, pourcentage: u32) {
self.prix_ht = self.prix_ht * (100 - pourcentage) / 100;
}
}
fn main() {
let mut poste = Poste::new("PC-0001", "Dell", 89900, 2023);
println!("{} a {} ans", poste.numero, poste.age(2026));
poste.appliquer_remise(10);
println!("Prix remisé : {} centimes", poste.prix_ht);
let jumeau = Poste { numero: String::from("PC-0009"), ..poste.clone() };
println!("{jumeau:?}");
println!("{poste:#?}");
println!("Identiques ? {}", poste == jumeau);
}
PC-0001 a 3 ans
Prix remisé : 80910 centimes
Poste { numero: "PC-0009", marque: "Dell", prix_ht: 80910, annee_achat: 2023 }
Poste {
numero: "PC-0001",
marque: "Dell",
prix_ht: 80910,
annee_achat: 2023,
}
Identiques ? false
Lis ce code ligne par ligne :
- Pas de constructeur dans le langage.
newest une simple fonction associée (sansself), qu'on appelle avecPoste::new(…). Le nomnewest une convention, pas un mot-clé. prix_ht,sans: prix_ht: quand le paramètre porte le nom du champ, on l'écrit une seule fois...poste.clone()remplit les champs non cités avec ceux d'un autre poste. C'est la syntaxe de mise à jour de struct.- La mutabilité porte sur toute la variable :
let mut posterend tous les champs modifiables. Il n'y a pas de champreadonlyisolé.
Le premier paramètre d'une méthode dit ce qu'elle fait du poste :
| Paramètre | La méthode… | Appel possible sur | Exemple |
|---|---|---|---|
&self | lit le poste | let poste ou let mut poste | age, prix_ttc |
&mut self | modifie le poste | let mut poste seulement | appliquer_remise, affecter |
self | consomme le poste (il n'est plus utilisable après) | toute variable, qui est déplacée | une conversion into_… |
Une struct tuple n'a pas de noms de champs : struct Centimes(u64);. Elle sert surtout à donner un type distinct à une valeur, pour que le compilateur refuse de confondre un prix et une quantité. On y accède par .0.
Les enums : une valeur parmi plusieurs formes
Une enum (énumération) liste les formes possibles d'une valeur, ses variantes. Dans sa forme simple, elle ressemble à l'enum de C# :
#[derive(Debug, Clone, Copy, PartialEq)]
enum TypePoste {
Fixe,
Portable,
Serveur,
}
Mais en Rust, chaque variante peut porter des données. Le statut d'un poste en est l'exemple parfait : un poste affecté l'est à quelqu'un, alors qu'un poste en stock n'a pas d'utilisateur.
#[derive(Debug, Clone, PartialEq)]
enum Statut {
EnStock,
Affecte(String), // la variante porte le nom de l'utilisateur
EnReparation,
Reforme,
}
En C# ou en JavaScript, on aurait écrit un champ statut et un champ utilisateur, et rien n'aurait empêché un poste « en stock » d'avoir un utilisateur, ou un poste « affecté » de ne pas en avoir. Avec l'enum, un état incohérent ne peut pas être représenté : le nom n'existe que dans la variante Affecte.
Une variante peut porter plusieurs valeurs, Affecte(String, i32), ou des champs nommés, EnReparation { depuis: i32, prestataire: String }.
match : traiter tous les cas
match compare une valeur à une liste de motifs (patterns) et exécute la branche du premier qui correspond. C'est un switch bien plus puissant, et c'est une expression qui produit une valeur :
impl TypePoste {
/// Durée de garantie habituelle, en années.
fn garantie(&self) -> u32 {
match self {
TypePoste::Fixe | TypePoste::Portable => 3,
TypePoste::Serveur => 5,
}
}
}
fn decrire(statut: &Statut) -> String {
match statut {
Statut::EnStock => String::from("en stock"),
Statut::Affecte(nom) => format!("affecté à {nom}"),
Statut::EnReparation => String::from("en réparation"),
Statut::Reforme => String::from("réformé"),
}
}
fn main() {
let statuts = [
Statut::Affecte(String::from("Julie Lefèvre")),
Statut::EnStock,
Statut::EnReparation,
Statut::Reforme,
];
for statut in &statuts {
println!("{}", decrire(statut));
}
println!("{:?} : garantie {} ans", TypePoste::Serveur, TypePoste::Serveur.garantie());
println!("{}", TypePoste::Portable == TypePoste::Fixe);
}
affecté à Julie Lefèvre
en stock
en réparation
réformé
Serveur : garantie 5 ans
false
Statut::Affecte(nom)extrait la donnée de la variante dans une variablenom, utilisable dans la branche.TypePoste::Fixe | TypePoste::Portableregroupe deux motifs dans une branche.- Chaque branche produit une valeur du même type (
u32, puisString), qui devient la valeur dumatch.
La force de match, c'est qu'il est exhaustif : si tu oublies une variante, le programme ne compile pas.
error[E0004]: non-exhaustive patterns: `&Statut::EnReparation` not covered
--> src/main.rs:9:11
|
9 | match statut {
| ^^^^^^ pattern `&Statut::EnReparation` not covered
|
note: `Statut` defined here
--> src/main.rs:1:6
|
1 | enum Statut {
| ^^^^^^
...
4 | EnReparation,
| ------------ not covered
Motifs avancés : intervalles, gardes et joker
Les motifs ne se limitent pas aux enums :
fn priorite(age: i32, garantie: u32) -> &'static str {
match age {
i32::MIN..=-1 => "date d'achat incohérente",
0..=2 => "récent",
3 | 4 if garantie >= 5 => "encore sous garantie",
3 | 4 => "à surveiller",
_ => "à renouveler",
}
}
| Motif | Correspond à |
|---|---|
0..=2 | Un intervalle de valeurs, bornes comprises |
3 | 4 | L'une ou l'autre valeur |
3 | 4 if garantie >= 5 | Les mêmes valeurs, avec une garde : une condition supplémentaire |
_ | Tout le reste (le joker) |
Statut::Affecte(nom) | Une variante, en extrayant sa donnée |
Pour l'appel priorite(4, 5), la troisième branche correspond (4 ans, garantie de 5 ans) : le résultat est « encore sous garantie ». Pour priorite(4, 3), la garde échoue et c'est la branche suivante qui s'applique : « à surveiller ».
if let et let else : un seul cas qui t'intéresse
Quand un seul motif compte, match est verbeux. Deux raccourcis :
let statut = Statut::Affecte(String::from("Karim Morel"));
// if let : un seul motif nous intéresse
if let Statut::Affecte(nom) = &statut {
println!("Utilisé par {nom}");
}
// let else : on continue seulement si le motif correspond
let Statut::Affecte(nom) = &statut else {
println!("Poste libre");
return;
};
println!("Prévenir {nom} avant l'intervention");
Utilisé par Karim Morel
Prévenir Karim Morel avant l'intervention
if letexécute le bloc seulement si le motif correspond, et peut avoir unelse.let … elseextrait la donnée ou quitte la fonction (avecreturn,breakoucontinue). La variablenomreste ensuite disponible pour toute la suite : c'est idéal pour éviter lesifimbriqués.
Option : une valeur qui peut manquer
Rust n'a pas de null. Une valeur qui peut être absente a le type Option<T>, une enum de la bibliothèque standard :
enum Option<T> {
Some(T), // il y a une valeur, de type T
None, // il n'y a rien
}
Some et None s'écrivent sans préfixe, car ils sont toujours disponibles. Un poste peut ainsi avoir un utilisateur, ou pas :
struct Poste {
numero: String,
utilisateur: Option<String>,
}
fn main() {
let postes = [
Poste { numero: String::from("PC-0001"), utilisateur: Some(String::from("Julie Lefèvre")) },
Poste { numero: String::from("PC-0007"), utilisateur: None },
];
for poste in &postes {
match &poste.utilisateur {
Some(nom) => println!("{} : {nom}", poste.numero),
None => println!("{} : personne", poste.numero),
}
}
}
PC-0001 : Julie Lefèvre
PC-0007 : personne
Tu ne peux pas utiliser l'utilisateur sans avoir traité le cas None : le compilateur l'impose. L'erreur « NullReferenceException » de C# ou « Cannot read properties of null » de JavaScript n'existe pas. Le chapitre 5 donne les méthodes pratiques d'Option.
Exercices
Exercice 1 sur 4 La struct Licence Réussi À revoir
Le service informatique suit aussi ses licences logicielles. Définis une struct Licence avec un nom de logiciel, un prix annuel HT en centimes (u32) et un nombre de postes couverts (u32). Ajoute :
- une fonction associée
new; - une méthode
cout_total(&self) -> u64(prix annuel × nombre de postes) ; - une méthode
ajouter_postes(&mut self, nombre: u32).
Teste avec une suite bureautique à 12 600 centimes par poste, pour 12 postes, puis ajoutes-en 3.
Voir la réponseMasquer la réponse de l'exercice 1
#[derive(Debug)]
struct Licence {
logiciel: String,
prix_annuel_ht: u32,
nb_postes: u32,
}
impl Licence {
fn new(logiciel: &str, prix_annuel_ht: u32, nb_postes: u32) -> Licence {
Licence { logiciel: logiciel.to_string(), prix_annuel_ht, nb_postes }
}
fn cout_total(&self) -> u64 {
u64::from(self.prix_annuel_ht) * u64::from(self.nb_postes)
}
fn ajouter_postes(&mut self, nombre: u32) {
self.nb_postes += nombre;
}
}
fn main() {
let mut suite = Licence::new("Suite bureautique", 12600, 12);
println!("{} : {} centimes", suite.logiciel, suite.cout_total());
suite.ajouter_postes(3);
println!("{} postes : {} centimes", suite.nb_postes, suite.cout_total());
}
Suite bureautique : 151200 centimes
15 postes : 189000 centimes
La conversion en u64 avant la multiplication évite tout dépassement du u32. ajouter_postes prend &mut self, donc la variable doit être déclarée let mut suite.
Tu avais trouvé ?
Exercice 2 sur 4 Ajouter une variante Réussi À revoir
On ajoute la variante EnPret { emprunteur: String, retour: i32 } à l'enum Statut (un poste prêté jusqu'à une année de retour).
- Que se passe-t-il à la compilation pour la fonction
decrirede ce chapitre ? - Écris la branche à ajouter, qui affiche par exemple « prêté à Chloé Lambert jusqu'en 2027 ».
Voir la réponseMasquer la réponse de l'exercice 2
- Le programme ne compile plus : erreur E0004,
non-exhaustive patterns: &Statut::EnPret { .. } not covered. Le compilateur montre lematchà compléter. - La branche à ajouter dans le
matchdedecrire:
Statut::EnPret { emprunteur, retour } => format!("prêté à {emprunteur} jusqu'en {retour}"),
Les accolades extraient les champs nommés de la variante dans des variables du même nom.
Tu avais trouvé ?
Exercice 3 sur 4 Classer un poste avec match Réussi À revoir
Écris une fonction peut_etre_affecte(statut: &Statut) -> bool qui renvoie true seulement pour un poste en stock. Écris-la d'abord avec un match qui liste toutes les variantes, puis en une ligne avec la macro matches!(valeur, motif). Laquelle préfères-tu si l'on ajoute plus tard une variante ?
Voir la réponseMasquer la réponse de l'exercice 3
fn peut_etre_affecte(statut: &Statut) -> bool {
match statut {
Statut::EnStock => true,
Statut::Affecte(_) | Statut::EnReparation | Statut::Reforme => false,
}
}
fn peut_etre_affecte_court(statut: &Statut) -> bool {
matches!(statut, Statut::EnStock)
}
Affecte(_) ignore le nom. Les deux versions donnent le même résultat. La première oblige à décider, à la compilation, si une nouvelle variante (EnPret) peut être affectée ; la seconde renverrait false sans que personne n'y réfléchisse. Pour une règle métier, la version exhaustive est plus sûre.
Tu avais trouvé ?
Exercice 4 sur 4 Lire un if let Réussi À revoir
Que vaut message à la fin de ce code, pour statut = Statut::Reforme puis pour statut = Statut::Affecte(String::from("Inès Bonnet")) ?
let message = if let Statut::Affecte(nom) = &statut {
format!("à rendre par {nom}")
} else {
String::from("rien à récupérer")
};
Voir la réponseMasquer la réponse de l'exercice 4
Pour Reforme, le motif ne correspond pas : message vaut « rien à récupérer ». Pour Affecte, il vaut « à rendre par Inès Bonnet ». Comme if, if let est une expression : ses deux branches renvoient une String.
Tu avais trouvé ?