1 points par GN⁺ 5 시간 전 | 1 commentaires | Partager sur WhatsApp
  • En Go, les vérifications de nil peuvent éviter les paniques, mais si elles sont répétées aux mauvais endroits, le code n’explique plus par lui-même « ce qui peut être nil »
  • Vérifier une dépendance indispensable comme un client Redis dans une méthode interne revient à traiter un échec de création comme un chemin d’exécution normal
  • Filtrer nil dans le constructeur ne suffit pas : il faut traiter immédiatement l’échec au point d’initialisation, par exemple NewRedisClient(addr)
  • Les valeurs provenant de l’extérieur, comme les objets de requête, doivent être validées à la couche de frontière — handler HTTP, dispatch RPC, consommateur de file — et la logique interne doit pouvoir faire confiance à cette garantie
  • Si l’on autorise silencieusement des états qui devraient être impossibles, les échecs deviennent silencieux, différés et ambigus, ce qui entraîne ensuite un coût pour recréer les signaux disparus avec des métriques, tableaux de bord et alertes

Une vérification de nil n’est pas toujours de la programmation défensive

  • Pour éviter les paniques en production, il faut de la programmation défensive qui vérifie les entrées, les bornes et les pointeurs avant de recourir à deferred recover
  • Une vérification de nil placée au bon endroit rend le code plus sûr, mais placée au mauvais endroit, elle signale que l’on ne sait plus suivre quelles valeurs peuvent être nil
  • Ce motif apparaît plus souvent dans le code généré, mais il n’est ni nouveau ni limité à l’IA
  • Les vérifications de nil semblent peu coûteuses et sûres, mais elles laissent au lecteur suivant le message « cette valeur peut être nil » et transmettent souvent une signification erronée

Le problème des vérifications de nil sur les dépendances

  • Un code où RateLimiter possède un champ *redis.Client et vérifie r.redis != nil dans Allow paraît sûr au premier abord
  • Si le client Redis est nil, le problème ne survient pas au moment de l’exécution de Allow, mais existe déjà au moment de la création
  • Vérifier nil dans une méthode interne revient à traiter la poursuite de l’exécution après un échec de création comme un état acceptable
  • Ce type de vérification indique que le code a perdu la trace de l’origine de l’objet, de la responsabilité d’initialisation et de l’invariant selon lequel nil devrait être impossible

La vérification de nil dans le constructeur ne suffit pas

  • Faire retourner une erreur par NewRateLimiter(client *redis.Client) lorsque client == nil est mieux, mais ce n’est pas une solution complète
  • Le simple fait qu’un pointeur nil ait été transmis jusqu’à cette fonction signifie déjà qu’un état incorrect est entré dans le système
  • La vraie erreur doit être traitée au point d’initialisation qui crée le client Redis
    • Si redisClient, err := NewRedisClient(addr) produit une erreur, il faut retourner immédiatement
    • Ensuite, seul un client valide doit être transmis à NewRateLimiter(redisClient)
  • Ainsi, le constructeur de RateLimiter n’a même plus besoin de retourner une erreur
  • Si le stockage doit pouvoir être temporairement indisponible, il ne faut pas propager nil : il faut l’envelopper dans un type externe toujours non nil, qui encapsule en interne les reprises ou le mode dégradé
  • C’est similaire aux contraintes NOT NULL ou aux clés étrangères dans une base de données
    • Si une ligne invalide ne peut pas exister dès le départ, chaque requête n’a pas à revérifier les données
    • Pour les valeurs d’exécution aussi, une fois l’invariant établi, le reste du code peut éviter les vérifications répétées

Le coût des échecs silencieux

  • Éviter d’arrêter un programme pour un petit changement en se contentant d’une vérification de nil ou d’un log peut donner une impression de stabilité
  • En réalité, le choix ressemble davantage à échouer bruyamment ou échouer silencieusement qu’à « crasher ou continuer »
  • Une erreur explicitement retournée possède trois propriétés
    • Clarté : on sait qu’un échec s’est produit
    • Immédiateté : l’échec est signalé près de sa cause
    • Attribution : l’appelant peut relier l’échec à l’opération concernée
  • Une erreur avalée fonctionne à l’inverse
    • L’échec disparaît silencieusement
    • Davantage de code s’exécute, puis l’échec se manifeste plus tard sous forme de symptôme
    • Lorsque le symptôme apparaît, il devient difficile d’en identifier la cause
  • Plus il y a d’appels qui survivent dans un état incorrect, plus l’écart entre la cause et le symptôme s’agrandit
  • La bonne correction ne consiste pas à cacher localement l’échec, mais à comprendre où l’erreur se propage et où elle devient un rejet de requête, un échec de tâche, une reprise, une alerte ou un arrêt
  • Si le retour d’erreur interrompt plus que nécessaire dans le système, le problème n’est pas la fonction concernée, mais la frontière de gestion des erreurs

Le coût secondaire de recréer les signaux disparus

  • Quand les échecs deviennent silencieux, il devient impossible de savoir ce qui s’est réellement passé, et les bugs peuvent se cacher
  • Il faut alors créer une infrastructure d’observabilité — métriques, tableaux de bord, alertes — pour détecter l’absence de comportement
  • Chaque fois que l’on autorise un état impossible ou non traité, on paie plus tard un coût d’ingénierie pour restaurer par l’observation le signal que l’on a abandonné

Le rôle des couches externes et internes

  • L’endroit où l’exécution commence et où les données externes entrent est la couche externe ; le code plus profond atteint par cet appel est la couche interne
  • Au début de l’exécution, rien n’est garanti, mais aucune opération n’a encore été effectuée
  • Pendant l’initialisation, il faut configurer les éléments dont dépend le programme et décider lesquels sont indispensables ou peuvent disparaître temporairement
  • La conception doit toujours privilégier les dépendances disponibles en permanence et minimiser celles qui peuvent disparaître en cours de route

Les données liées à une requête doivent être validées à la frontière

  • Les objets de requête, les champs de requête et les valeurs dérivées d’une requête diffèrent des dépendances fixes
  • Une requête arrive à chaque appel depuis l’extérieur : handler HTTP, RPC, file, helper de test, autre package, etc.
  • Vérifier req == nil dans RateLimiter.Allow(ctx, req) est la même erreur que de vérifier nil sur une dépendance
  • La requête n’est pas apparue pour la première fois dans Allow : elle est entrée plus en amont, à la frontière de transport, puis a circulé dans le code
  • Si une fonction interne comme Allow la valide à nouveau, une fonction profonde revérifie ce que la couche externe devrait garantir, et l’incertitude se propage

Après la validation à la frontière, la logique interne fait confiance aux invariants

  • Les vérifications de nil doivent se trouver au point de frontière où des octets non fiables deviennent un type interne comme *Request
  • Dans l’exemple d’un handler HTTP, si DecodeRequest(r) échoue, il répond avec http.StatusBadRequest puis retourne
  • Après la validation, req est une valeur valide, et l’appel suivant h.limiter.Allow(r.Context(), req) peut lui faire confiance
  • Comme les données reçues de l’extérieur ne sont pas maîtrisées, il est logique de vérifier nil et les contraintes nécessaires à la frontière
  • Les données qui franchissent la frontière sont mappées vers des types internes et de la logique métier, puis deviennent ensuite des invariants du système
  • Au final, Allow se concentre sur la vraie logique sans vérification de nil
    • userID := GetUserID(req)
    • Si userID == "", retourner false, nil
    • Sinon, appeler r.checkLimit(ctx, userID)
  • La vérification d’un userID vide pourrait aussi être déplacée vers la couche HTTP, mais dans cet exemple, le limiteur de débit reste propriétaire de cette politique

Les vérifications répétées de nil créent de nouvelles branches et de nouveaux comportements

  • Un système structuré ainsi est facile à raisonner et à modifier
  • À l’inverse, un système sans invariants ajoute des vérifications partout, puis doit décider quoi faire à chacune d’elles
  • Chaque vérification de nil est une nouvelle branche, et chaque branche force à définir un nouveau comportement pour un état qui ne devrait pas exister
  • Les vérifications de nil sont utiles lorsqu’elles imposent des frontières documentées ou modélisent un état optionnel intentionnel
  • Il faut se méfier des vérifications de nil qui traitent silencieusement un état que le programme considère comme impossible
  • Si les vérifications de nil apparaissent partout, c’est l’un des deux cas suivants
    • Du code normal qui protège des entrées non fiables à la frontière
    • Un problème de conception où la base de code n’a pas su établir ses invariants
  • Dans un système où aucun paramètre n’est fiable, il peut être nécessaire d’ajouter des vérifications immédiatement, mais le vrai travail consiste à établir l’invariant que ces vérifications remplacent et à le transformer en garantie fiable

1 commentaires

 
GN⁺ 5 시간 전
Commentaires sur Lobste.rs
  • Je le redemande aux autres programmeurs Go : wrappez les erreurs, s’il vous plaît

    redisClient, err := NewRedisClient(addr)  
    if err != nil {  
      return nil, fmt.Errorf("Couldn't obtain new RedisClient: %w", err)  
    }  
    

    À mesure que la pile d’appels se déroule, le contexte autour de l’erreur devrait s’accumuler

    • Un exemple plus idiomatique ressemblerait à ceci
      redisClient, err := NewRedisClient(addr)  
      if err != nil {  
        return nil, fmt.Errorf("NewRedisClient: %w", err)  
      }  
      
      Ensuite, chaque couche ajoute seulement l’erreur s’est produite, tandis que le err le plus interne indique ce qui s’est passé
    • Malheureusement, il n’existe pas de stack trace unifiée et de fait standard pour les erreurs
      En pratique, le « wrapping » revient souvent à faire un grep sur la chaîne d’erreur, à espérer que cette chaîne soit unique, puis à se montrer artificiellement créatif pour la rendre unique
    • Certains se plaignent que les piles d’erreurs sont trop longues, mais la plupart considèrent que ce type de message est exploitable et utile
      Il y a quelque temps, sur un produit réseau, un ingénieur a passé un mois à corriger des centaines de messages d’erreur, parce qu’avoir « What the f-ck? » dans les logs n’aidait pas l’utilisateur final
      Il fallait rendre ces messages utiles et, pour les raisons ci-dessus, ajouter aussi des piles d’erreurs
    • La méthode actuelle, de mémoire, consiste plutôt à utiliser errors.Join
  • Je pense que Go crée ici deux problèmes

    1. Si Go avait une nullabilité explicite, ce problème disparaîtrait presque entièrement
    2. Il ne semble pas y avoir de moyen d’empêcher l’initialisation à zéro des types nommables, donc les erreurs peuvent toujours se glisser quelque part
    • Cette phrase de l’article me semble bien mettre en évidence le problème fondamental
      Il s’agit du passage disant : « comme on ne peut pas contrôler ce qu’on reçoit, il est raisonnable de vérifier nil à cette frontière »
      C’est vrai pour les entrées externes, mais si tous les pointeurs peuvent être nil, suivre les frontières sûres dans une base de code demande du raisonnement
      Le problème de Go est qu’il force ce raisonnement à se faire dans la tête de chaque programmeur, plutôt que dans le compilateur
  • Rust a Option<T> et C# a des types nullables
    Je pense qu’en 2026, on ne devrait plus avoir à subir ce genre de problème

    • Pour défendre l’autre point de vue, la capacité à exprimer « absence » ou « manquant » de façon concise est très utile, surtout lorsqu’on manipule des structures de données arbitraires comme du JSON
      La syntaxe est généralement l’un des aspects les moins intéressants d’un langage, mais écrire foo.bar.baz dans son langage de script préféré est beaucoup plus simple que foo.unwrap().bar.unwrap().baz en Rust
      Je dis cela tout en aimant Rust, et même si Go et Rust sont souvent rangés dans la même catégorie, je vois Go bien davantage comme un langage de script réinventé par des programmeurs C
      Cela dit, si un langage utilise null, le mieux est que la valeur par défaut soit non nullable. Surtout avec une syntaxe courte comme ? ou .?, la charge syntaxique vaut la peine dans les gros projets
    • Si on n’utilise pas de pointeurs, il n’y a pas de null, hourra… 😭
  • Je comprends que Go n’est pas un langage qui modélise bien les objets non nullables
    Sur ce point, il ressemble à C, et Option<T> peut être représenté par T*, mais T* ne signifie pas nécessairement Option<T>
    Dans l’ensemble, je suis d’accord avec l’article. Quand je travaillais dans une entreprise de firmware embarqué, j’avais aussi essayé de convaincre les gens de ne pas parsemer le code C++ de vérifications de null, mais d’utiliser des assert
    Les assert sont plus faciles à déboguer, ne comptent pas comme des branches du point de vue de la couverture, et communiquent clairement les préconditions au lecteur. Comme elles sont exclues des builds de release, elles sont aussi plus efficaces
    Cela dit, en Go, un déréférencement de nil fournit déjà de bonnes informations de débogage, donc je comprends que l’intérêt des assert n’y soit pas aussi grand qu’en C++

    • Le déréférencement de nil en Go est meilleur que le déréférencement d’un pointeur null en C, car il déclenche un panic de façon déterministe, mais ce n’est pas si formidable, puisque l’erreur ne se produit qu’au moment où le pointeur est réellement déréférencé
      Dans l’exemple de l’article, cela exploserait au fin fond de checkLimit, et il faudrait alors remonter à l’origine du nil. Selon le système ou l’architecture, cela peut être assez complexe
      Donc faire un assert directement dans NewRateLimiter apporte clairement un bénéfice. Dans l’exemple de code, cela revient à remplacer
      if client == nil {  
          return nil, errors.New("redis client is nil")  
      }  
      
      par
      if client == nil {  
          panic("redis client is nil")  
      }  
      
      Cela dit, l’équipe Go est fortement opposée aux assertions, et panic n’est pas idéal non plus : s’il n’est pas récupéré, il fait planter tout le runtime
    • Les vérifications de null et les assert sont, à mon sens, complètement différentes
      Une assert signifie « cet état n’est pas valide », et une macro assert peut transformer cette vérification de null en non-opération dans les builds de release
      Selon la façon dont la macro assert est définie, des optimisations liées au comportement indéfini peuvent se produire, supprimant les vérifications suivantes et menant à des crashs déroutants
      Par exemple, j’ai déjà vu une définition d’assert qui faisait supprimer la vérification suivante dans assert(p); if (!p) { ... }
      Dire aveuglément « ne vérifiez pas null, utilisez assert » peut convenir pour des invariants d’état, mais pas pour vérifier des erreurs
  • Il y a un bon conseil dans la conclusion
    Si des vérifications nil apparaissent partout, c’est l’un des deux cas suivants : soit c’est du code normal qui se protège contre des entrées de frontière non fiables, soit c’est un problème de conception où la base de code n’a pas su établir d’invariant.
    Dans un système où aucun paramètre n’est fiable, la solution n’est pas d’ajouter encore plus de vérifications. À court terme, il faut parfois le faire, mais le vrai travail consiste à établir les invariants que ces vérifications remplacent, puis à transformer progressivement le bruit né de la peur en garanties sur lesquelles le système peut s’appuyer.
    À mon avis, cela dépasse les simples vérifications nil. Ajouter des vérifications ou du code défensif dans les parties « feuilles » d’un système revient souvent à traiter le symptôme d’un manque d’invariants, ou d’invariants mal imposés.
    « Ajouter une vérification de plus » est facile à prendre comme comportement par défaut, mais cela ne passe pas à l’échelle. À un moment, la logique de vérification devient plus volumineuse que la logique fonctionnelle, et la complexité globale explose.
    Une vérification supplémentaire pour éviter un ou deux bugs n’est généralement pas nuisible, mais quand on a l’impression que le nombre et la complexité des vérifications augmentent trop, il vaut mieux, à long terme, prendre du recul et chercher la cause racine plutôt que de continuer à corriger uniquement les feuilles — c’est meilleur pour le système comme pour la vie des mainteneurs.

    • Asserter les invariants, c’est excellent quand on commence ainsi dès le départ et qu’on le maintient dans la durée.
      Mais le problème plus difficile est de former les développeurs à arrêter la programmation défensive.
  • De tels invariants, ici par exemple la non-nullabilité, se modélisent bien mieux dans des systèmes de types plus expressifs que celui de Go.
    Mon article préféré sur ce sujet est celui d’Alexis King, publié en 2019 : Parse, don't validate.
    Le principe est applicable partout, mais avec le système de types de Haskell, cela semble vraiment facile. J’ai essayé pendant des années de suivre les conseils d’Alexis en TypeScript, mais ce n’était pas simple.

  • En résumé, le problème n’est pas qu’il y ait trop de vérifications, mais le fait d’emballer nil comme une valeur.

  • Ce problème revient régulièrement, et j’y vois la conséquence des langages où la gestion des erreurs n’est pas une fonctionnalité de première classe.
    Comme cela a déjà été dit, si je me souviens bien, dans d’autres fils, les linters devenus de fait standards finissent par imposer cette structure.
    Je ne sais pas si ces vérifications nil sont logiquement mauvaises. Beaucoup de langages intègrent la gestion des erreurs, et la différence tient surtout à la cohérence et à la simplicité de la propagation.
    Face à une interface qui peut produire une erreur, les options sont grosso modo au nombre de quatre : traiter et récupérer, ignorer, propager l’erreur, ou jeter l’erreur et propager sa propre erreur, la dernière option pouvant aussi envelopper l’erreur existante.
    Les langages où la gestion des erreurs est de première classe rendent généralement les options 2 et 3 faciles, d’autant plus dans les langages modernes. Selon le langage, l’option 4 peut donc elle aussi devenir assez propre.
    Pour l’option 1, même un support de première classe ne peut pas aider beaucoup, sinon en rendant plus explicite le fait qu’un tel traitement est nécessaire.
    Fondamentalement, si une fonction peut produire une erreur, tous les langages, quelle que soit leur implémentation, reviennent à faire {error,result} = functioncall() puis if (error) { ... }.
    Comme la gestion des erreurs n’est pas de première classe en Go, beaucoup de fonctions renvoient préventivement un tuple (result, err), et comme les linters imposent de fait la vérification err != nil, le code donne l’impression d’être rempli de ce motif.
    Je considère que le fait qu’un langage ne prenne pas directement en charge une gestion correcte des erreurs est un défaut de conception, mais une fois qu’on se trouve dans cette situation, ce modèle est probablement proche de ce qu’on peut faire de mieux.
    Je ne sais pas si le code Go utilise idiomatiquement des types de retour optionnels pour distinguer les erreurs fonctionnellement ignorables de celles dont il « faut se soucier ». Si, même dans ces cas-là, l’idiome consiste toujours à renvoyer un type d’erreur, alors les linters imposeront toujours ce motif.
    Je ne déteste pas Go ; je suis simplement en désaccord avec un de ses choix de conception. On peut se plaindre des choix de conception de presque tous les langages.
    À mon avis, la plus grosse erreur de Go est que les vérifications explicites err != nil sont fonctionnellement nécessaires pratiquement partout, et que les linters finissent donc par les exiger aussi.

  • Quand Go est apparu, des centaines de personnes avaient déjà souligné à quel point toute cette structure était ridicule.
    Mais le langage est devenu très populaire, et les critiques ont été balayées dans une ambiance où Rob Pike était censé savoir mieux que tout le monde.
    C’est agréable de voir enfin des gens en discuter normalement, avec des arguments logiques.
    Ce n’est pas comme si l’on ne savait pas depuis des décennies que c’était une mauvaise idée, mais si Google le fait, c’est forcément bien… non ?

    • Je ne suis pas fan de Go, mais ce cadrage me dérange.
      Parce qu’en qualifiant cela de « sottises ridicules », on risque justement d’étouffer la pensée logique que l’on dit vouloir voir davantage.
      J’ai oublié dans quel podcast d’Oxide c’était, mais Bryan Cantrill a dit quelque chose comme : « je veux étudier ceci pour mieux le détester ».
      Dans cet esprit, j’aimerais comprendre pourquoi les gens se sont autant enthousiasmés pour Go dans les années 2010. Il y avait certainement une part de hype, et j’ai vu moi-même à l’époque, au travail, des développeurs s’emballer pour Go sans vraiment parvenir à expliquer pourquoi c’était bien.
      Mais cela ne pouvait pas être uniquement de la hype. Je me demande quel était, à cette époque, le meilleur steel-man argument en faveur de l’adoption de Go.