- Go 1.22 modifie les variables de boucle
for pour qu’elles aient une portée par itération plutôt qu’une portée sur l’ensemble de la boucle, afin de réduire l’une des erreurs les plus classiques en Go, où une closure capture par erreur la même variable
- Avec l’ancienne sémantique, même sans goroutine, une fonction exécutée après les itérations peut référencer le même
v ou i, ne voir que la dernière valeur, ou faire en sorte qu’un test réussisse à tort
- Les analyseurs
loopclosure de go vet et gopls ne détectent que les cas certains, ce qui entraîne des faux négatifs, tandis que des vérifications plus agressives peuvent multiplier inutilement le code x := x à cause des faux positifs
- La nouvelle sémantique ne s’applique qu’aux modules déclarant
go 1.22 ou plus dans go.mod, et dans Go 1.21 il est possible de l’essayer en avant-première avec GOEXPERIMENT=loopvar
- Google a imposé ce mode sur toutes les builds de son toolchain Go interne à partir de début mai 2023 et n’a reçu aucun signalement de problème en production pendant quatre mois, mais des tests mal écrits ont été mis en évidence
Piège de capture de variables dans les anciennes boucles for
- Dans l’ancienne sémantique de Go, les variables de boucle
for ont une portée sur l’ensemble de la boucle, si bien que du code qui y fait référence après la fin des itérations peut voir une valeur différente de celle attendue
- Si l’on parcourt
values := []string{"a", "b", "c"} en créant trois goroutines, chacune affichera non pas le v propre à son itération, mais la même variable v
- Le même problème peut survenir même sans concurrence
- Si l’on stocke
func() { fmt.Println(i) } dans un slice à l’intérieur de la boucle pour l’exécuter plus tard, chaque fonction référencera le même i au lieu de la valeur propre à l’itération
Incidents en production et limites des analyseurs
- Ce type d’erreur a provoqué des problèmes en production dans plusieurs entreprises, et l’incident public de Let’s Encrypt en est un exemple
- Dans le cas de Let’s Encrypt, lors d’un parcours de map,
k avait bien été copié avec kCopy := k, mais comme modelToAuthzPB(&v) utilisait des pointeurs vers des champs de v pendant la construction du résultat, il fallait aussi copier v séparément
- La capture de variable s’étendait sur plusieurs fonctions, ce qui rendait le problème difficile à repérer
- Les outils d’analyse statique ont du mal à déterminer si une variable survit au-delà de l’itération, et doivent donc arbitrer entre faux positifs et faux négatifs
- Les analyseurs
loopclosure de go vet et gopls ne signalent que les problèmes certains, au prix de faux négatifs
- Des vérifications plus agressives peuvent signaler à tort du code correct comme étant erroné
- En examinant des commits de code Go open source ajoutant des lignes
x := x, on constate qu’ils mêlent de vraies corrections de bugs à de nombreuses modifications inutiles
- Certains développeurs ajoutaient du code superflu uniquement pour satisfaire l’analyseur
- Parmi deux diffs comme
informer := informer et a := a, l’un pouvait corriger un bug et l’autre être inutile, mais il est difficile de les distinguer sans informations de type et de fonction
Nouvelle sémantique des boucles dans Go 1.22
- Dans Go 1.22, les variables des boucles
for auront une portée distincte à chaque itération
- Les exemples précédents ne seront alors plus des programmes Go bogués, ce qui réduira à la fois les incidents en production dus à cette erreur et le besoin d’outils d’analyse imprécis
- Pour préserver la compatibilité descendante, la nouvelle sémantique ne s’applique qu’aux packages de modules déclarant
go 1.22 ou plus dans go.mod
- Il est ainsi possible de migrer progressivement sans modifier toute une base de code d’un seul coup
- Un contrôle au niveau fichier est aussi possible via les lignes
//go:build
- Le code existant conserve exactement la sémantique actuelle
- La modification ne concerne que le nouveau code ou le code mis à jour
- Les développeurs gardent la main sur le moment où la sémantique change dans un package donné
Garde-fous dans les versions antérieures de Go
- Grâce au travail de compatibilité ascendante de Go, Go 1.21 ne compile pas le code déclarant
go 1.22 ou plus
- Les versions correctives Go 1.20.8 et Go 1.19.13 incluent aussi un traitement spécial produisant le même effet
- Une fois Go 1.22 publié, le code écrit en s’appuyant sur la nouvelle sémantique ne pourra pas être compilé avec l’ancienne sémantique, sauf en utilisant une très ancienne version de Go hors support
Essayer l’aperçu dans Go 1.21
- Go 1.21 inclut un aperçu du changement de portée des boucles
- En compilant avec
GOEXPERIMENT=loopvar, la ligne go de go.mod est ignorée et la nouvelle sémantique s’applique à toutes les boucles
- Pour vérifier qu’un package et toutes ses dépendances passent bien les tests avec la nouvelle sémantique de boucle, on peut lancer :
GOEXPERIMENT=loopvar go test
- Dans le Go Playground, il est possible de tester la nouvelle sémantique en ajoutant le commentaire
// GOEXPERIMENT=loopvar en haut du programme
- Programme d’exemple : Exemple Go Playground
- Ce commentaire n’est pris en compte que dans le Go Playground
- Le toolchain Go interne de Google a été modifié pour imposer ce mode sur toutes les builds dès début mai 2023, et aucun problème de code de production n’a été signalé pendant les quatre mois qui ont suivi
Bugs de tests révélés par la nouvelle sémantique
- La nouvelle sémantique des boucles n’a pas causé de problème dans le code de production, mais elle a mis en lumière des tests qui réussissaient à tort
- Dans l’exemple de sous-tests utilisant
t.Parallel, Go 1.21 bloque chaque sous-test jusqu’à la fin de toute la boucle, puis les exécute en parallèle
- Une fois la boucle terminée,
v vaut toujours 6, donc tous les sous-tests vérifient que 6 est pair et réussissent
- Pourtant, les cas de test incluent aussi
1, le test devrait donc échouer
- Dans Go 1.21, la précision de l’analyseur
loopclosure a été améliorée afin d’identifier et de signaler ce problème
- Exemple de rapport dans Go Playground : Exemple de programme
- Si
go vet signale ce type de problème dans des tests, le corriger aide à préparer le passage à Go 1.22
- Des outils et exemples pour localiser les boucles à l’origine d’échecs de tests avec la nouvelle sémantique sont rassemblés dans la FAQ
Pour aller plus loin
1 commentaires
Avis sur Hacker News
Il existe sans doute des exemples bien plus anciens, mais le plus ancien avertissement sur ce comportement que j’ai trouvé en 60 secondes de recherche date de 1992, il y a plus de 30 ans, dans la FAQ comp.lang.lisp.
Elle expliquait que
DOTIMES,DOLISTetDOutilisent une affectation, et non un binding, lorsqu’ils mettent à jour la variable d’itération ; donc, si unlambdacapturencomme dans l’exemple, les 10 closures sont toutes créées au-dessus de la valeur de la même variableN.Si l’on capture par référence, c’est en fait le comportement attendu.
Cela dit, une fois qu’on a appris comment ça fonctionne, ce n’est plus un problème, et si nécessaire on peut choisir la forme et regarder l’expansion de macro pour vérifier l’implémentation.
L’équipe du langage C# a rencontré le même problème après avoir introduit des closures légères dans C# 4.0, et il est vite apparu que c’était un piège.
Les utilisateurs se trompaient presque toujours dans l’utilisation de la variable de boucle, et un changement cassant la compatibilité a été introduit dans C# 5.0.
Eric Lippert a écrit un billet qui explique bien le « pourquoi » de ce point de vue : https://ericlippert.com/2009/11/12/closing-over-the-loop-var...
Le billet d’annonce original de C# 5 était difficile à retrouver ; espérons qu’il n’a pas disparu lors des multiples migrations de blogs sur les domaines Microsoft après 2012.
Quand on pense au bazar provoqué par le seul changement de type des chaînes lors du passage de Python 2 à 3, je doute que ce changement arrive avant Python 4.0.
Et quelqu’un dira probablement que Python est nul parce qu’il ne corrige pas ce genre de choses, puis critiquera encore Python parce qu’un script écrit en 2003 ne fonctionne plus.
À mon avis, cela a beaucoup compté pour franchir la barrière du « rejet par défaut » qu’une proposition de changement de langage doit normalement surmonter.
Un autre élément très convaincant a été le scan de bases de code open source, pour comparer l’équilibre entre les bugs corrigés et les nouveaux bugs introduits.
Comme le passage se fait par valeur, cela capture l’état de la variable au moment de l’appel et réduit l’ambiguïté du code.
Si l’on essaie de capturer des variables de façon étrange, par exemple une collection accumulée pour transformer un tableau en map et des variables déclarées peuvent se comporter différemment.
Go semble vouloir trouver un équilibre en appliquant ce comportement seulement aux compteurs de boucle, mais certaines variables continuent malgré tout à se comporter bizarrement.
Je suis notamment curieux de voir ce qui se passe lorsqu’on définit plusieurs variables de boucle pour scanner directement une entrée.
for(let).https://eli.thegreenplace.net/2019/go-internals-capturing-lo... semble expliquer ce problème plus en détail.
i := ifonctionne pour une raison totalement différente de ce que je pensais.Au départ, je pensais que le nouveau
iétait passé à la goroutine, que l’analyse d’échappement le marquait donc comme s’échappant de sa portée lexicale, qu’il était alors alloué sur le tas, et qu’une allocation sur le tas était créée à chaque itération, donnant à chaque goroutine son propre emplacement mémoire.En réalité, le compilateur Go dispose d’heuristiques pour choisir entre capture par référence et capture par valeur, et l’une des conditions pour capturer par valeur est que la valeur ne soit pas modifiée après son initialisation.
Le nouveau
ise trouve dans la portée du corps dufor, et comme la boucle elle-même ne le met pas à jour, il est considéré comme une valeur non modifiée après initialisation ; le compilateur génère donc du code qui le capture par valeur, sans allocation sur le tas.Je comprends que cette seconde approche est meilleure, mais j’aimerais qu’une personne connaissant Go en profondeur m’explique pourquoi la première ne se produit pas en même temps.
Ce changement ne risque-t-il pas de casser les programmes qui dépendent du comportement actuel ?
go 1.22ou une version ultérieure dansgo.modAu niveau d’un fichier, cela peut aussi être déterminé avec une ligne
//go:buildCette promesse dit qu’un programme écrit selon la spécification Go 1 doit continuer à compiler et à s’exécuter correctement sans modification pendant toute la durée de vie de la spécification ; une spécification Go 2 pourra peut-être arriver un jour, mais d’ici là, même dans des versions mineures comme Go 1.1 ou Go 1.2, les programmes Go qui fonctionnent aujourd’hui doivent continuer à fonctionner
Ils ont estimé que cette conception avait probablement causé beaucoup plus de bugs involontaires qu’il n’y aurait de personnes affectées par la correction
De mémoire, elle indiquait que, dans la base de code de Google ou dans le code sur GitHub, ce changement ne cassait presque jamais le comportement attendu
Ce n’est qu’après avoir vérifié à quel point les bases de code affectées étaient rares, puis mis en place un mécanisme obligeant à modifier activement le code via la version indiquée dans
go.modpour obtenir le nouveau comportement, qu’ils ont décidé de rompre la rétrocompatibilitéhttps://twitter.com/go100and1/status/1690412229135601664
https://twitter.com/go100and1/status/1690587305806057472
https://twitter.com/go100and1/status/1690589791686119424
https://twitter.com/go100and1/status/1690591234715492352
https://twitter.com/go100and1/status/1690593184857145344
https://twitter.com/go100and1/status/1691456732151889920
La plupart n’étaient pas du tout mentionnés dans le document de proposition
J’ai déjà rencontré ce problème en Python, mais pas récemment
Je ne sais pas si Python a changé, ou si c’est moi qui ai appris à repérer le problème
Le simple code suivant suffit à montrer que cela peut toujours poser problème en Python :
funcs = [(lambda: x) for x in range(3)];funcs[0]()affiche2Python était pire auparavant, et partageait même la portée en dehors des compréhensions de liste
Quand on utilise une lambda dans une compréhension de liste ou une boucle, elle ne capture pas la valeur courante de
x, mais une référence à la variablexAu moment où
funcs[0]()est appelé,xa déjà été fixé à la dernière valeur derange, c’est-à-dire2Pour obtenir le comportement souhaité, il faut passer
xcomme argument par défaut de la lambda :funcs = [(lambda x=x: x) for x in range(3)]Je n’ai utilisé Go que très peu et je comprends le problème général que ce changement résout, mais je comprends moins bien les exemples plus subtils, comme le cas letsencrypt ou
"range c.informerMap"contre"range alarms"Dans
for k, v := range someMap,vest du type de la valeur de la map, et il y a une seule liaison pour toute la boucle, recopiée à chaque itération ? Si c’est le cas, cela explique le problème, mais je m’attendais à ce quevsoit une référence vers l’intérieur de la mapEn parcourant rapidement la section « For statements with range clause » de la spécification, je n’ai pas trouvé la réponse, mais comme je touche très peu à Go, j’ai sans doute regardé au mauvais endroit : https://go.dev/ref/spec#For_statements
Édit : la réponse était dans le tableau sous forme de bloc de code. J’ai dû le survoler comme une bannière. Je suis surpris que
vsoit une valeur copiée, et non une référenceIl prend en charge les pointeurs vers les cases d’un tableau, mais
for rangecopie chaque case au lieu de fournir un pointeur vers ellevestintC’est une valeur, pas un pointeur vers un
intVous pouvez la consulter si cela vous intéresse : https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
En gros, à cause du déréférencement automatique, le compilateur transforme
go a.Monitor(b)en(&a).Monitor(b)Je me demande comment fonctionne le passage disant : « À la suite du travail sur la compatibilité ascendante, Go 1.21 ne tente pas de compiler du code déclarant
go 1.22ou plus. Nous avons aussi ajouté un traitement spécial ayant le même effet dans les releases ponctuelles Go 1.20.8 et Go 1.19.13 ; ainsi, quand Go 1.22 sortira, le code écrit en s’appuyant sur la nouvelle sémantique ne sera jamais compilé avec l’ancienne sémantique, sauf si l’on utilise une très vieille version de Go non prise en charge. »Si un package est fixé à 1.22 et que je compile avec 1.18, est-ce que ça compile, ou est-ce que j’obtiens une erreur disant qu’il faut le compilateur 1.22 ?
Comme Go 1.21 a changé le format du numéro de version dans le fichier
go.mod, si l’on essaie de builder avec Go 1.18, on obtient une erreur du genrego.mod:3: invalid go version '1.21.0': must match format 1.23.Cela dit, ce n’est le cas que lorsqu’on crée le module avec
go mod init; si l’on écrit manuellementgo 1.21dansgo.mod, ça build sans protester.C’est une fonctionnalité assez sympa, mais c’est un comportement surprenant, et le fait de se connecter à un serveur contrôlé par Google pour télécharger un binaire me fait un peu hésiter.
Avec le proxy de modules, c’est l’une des fonctionnalités de Go envers lesquelles je suis le plus partagé ; je serais beaucoup plus à l’aise si Go était géré par une fondation dans laquelle Google n’aurait qu’une participation.
Édit : à la réflexion, cela concerne le cas où c’est le module courant qui déclare cette version, pas une dépendance qui déclare une autre version, donc ce n’est pas exactement la question d’origine.
L’utilisation de Go 1.18 devient donc activement dangereuse.
Avec Go 1.19, il devrait y avoir une erreur de compilation.
De toute façon, Go ne corrige pas les failles de sécurité dans les anciennes releases ni dans leur bibliothèque standard, donc j’estime que l’utilisation de ces versions est en soi risquée.
Mais même si tu compiles avec Go 1.22, ton code conserve la sémantique de Go 1.18.
Go est, à certains égards, un langage vraiment étrange.
C’est un langage très opinionated, tout en donnant l’impression d’être un langage qui n’a presque pas d’opinions.
Je ne suis pas sûr de la différence entre le code qui parcourt
c.informerMapet celui qui parcourtalarms, mais à vue de nez, la variable de boucle de l’un pourrait être un pointeur et celle de l’autre une valeur.Comme l’appel de méthode utilise un receiver pointeur, dans le cas d’une valeur, le compilateur n’ajouterait-il pas automatiquement une référence au receiver ?
https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
La différence est que, dans le premier cas,
informerest une interface, donc l’appel de méthode est immédiatement résolu eninformer.Runet il n’y a pas de problème.Dans l’autre cas,
aest une structAlarmet elle est copiée par valeur, tandis que la méthodeMonitorprend un receiver pointeur.Le compilateur transforme donc en pratique
go a.Monitor(b)engo (&a).Monitor(b), ce qui crée une référence à la variable de boucle et provoque le problème.Pour le second, je suppose que
afinit par ne contenir que la valeur du dernier élément dealarms, ce qui déclenche le problème décrit dans l’article.Mes connaissances internes s’arrêtent à peu près là, mais les slices ont un tableau sous-jacent sur le tas, donc des pointeurs ou références y sont impliqués dans une certaine mesure.
Lire ça me soulage énormément.
Cela corrige l’un des plus gros défauts de Go.
Si l’on écrit
foo, err := getFoo(); if err != nil ...puisbar, err := getBar(); fmt.Println(bar), on peut rater la vérification de l’erreur degetBar.À cause des règles de portée, le pattern
if foo, err := getFoo(); err != nildevient ingérable dès que l’imbrication s’approfondit un tant soit peu.Il introduit aussi des états invalides. Que doit retourner
getFooquand il renvoie une erreur ? On se retrouve à se demander s’il faut changer l’API pour retourner un pointeur et pouvoir renvoyernil, ou laisser un objet partiellement construit dans un état invalide.