Modèles de programmation défensive en Rust
(corrode.dev)- Présentation d’habitudes de codage qui permettent de bloquer les bugs en amont en tirant pleinement parti du système de types et du compilateur de Rust
- Présentation de cas de code smells fragiles tels que l’indexation de vecteurs, l’abus de
Default, lesmatchincomplets ou les paramètres booléens inutiles, avec leurs alternatives - Le principe central consiste à concevoir les structures de sorte que le compilateur impose les invariants, en s’appuyant sur le pattern matching, les champs privés ou l’attribut
#[must_use] - Présentation concrète de techniques défensives au niveau du code réel : utilisation de
TryFrom, déstructuration complète des structures, mutabilité temporaire, validation dans les constructeurs - Ces modèles sont essentiels pour garantir la stabilité lors du refactoring et améliorer la maintenabilité à long terme
Vue d’ensemble de la programmation défensive
- Un emplacement accompagné du commentaire
// this should never happenest un point où un invariant implicite est rompu- Dans la plupart des cas, le développeur n’a pas pris en compte toutes les conditions limites ni les futures modifications du code
- Le compilateur Rust garantit la sûreté mémoire, mais des erreurs de logique métier restent possibles
- De petits idiomes acquis avec des années d’expérience pratique améliorent fortement la qualité du code
Code smell : indexation de vecteurs
- Une forme comme
if !vec.is_empty() { let x = &vec[0]; }présente un risque de panic à l’exécution, car la vérification de longueur et l’indexation sont séparées - En utilisant le pattern matching sur les slices (
match vec.as_slice()), le compilateur force la vérification de tous les états- Il devient possible de traiter explicitement tous les cas : vecteur vide, élément unique, doublons, etc.
- C’est un exemple représentatif d’une conception où le compilateur garantit les invariants
Code smell : usage inconsidéré de Default
..Default::default()entraîne un risque d’omission lors de l’ajout de nouveaux champs ainsi qu’un problème de valeurs implicites- Si tous les champs sont initialisés explicitement, le compilateur impose aussi l’initialisation des nouveaux champs
- Avec une forme comme
let Foo { field1, field2, .. } = Foo::default();, on peut déstructurer une structure par défaut puis redéfinir sélectivement certains champs- Cela permet d’équilibrer conservation des valeurs par défaut et surcharge explicite
Code smell : implémentations de traits fragiles
- En déstructurant complètement les champs d’une structure lors d’une comparaison, l’ajout d’un nouveau champ provoque une erreur de compilation servant d’alerte
- Exemple : dans une implémentation de
PartialEq,let Self { size, toppings, .. } = self;
- Exemple : dans une implémentation de
- Si un nouveau champ comme
extra_cheeseest ajouté, la logique de comparaison doit être réexaminée - Le même principe s’applique aussi à d’autres traits comme
Hash,DebugouClone
Code smell : TryFrom requis à la place de From
- Quand une conversion ne peut pas toujours réussir, il faut indiquer explicitement la possibilité d’échec avec
TryFromplutôt qu’utiliserFrom - L’usage de
unwrap_or_elseest un signal qui masque un échec potentiel ; une stratégie de fail fast est plus sûre
Code smell : match incomplet
- Un pattern attrape-tout comme
_ => {}présente un risque d’omission lors de l’ajout d’un nouveau variant - Si tous les variants sont listés explicitement, le compilateur avertit lorsqu’un nouveau cas n’est pas pris en charge
- La même logique peut être regroupée avec une forme comme
Variant3 | Variant4
Code smell : abus du placeholder _
- N’utiliser que
_rend peu clair quels champs ou variables ont été omis - Des noms explicites comme
has_fuel: _, has_crew: _améliorent la lisibilité
Modèle : mutabilité temporaire
- Quand des données ne doivent être mutables que pendant l’initialisation, on peut utiliser une forme comme
let mut data = ...; data.sort(); let data = data; - En exploitant la portée des blocs, on peut éviter d’exposer la variable temporaire à l’extérieur
- Exemple :
let data = { let mut d = get_vec(); d.sort(); d };
- Exemple :
- Cela permet aussi de délimiter clairement les portées lors d’une initialisation impliquant plusieurs variables temporaires
Modèle : forcer la validation dans les constructeurs
- Lors de la création d’une structure, il faut imposer le passage par une logique de validation
- En ajoutant un champ
_private: (), la création directe depuis l’extérieur devient impossible - L’attribut
#[non_exhaustive]empêche la création hors de la crate et signale une future extensibilité
- En ajoutant un champ
- Pour imposer cela aussi dans les modules internes, on peut utiliser une structure de modules imbriqués avec un type privé (
Seal)- Comme
Sealn’existe qu’en interne, la création directe en dehors denew()devient impossible
- Comme
- En rendant les champs privés et en fournissant des getters, on préserve l’état invariant
- Critères d’application
- Bloquer le code externe :
_privateou#[non_exhaustive] - Bloquer le code interne : module privé +
Seal - Transformer la logique de validation en garantie assurée par le compilateur
- Bloquer le code externe :
Modèle : utilisation de l’attribut #[must_use]
#[must_use]permet d’éviter qu’une valeur de retour importante soit ignorée- Exemple :
#[must_use = "Configuration must be applied to take effect"]
- Exemple :
- Si l’utilisateur ignore la valeur de retour, le compilateur émet un avertissement
- C’est un mécanisme défensif simple mais puissant, largement utilisé aussi dans la bibliothèque standard, notamment pour
Result
Code smell : paramètres booléens
- Une forme comme
fn process_data(..., compress: bool, encrypt: bool, validate: bool)présente un sens peu clair et un risque d’erreur d’ordre - Des types comme
enum Compressionouenum Encryptionpermettent d’exprimer explicitement l’intention - S’il existe plusieurs options, on peut utiliser une structure de paramètres (
Params struct)- Des méthodes prédéfinies comme
ProcessDataParams::production()améliorent la réutilisabilité
- Des méthodes prédéfinies comme
- Lors de l’ajout de nouvelles options, l’impact sur les appels existants est minimisé
Automatisation avec les lints Clippy
- Les principaux modèles défensifs peuvent être vérifiés automatiquement avec des lints Clippy
indexing_slicing: interdit l’indexation directefallible_impl_from: recommandeTryFromau lieu deFromwildcard_enum_match_arm: interdit le pattern_fn_params_excessive_bools: avertit en cas d’excès de paramètres booléensmust_use_candidate: propose des candidats pour#[must_use]
- On peut les appliquer à tout le projet via
#![deny(clippy::...)]ou une configuration dansCargo.toml
Conclusion
- Le cœur de la programmation défensive en Rust consiste à tirer activement parti du système de types et du compilateur pour rendre les invariants explicites et vérifiables
- Ces modèles contribuent à sécuriser les refactorings, minimiser les risques de bugs et renforcer la maintenabilité à long terme
- C’est une approche qui met en pratique le principe suivant : « le meilleur bug est celui qui ne compile pas »
1 commentaires
Avis Hacker News
J’ai bien aimé l’article. Cela dit, l’exemple PizzaOrder donne l’impression de concentrer trop de responsabilités dans une seule struct
Si l’objectif est d’exclure
ordered_atde la comparaison, je pense qu’il vaudrait mieux séparer cela en deux structs,PizzaDetailsetPizzaOrderCela permettrait de rendre explicite, lors de l’implémentation de
PartialEq, qu’on ne compare quedetailsSi l’heure de commande diffère, ce n’est pas la même commande, donc le définir comme égal au niveau du type est risqué
Mettre
PartialEqsurPizzaDetailsest acceptable, mais la logique de comparaison des commandes devrait être placée dans une fonction métier distinctePizzaDetails, ce changement peut affecter la logique de déduplication des pizzasL’idéal est d’utiliser une struct uniquement pour regrouper des données
Pour éviter qu’un changement ait des effets ailleurs, on peut aussi envisager un type séparé comme
PizzaComparatorouPizzaFlavorCe serait bien de pouvoir mettre des annotations de champ comme
{important_to_flavour=true}sur les champs, un peu comme en ProtobufPar exemple, si on veut comparer des chaînes sans tenir compte de la casse, comment faudrait-il les séparer ?
Ce qui est vraiment génial en Rust, c’est qu’il y a souvent moins besoin de programmation défensive
Grâce aux règles de propriété et d’emprunt, on peut garantir qu’un certain objet n’est accessible qu’en un seul endroit dans tout le programme
Une référence ne peut pas être nulle, et un smart pointer non plus
Le système de types garantit aussi que, si l’on transfère la propriété de
self, il devient ensuite impossible d’appeler des méthodes dessusGrâce à cela, la sûreté des threads, les durées de vie, la possibilité de cloner, etc. sont vérifiées globalement à la compilation
Dans d’autres langages, il faut maintenir l’immuabilité via un style fonctionnel pour obtenir ces avantages, alors que Rust les impose par le système de types
Le sujet de l’article, c’était des bogues logiques que même le borrow checker ne détecte pas
J’ai l’impression qu’il est plus sage d’éviter l’indexation directe dans les tableaux ou les vecteurs
Le jour de l’incident Cloudflare lié à
unwrap, j’ai moi-même trouvé un bug où un slice dépassait la fin d’un vecteurDepuis, je suis passé à une approche fondée sur les itérateurs, et cela me semble bien plus sûr
unwrapcomme un « incident »En Rust,
unwrapest l’équivalent deasserten C. Son rôle est simplement de signaler un problème en cas d’échecOn peut toujours écrire des bugs en Rust
L’une des habitudes contre lesquelles les développeurs Rust doivent se prémunir, c’est l’ajout de dépendances crate inutiles
Rust a tendance à encourager cette habitude. Par exemple, le fait que le Rust Book utilise le crate
randcomme exemple de base contribue à créer cette cultureBien sûr, c’était un choix stratégique pour pouvoir remplacer facilement les packages liés à la cryptographie, mais cela reste problématique si cela devient un réflexe
Mais plus tard, j’en ai compris l’intention et j’ai changé d’avis
L’implémentation de l’égalité partielle était intéressante
Je me demande aussi comment utiliser un enum quand on veut éviter les paramètres booléens
J’utilise une struct qui encapsule un bool, mais je trouve dommage de ne pas pouvoir la manipuler comme un bool ordinaire
Je me demande s’il existe un moyen d’utiliser un enum comme un bool
Je gère cela en regroupant la logique nécessaire dans un Trait, ou en ajoutant des méthodes communes dans un bloc
impl <Enum>C’est plus lisible, et cela permet de définir clairement le comportement propre à chaque variante
impl Deref, mais je ne sais pas si c’est vraiment une bonne idéeLe
matchdu premier exemple me paraît excessifVec.first()ouVec.iter().nth(0)sont plus clairs et plus conformes à l’intentionmatchrevient au contraire à une solution plus compliquée que le problèmeSi on peut éliminer le
if, on peut aussi éliminer lematch, donc il n’y a pas de différence du point de vue de la sûretéfirst()est bien plus concis et explicitematcha l’intérêt de pousser à traiter aussi le cas « lorsqu’il y a au moins un élément »Autrement dit, il met en avant le principe éviter de séparer la vérification du code qui en dépend
Chaque fois que je lis ce genre d’article, je me demande pourquoi il n’existe pas d’équipe dédiée à la surveillance des patterns de code
Comme le SOC ou la QA, ce serait bien d’avoir une équipe qui observe à long terme les patterns d’une base de code
Les outils automatisés de détection des code smells ont leurs limites
Elle s’occupe des règles de lint, de la documentation, de la formation des développeurs et de la maintenance des bibliothèques communes
Quand plusieurs équipes répètent le même problème, elle conçoit une API centrale capable de l’unifier
Mais dans la réalité, quand le code atteint des millions de lignes, cela devient très difficile à gérer
Je me demande comment encourager ce type de bons patterns de code au sein d’une équipe
Pendant les revues de code, cela dégénère souvent en « débat de style » improductif
Mais curieusement, quand un linter affiche un avertissement, ces débats disparaissent presque complètement
Le fait que le trait
TryFromait été ajouté dans la version 1.34 a été vraiment utileLe code qui utilisait
unwrap_or_else()est probablement un vestige d’une période antérieureLa documentation du trait From explique désormais très clairement quand il faut l’implémenter
unwrap_or_else()me fait sourire, comme si on « donnait un ordre à l’ordinateur sur un ton menaçant »Je pense que ce type de patterns de programmation défensive pourrait aussi aider à améliorer la qualité de la génération de code par IA à grande échelle
Les retours précis fournis par Clippy ou par le compilateur Rust peuvent beaucoup aider les agents IA à réduire leurs erreurs et à s’orienter correctement