32 points par GN⁺ 2025-12-07 | 1 commentaires | Partager sur WhatsApp
  • 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, les match incomplets 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 happen est 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;
  • Si un nouveau champ comme extra_cheese est ajouté, la logique de comparaison doit être réexaminée
  • Le même principe s’applique aussi à d’autres traits comme Hash, Debug ou Clone

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 TryFrom plutôt qu’utiliser From
  • L’usage de unwrap_or_else est 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 };
  • 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é
  • Pour imposer cela aussi dans les modules internes, on peut utiliser une structure de modules imbriqués avec un type privé (Seal)
    • Comme Seal n’existe qu’en interne, la création directe en dehors de new() devient impossible
  • 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 : _private ou #[non_exhaustive]
    • Bloquer le code interne : module privé + Seal
    • Transformer la logique de validation en garantie assurée par le compilateur

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"]
  • 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 Compression ou enum Encryption permettent 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é
  • 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 directe
    • fallible_impl_from : recommande TryFrom au lieu de From
    • wildcard_enum_match_arm : interdit le pattern _
    • fn_params_excessive_bools : avertit en cas d’excès de paramètres booléens
    • must_use_candidate : propose des candidats pour #[must_use]
  • On peut les appliquer à tout le projet via #![deny(clippy::...)] ou une configuration dans Cargo.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

 
GN⁺ 2025-12-07
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_at de la comparaison, je pense qu’il vaudrait mieux séparer cela en deux structs, PizzaDetails et PizzaOrder
    Cela permettrait de rendre explicite, lors de l’implémentation de PartialEq, qu’on ne compare que details

    • Bonne remarque. Mais je pense toujours que, sur le plan logique, c’est une modélisation incorrecte
      Si 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 PartialEq sur PizzaDetails est acceptable, mais la logique de comparaison des commandes devrait être placée dans une fonction métier distincte
    • L’approche consistant à séparer la structure est bonne, mais le problème est qu’en modifiant PizzaDetails, ce changement peut affecter la logique de déduplication des pizzas
      L’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 PizzaComparator ou PizzaFlavor
      Ce serait bien de pouvoir mettre des annotations de champ comme {important_to_flavour=true} sur les champs, un peu comme en Protobuf
    • Scinder une structure uniquement pour une autre manière de comparer n’est pas vraiment généralisable
      Par 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 dessus
    Grâce à cela, la sûreté des threads, les durées de vie, la possibilité de cloner, etc. sont vérifiées globalement à la compilation

    • Moi aussi, je pense que la vraie force de Rust réside dans tout ce dont on n’a pas besoin de se préoccuper
      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
    • Mais ce commentaire ne semble pas vraiment lié à l’article d’origine
      Le sujet de l’article, c’était des bogues logiques que même le borrow checker ne détecte pas
    • Le contenu de l’article se concentrait surtout sur des patterns de code pour éviter les erreurs logiques lorsqu’on améliore un programme de façon itérative
  • 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 vecteur
    Depuis, je suis passé à une approche fondée sur les itérateurs, et cela me semble bien plus sûr

    • Je ne pense pas qu’il faille considérer l’incident unwrap comme un « incident »
      En Rust, unwrap est l’équivalent de assert en C. Son rôle est simplement de signaler un problème en cas d’échec
      On peut toujours écrire des bugs en Rust
    • Au final, c’est le même problème. Le camp Rust veut abandonner le C, mais en C aussi il est courant d’utiliser des handles plutôt que des index
  • 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 rand comme exemple de base contribue à créer cette culture
    Bien 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

    • Moi aussi, cet exemple m’avait d’abord rebuté vis-à-vis de Rust
      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

    • Moi aussi, je préfère presque toujours enum + match!
      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
    • On pourrait peut-être essayer quelque chose comme impl Deref, mais je ne sais pas si c’est vraiment une bonne idée
  • Le match du premier exemple me paraît excessif
    Vec.first() ou Vec.iter().nth(0) sont plus clairs et plus conformes à l’intention

    • Je suis d’accord. Utiliser match revient au contraire à une solution plus compliquée que le problème
      Si on peut éliminer le if, on peut aussi éliminer le match, donc il n’y a pas de différence du point de vue de la sûreté
      first() est bien plus concis et explicite
    • Pour exprimer le même comportement plus simplement, on peut aussi utiliser exactly_one d’itertools
    • Cela dit, match a 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

    • Dans notre entreprise (environ 300 personnes), il existe une équipe dédiée à la dette technique qui remplit ce rôle
      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
    • La plupart des grandes entreprises tech ont ce type d’équipe
      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 TryFrom ait été ajouté dans la version 1.34 a été vraiment utile
    Le code qui utilisait unwrap_or_else() est probablement un vestige d’une période antérieure
    La documentation du trait From explique désormais très clairement quand il faut l’implémenter

    • J’apprends encore Rust, mais le nom 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