Les patrons de conception en Rust
Un patron de conception (design pattern) est une façon éprouvée de résoudre un problème qui revient sans cesse. Ce n’est pas une bibliothèque à installer. C’est plutôt une recette : « quand vous avez ce problème, organisez votre code comme ceci ».
Beaucoup de patrons venus de Java ou de C# prennent une autre forme en Rust, ou ne servent plus à rien. Rust a ses propres favoris, construits sur son système de types très strict, les enums, les traits et l’ownership (possession). L’idée derrière la plupart d’entre eux est toujours la même : laisser le compilateur attraper les erreurs à votre place, pour qu’elles n’atteignent jamais vos utilisateurs. Chaque section ci-dessous commence par le problème, puis montre le patron qui le règle.
Newtype : sens différent, type différent
Section intitulée « Newtype : sens différent, type différent »Le problème. Un simple f64 peut représenter n’importe quoi : des mètres, des pieds, des secondes, des euros. Si une fonction attend un f64 en mètres et que vous lui passez des pieds, le compilateur ne peut rien pour vous : les deux ne sont que des f64. (C’est justement ce genre de confusion d’unités qui a fait perdre à la NASA la sonde Mars Climate Orbiter en 1999.)
Le patron. Enveloppez la valeur dans une toute petite struct à un seul champ. Meters et Feet deviennent alors deux types différents, et Rust refuse de les mélanger :
#[derive(Debug, Clone, Copy)]struct Meters(f64);
#[derive(Debug, Clone, Copy)]struct Feet(f64);
impl Feet { fn to_meters(self) -> Meters { Meters(self.0 * 0.3048) }}
fn safe_altitude(height: Meters) -> bool { height.0 >= 300.0}
fn main() { let tower = Meters(330.0); let plane = Feet(900.0);
println!("Tour assez haute ? {}", safe_altitude(tower)); println!("900 pieds font {:.1} mètres", plane.to_meters().0); println!("Avion assez haut ? {}", safe_altitude(plane.to_meters()));}Sortie :
Tour assez haute ? true900 pieds font 274.3 mètresAvion assez haut ? falseSi vous oubliez la conversion et écrivez safe_altitude(plane), le programme ne compile même pas :
error[E0308]: mismatched types --> src/main.rs:13:34 |13 | println!("{}", safe_altitude(plane)); | ------------- ^^^^^ expected `Meters`, found `Feet` | | | arguments to this function are incorrectLe compilateur (qui s’exprime en anglais) dit : « types incompatibles : Meters attendu, Feet trouvé ».
Un newtype ne coûte rien à l’exécution : en mémoire, Meters est exactement un f64. Utilisez-le pour les identifiants (UserId(u32) contre OrderId(u32)), les unités et les chaînes déjà validées. Il permet aussi de contourner la règle de l’orphelin (orphan rule, voir Traits avancés).
Builder : beaucoup d’options, un résultat clair
Section intitulée « Builder : beaucoup d’options, un résultat clair »Le problème. Certaines structs ont beaucoup de champs, et la plupart sont facultatifs. Un constructeur comme Email::new("[email protected]", "Salut", "", vec![], false) est difficile à lire : quel false correspond à quoi ? Et que se passe-t-il si l’adresse manque ou est fausse ?
Le patron. Une struct séparée, le builder (constructeur pas à pas), rassemble les réglages grâce à de petites méthodes bien nommées. Chaque méthode renvoie le builder, ce qui permet d’enchaîner les appels. Une dernière méthode build() vérifie le tout et renvoie un Result :
struct Email { to: String, subject: String, body: String, urgent: bool,}
#[derive(Default)]struct EmailBuilder { to: Option<String>, subject: Option<String>, body: String, urgent: bool,}
impl EmailBuilder { fn new() -> Self { EmailBuilder::default() }
fn to(mut self, address: &str) -> Self { self.to = Some(address.to_string()); self }
fn subject(mut self, subject: &str) -> Self { self.subject = Some(subject.to_string()); self }
fn body(mut self, text: &str) -> Self { self.body = text.to_string(); self }
fn urgent(mut self) -> Self { self.urgent = true; self }
fn build(self) -> Result<Email, String> { let to = self.to.ok_or("un e-mail a besoin d'un destinataire")?; if !to.contains('@') { return Err(format!("'{}' n'est pas une adresse e-mail", to)); } let subject = self.subject.unwrap_or_else(|| String::from("(sans objet)")); Ok(Email { to, subject, body: self.body, urgent: self.urgent }) }}
fn show(result: Result<Email, String>) { match result { Ok(e) => println!("Prêt : dest={} objet={} corps={:?} urgent={}", e.to, e.subject, e.body, e.urgent), Err(msg) => println!("Erreur : {}", msg), }}
fn main() { let email = EmailBuilder::new() .subject("Déjeuner ?") .body("Pizza à midi ?") .urgent() .build(); show(email);
show(EmailBuilder::new().subject("Bonjour").build()); show(EmailBuilder::new().to("ana.example.com").build());}Sortie :
Prêt : [email protected] objet=Déjeuner ? corps="Pizza à midi ?" urgent=truePrêt : [email protected] objet=(sans objet) corps="" urgent=falseErreur : un e-mail a besoin d'un destinataireErreur : 'ana.example.com' n'est pas une adresse e-mailChaque setter prend mut self (il prend possession du builder), modifie un champ, puis rend le builder. C’est ce qui rend la chaîne possible. L’appelant ne règle que ce qui l’intéresse, les noms expliquent chaque valeur, et build() est le seul endroit qui vérifie les règles. À l’intérieur, ok_or transforme le None du destinataire manquant en Err, et ? renvoie aussitôt cette erreur. Vous croiserez des builders dans beaucoup de crates, par exemple std::thread::Builder et std::process::Command (voir Environnement et processus).
Default et la mise à jour de struct : l’option légère
Section intitulée « Default et la mise à jour de struct : l’option légère »Le problème. Un builder, c’est beaucoup de code. Si votre struct n’a aucune règle à vérifier, seulement des valeurs de départ raisonnables, c’est excessif.
Le patron. Implémentez Default avec de bonnes valeurs de départ, puis utilisez la syntaxe de mise à jour de struct .. pour ne changer que les champs qui vous intéressent :
#[derive(Debug, Clone)]struct ServerConfig { host: String, port: u16, workers: u32, verbose: bool,}
impl Default for ServerConfig { fn default() -> Self { ServerConfig { host: String::from("127.0.0.1"), port: 8080, workers: 4, verbose: false, } }}
fn main() { let dev = ServerConfig { verbose: true, ..Default::default() }; let prod = ServerConfig { host: String::from("0.0.0.0"), workers: 16, ..ServerConfig::default() }; // Partir d'une autre config au lieu de la valeur par défaut : let staging = ServerConfig { port: 9090, ..prod.clone() };
println!("{:?}", dev); println!("{:?}", prod); println!("{:?}", staging);}Sortie :
ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, verbose: true }ServerConfig { host: "0.0.0.0", port: 8080, workers: 16, verbose: false }ServerConfig { host: "0.0.0.0", port: 9090, workers: 16, verbose: false }..quelque_chose signifie « prends dans quelque_chose tous les champs que je n’ai pas cités ». Pourquoi prod.clone() ? La mise à jour de struct déplace les champs restants, et host est une String. Sans le clone, prod serait partiellement déplacée et vous ne pourriez plus l’afficher ensuite. Une bonne règle : utilisez Default + .. quand toutes les combinaisons de valeurs sont acceptables, et un builder quand certaines combinaisons sont invalides.
Typestate : une machine à états vérifiée à la compilation
Section intitulée « Typestate : une machine à états vérifiée à la compilation »Le problème. Certaines choses doivent se faire dans un ordre précis. Une requête web a besoin d’une URL avant de pouvoir être envoyée. Avec une struct ordinaire, vous vérifieriez à l’exécution (« l’URL est-elle définie ? sinon, renvoyer une erreur »), en espérant que chaque appelant gère cette erreur.
Le patron. Faites de l’état une partie du type (c’est le typestate). Une Request<NoUrl> n’a tout simplement pas de méthode send. Seul le fait de définir l’URL la transforme en Request<HasUrl>, qui, elle, en possède une :
// Les deux états. Ils ne contiennent aucune donnée (ou seulement ce dont l'état a besoin).struct NoUrl;struct HasUrl(String);
struct Request<U> { url: U, headers: Vec<(String, String)>,}
impl Request<NoUrl> { fn new() -> Self { Request { url: NoUrl, headers: Vec::new() } }
// Change le type : Request<NoUrl> -> Request<HasUrl> fn url(self, url: &str) -> Request<HasUrl> { Request { url: HasUrl(url.to_string()), headers: self.headers } }}
// Disponible dans tous les étatsimpl<U> Request<U> { fn header(mut self, key: &str, value: &str) -> Self { self.headers.push((key.to_string(), value.to_string())); self }}
// Disponible seulement une fois l'URL définieimpl Request<HasUrl> { fn send(self) -> String { format!("GET {} avec {} en-tête(s)", self.url.0, self.headers.len()) }}
fn main() { let response = Request::new() .header("Accept", "text/html") .url("https://example.com") .header("User-Agent", "rust-tutorial") .send();
println!("{}", response);}Sortie :
GET https://example.com avec 2 en-tête(s)Essayez maintenant d’envoyer la requête sans URL, avec Request::new().send(), et le compilateur vous arrête :
error[E0599]: no method named `send` found for struct `Request<NoUrl>` in the current scope --> src/main.rs:22:35 |22 | let response = Request::new().send(); | ^^^^ method not found in `Request<NoUrl>` | = note: the method was found for `Request<HasUrl>`Autrement dit : « aucune méthode send pour Request<NoUrl> ; la méthode existe pour Request<HasUrl> ».
Ce bug ne peut pas exister dans un programme qui compile. Il n’y a aucune vérification à l’exécution et aucun coût supplémentaire : NoUrl occupe zéro octet. Comme url() prend self par valeur, l’ancienne Request<NoUrl> est consommée, et personne ne peut continuer à utiliser l’état « avant » par accident. Cette technique utilise les génériques de manière astucieuse ; faites une petite modification dans le Playground pour bien sentir comment elle fonctionne.
RAII et gardes Drop : un nettoyage impossible à oublier
Section intitulée « RAII et gardes Drop : un nettoyage impossible à oublier »Le problème. Les ressources doivent être libérées : fermer le fichier, déverrouiller le verrou, supprimer le dossier temporaire, afficher « terminé ». Si le nettoyage est une fonction qu’il faut penser à appeler, quelqu’un finira par l’oublier, surtout lors d’un return anticipé ou d’une erreur.
Le patron. Placez le nettoyage dans Drop. Rust appelle drop automatiquement quand une valeur sort de sa portée, quel que soit le chemin de sortie : fin normale, return anticipé, ?, et même une panic. On appelle cela RAII (Resource Acquisition Is Initialization, « l’acquisition d’une ressource est une initialisation » : obtenir la ressource et posséder une valeur sont une seule et même étape). Une valeur dont le seul rôle est de faire le ménage s’appelle une garde (guard) :
struct Guard { name: String,}
impl Guard { fn new(name: &str) -> Guard { println!("début : {}", name); Guard { name: name.to_string() } }}
impl Drop for Guard { fn drop(&mut self) { println!("nettoyage : {}", self.name); }}
fn risky(fail: bool) -> Result<(), String> { let _guard = Guard::new("tâche risquée"); if fail { return Err(String::from("quelque chose a mal tourné")); // la garde fait quand même le ménage } println!("tâche terminée"); Ok(())}
fn main() { { let _block = Guard::new("bloc"); println!("dans le bloc"); } // _block est libérée ici
println!("{:?}", risky(false)); println!("{:?}", risky(true));}Sortie :
début : blocdans le blocnettoyage : blocdébut : tâche risquéetâche terminéenettoyage : tâche risquéeOk(())début : tâche risquéenettoyage : tâche risquéeErr("quelque chose a mal tourné")Vous utilisez déjà ce patron en permanence. Un File se ferme tout seul quand il est libéré. Un MutexGuard (obtenu avec mutex.lock(), voir Concurrence) déverrouille le mutex quand il est libéré. Une Box rend sa mémoire quand elle est libérée (Smart pointers). Voilà pourquoi le code Rust ne contient presque jamais d’appels à « close » ou « free ».
From et Into : des arguments de fonction accueillants
Section intitulée « From et Into : des arguments de fonction accueillants »Le problème. Votre fonction prend une String, alors les appelants qui ont un &str doivent écrire String::from("Ana") ou "Ana".to_string() à chaque fois. Ou bien votre fonction prend un UserId, et les appelants ont un simple u32. C’est peu de chose, mais cela ajoute du bruit partout.
Le patron. Implémentez From pour vos types, et acceptez impl Into<T> dans vos fonctions. Les appelants peuvent alors passer n’importe quoi qui se convertit :
#[derive(Debug)]struct UserId(u32);
impl From<u32> for UserId { fn from(n: u32) -> Self { UserId(n) }}
#[derive(Debug)]struct User { id: UserId, name: String,}
impl User { fn new(id: impl Into<UserId>, name: impl Into<String>) -> Self { User { id: id.into(), name: name.into() } }}
fn main() { let ana = User::new(1, "Ana"); // u32 et &str let ben = User::new(UserId(2), String::from("Ben")); // déjà les bons types
println!("{:?}", ana); println!("{:?}", ben);}Sortie :
User { id: UserId(1), name: "Ana" }User { id: UserId(2), name: "Ben" }Implémentez From, jamais Into directement : vous obtenez Into gratuitement. La même astuce fait fonctionner la gestion des erreurs : ? appelle From::from sur l’erreur, donc implémenter From<io::Error> for MyError permet à ? de convertir les erreurs pour vous (voir Erreurs personnalisées). Réservez-la aux conversions qui ne peuvent pas échouer. Si une conversion peut échouer, utilisez TryFrom, qui renvoie un Result.
Machines à états avec des enums et états impossibles
Section intitulée « Machines à états avec des enums et états impossibles »Le problème. Une commande passe de « dans le panier » à « payée », puis à « expédiée ». Un premier essai ressemble souvent à ceci :
struct Order { paid: bool, shipped: bool, tracking_number: Option<String>,}Mais cela autorise des absurdités : shipped: true avec paid: false, ou un numéro de suivi sur une commande qui n’est pas encore partie. Chaque fonction doit vérifier ces combinaisons impossibles.
Le patron. Rendez les états illégaux impossibles à représenter. Utilisez une enum avec une variante par état, et donnez à chaque variante uniquement les données que cet état possède vraiment. Les transitions sont des méthodes qui prennent l’ancien état et renvoient le nouveau :
#[derive(Debug)]enum Order { Cart { items: Vec<String> }, Paid { items: Vec<String>, amount: u32 }, Shipped { tracking: String }, Cancelled,}
impl Order { fn pay(self, amount: u32) -> Order { match self { Order::Cart { items } => Order::Paid { items, amount }, other => other, // seul un panier peut être payé ; tout le reste ne change pas } }
fn ship(self, tracking: &str) -> Order { match self { Order::Paid { .. } => Order::Shipped { tracking: tracking.to_string() }, other => other, } }
fn cancel(self) -> Order { match self { Order::Shipped { .. } => self, // trop tard pour annuler _ => Order::Cancelled, } }
fn status(&self) -> String { match self { Order::Cart { items } => format!("panier de {} article(s)", items.len()), Order::Paid { items, amount } => format!("payée : {} pour {} article(s)", amount, items.len()), Order::Shipped { tracking } => format!("expédiée, suivi {}", tracking), Order::Cancelled => String::from("annulée"), } }}
fn main() { let order = Order::Cart { items: vec![String::from("livre"), String::from("stylo")] }; println!("{}", order.status());
let order = order.ship("XYZ"); // pas encore payée : rien ne se passe println!("{}", order.status());
let order = order.pay(25).ship("TRACK-42"); println!("{}", order.status());
let order = order.cancel(); // déjà expédiée println!("{}", order.status());
let other = Order::Cart { items: vec![] }.cancel(); println!("{}", other.status());}Sortie :
panier de 2 article(s)panier de 2 article(s)expédiée, suivi TRACK-42expédiée, suivi TRACK-42annuléeUne commande expédiée ne peut pas manquer de numéro de suivi : la variante Shipped en a toujours un. Un panier ne peut pas avoir de numéro de suivi : il n’existe aucun champ pour cela. Et comme match doit couvrir toutes les variantes, ajouter un nouvel état (par exemple Refunded) pousse le compilateur à vous montrer chaque endroit à mettre à jour.
Enums ou trait objects ? Les langages orientés objet utilisent souvent une interface « State » avec une classe par état. En Rust, vous pourriez faire la même chose avec des trait objects (objets trait, Box<dyn State>), et le Rust Book montre comment. Voici comment choisir :
- Enum : vous connaissez tous les états à l’avance. Vous profitez des vérifications exhaustives de
match, et toute la logique est au même endroit. C’est le choix habituel en Rust. - Trait objects : d’autres personnes (ou d’autres crates) doivent pouvoir ajouter de nouveaux états sans modifier votre code.
- Typestate (vu plus haut) : vous voulez que les appels dans le mauvais ordre soient des erreurs de compilation, et pas seulement ignorés à l’exécution. C’est plus strict, mais l’état doit être connu à la compilation.
Erreurs courantes
Section intitulée « Erreurs courantes »Écrire let _ = guard au lieu de let _guard = guard. Les deux se ressemblent beaucoup, mais ne se comportent pas pareil. Un nom qui commence par un tiret bas (comme _guard) garde la valeur en vie jusqu’à la fin de la portée. Un simple _ n’est pas du tout une variable : la valeur est donc libérée immédiatement :
struct Guard;
impl Drop for Guard { fn drop(&mut self) { println!("garde libérée"); }}
fn main() { let _ = Guard; println!("travail avec `let _`");
let _g = Guard; println!("travail avec `let _g`");}Sortie :
garde libéréetravail avec `let _`travail avec `let _g`garde libéréeAvec let _, le nettoyage a eu lieu avant le travail, ce qui est exactement l’inverse de ce qu’il faut pour un verrou ou un chronomètre. Pour les gardes, donnez toujours un nom à la variable.
Utiliser un patron simplement parce qu’il existe. Un builder pour une struct à deux champs, ou un typestate pour quelque chose qu’on n’utilise jamais de travers, ajoute du code sans rien rendre plus sûr. Commencez simple. Sortez un patron quand vous ressentez vraiment le problème qu’il résout.
Récapitulatif
Section intitulée « Récapitulatif »- Newtype (
struct Meters(f64)) donne des types différents à des données identiques, pour que le compilateur repère les confusions, sans aucun coût à l’exécution. - Builder offre une configuration lisible et enchaînée, et un seul
build()qui valide et renvoie unResult. Default +..est le choix plus léger quand il n’y a rien à vérifier. - Typestate place l’état dans le type (
Request<NoUrl>contreRequest<HasUrl>), si bien qu’appeler les méthodes dans le mauvais ordre ne compile pas. - Les gardes RAII / Drop lancent le nettoyage automatiquement, quelle que soit la façon de sortir d’une portée.
- From/Into permettent aux fonctions d’accepter de nombreux types d’entrée grâce à
impl Into<T>. - Les enums modélisent des machines à états et rendent les états illégaux impossibles à représenter ; n’utilisez les trait objects que si d’autres doivent ajouter des états.
Petit quiz
Section intitulée « Petit quiz »Petit quiz
1. Qu'apporte le newtype struct Meters(f64) par rapport à un simple f64 ?
2. Dans le patron builder de cette page, que renvoie build() ?
3. Dans l'exemple typestate, pourquoi ne peut-on pas appeler send() sur Request::new() ?
4. Quand le nettoyage d'une garde Drop s'exécute-t-il ?
5. Que fait let _ = Guard; ?
Score : 0 / 5
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Rust Design Patterns (en anglais) : le livre communautaire et gratuit sur les idiomes, patrons et anti-patrons de Rust, dont newtype, builder et les gardes RAII.
- Implementing an Object-Oriented Design Pattern — The Rust Book (en anglais) : le patron « State » réalisé avec des trait objects, puis refait en encodant les états dans les types.
- Rust API Guidelines (en anglais) : les conseils officiels pour concevoir des API agréables et idiomatiques (conversions, builders, nommage).
- RAII — Rust by Example (en anglais) : comment l’ownership et
Dropvous offrent un nettoyage automatique. - The Typestate Pattern in Rust — Cliff L. Biffle (en anglais) : un guide plus approfondi, très agréable à lire, sur le typestate, avec davantage d’exemples.