Vérifications excessives des pointeurs nil en Go
(konradreiche.com)- 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ù
RateLimiterpossède un champ*redis.Clientet vérifier.redis != nildansAllowparaî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)lorsqueclient == nilest 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)
- Si
- Ainsi, le constructeur de
RateLimitern’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 NULLou 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 == nildansRateLimiter.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
Allowla 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 avechttp.StatusBadRequestpuis retourne - Après la validation,
reqest une valeur valide, et l’appel suivanth.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,
Allowse concentre sur la vraie logique sans vérification de niluserID := GetUserID(req)- Si
userID == "", retournerfalse, nil - Sinon, appeler
r.checkLimit(ctx, userID)
- La vérification d’un
userIDvide 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
Commentaires sur Lobste.rs
Je le redemande aux autres programmeurs Go : wrappez les erreurs, s’il vous plaît
À mesure que la pile d’appels se déroule, le contexte autour de l’erreur devrait s’accumuler
errle plus interne indique ce qui s’est passéEn pratique, le « wrapping » revient souvent à faire un
grepsur la chaîne d’erreur, à espérer que cette chaîne soit unique, puis à se montrer artificiellement créatif pour la rendre uniqueIl 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
Je pense que Go crée ici deux problèmes
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 raisonnementLe 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 nullablesJe pense qu’en 2026, on ne devrait plus avoir à subir ce genre de problème
La syntaxe est généralement l’un des aspects les moins intéressants d’un langage, mais écrire
foo.bar.bazdans son langage de script préféré est beaucoup plus simple quefoo.unwrap().bar.unwrap().bazen RustJe 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 projetsJe 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é parT*, maisT*ne signifie pas nécessairementOption<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++
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 complexeDonc faire un assert directement dans
NewRateLimiterapporte clairement un bénéfice. Dans l’exemple de code, cela revient à remplacer par Cela dit, l’équipe Go est fortement opposée aux assertions, etpanicn’est pas idéal non plus : s’il n’est pas récupéré, il fait planter tout le runtimeUne 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
nilapparaissent 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.
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()puisif (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érificationerr != 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 != nilsont 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 ?
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.