- Go 1.21 étend la compatibilité fondée sur GODEBUG afin que la nouvelle toolchain reproduise aussi fidèlement que possible le comportement des anciennes versions de Go, avec l’objectif de réduire le coût des mises à niveau
- Depuis Go 1 en 2012, Go promet la compatibilité source et a limité les ruptures liées aux suppressions ou modifications grâce à la vérification des API publiques et à de vastes tests internes
- Même des améliorations autorisées par la documentation, comme une meilleure précision de
time.Now, des changements d’implémentation de sort, des variations de sortie de compress/flate, l’élargissement des entrées acceptées par strconv.ParseInt ou des changements de parsing dans net.ParseIP, peuvent casser des programmes existants
- À partir de Go 1.21, les réglages GODEBUG liés à la compatibilité sont conservés au moins 2 ans ou 4 releases de Go, et le comportement antérieur peut rester la valeur par défaut selon la version
go du go.mod
- Go 2 ne sortira pas sous la forme d’une nouvelle spécification qui casserait les programmes Go 1, et Go continue de faire passer la compatibilité avant tout pour que les mises à niveau de la toolchain restent fiables même avec l’ajout de nouvelles fonctionnalités
Principes de base de la compatibilité Go 1
- En 2012, avec Go 1, Go a défini dans le document « Go 1 and the Future of Go Programs » l’objectif suivant : les programmes conformes à la spécification Go 1 doivent continuer à se compiler sans modification et à s’exécuter correctement pendant toute la durée de vie de cette spécification
- Le cœur de cette promesse est la compatibilité source
- Lors d’une mise à jour vers une nouvelle version de Go, le code doit être recompilé
- De nouvelles API peuvent être ajoutées, mais il faut éviter les ajouts qui casseraient le code existant
- Il est impossible de garantir qu’aucun changement futur ne cassera absolument tous les programmes
- Si un programme dépend d’un comportement bogué, il peut casser quand ce bug est corrigé
- Go cherche à réduire au maximum les ruptures tout en maintenant des mises à niveau stables
Empêcher les ruptures de compatibilité via la vérification des API publiques
- Dans le développement de Go, la liste des API publiques de chaque package est maintenue dans un fichier séparé du package lui-même
- Par exemple,
go/api/go1.21.txt recense des fonctions, méthodes et types de bytes, cmp, context, etc.
- Les tests standard vérifient que l’API réelle des packages correspond à ces fichiers
- Si une nouvelle API est ajoutée, il faut aussi l’ajouter au fichier d’API pour que les tests passent
- Si une API existante est modifiée ou supprimée, les tests échouent également
- Au-delà de la suppression d’API, un changement de type peut aussi casser la compatibilité
os.Stdout est une variable globale de type *os.File
- Le remplacer par une interface ayant les mêmes méthodes casserait du code comme
greet(f *os.File) qui exige un *os.File
- La vérification des API est utile pour détecter les modifications ou suppressions d’API, mais elle n’empêche pas tous les changements incompatibles possibles dans Go
Les ruptures subtiles révélées par les tests
- Les versions de développement des nouvelles releases de Go sont testées en continu sur l’ensemble du code Go interne à Google
- Si les tests passent, le commit concerné est installé dans la toolchain Go de production de Google
- Si les tests internes cassent, cela signifie qu’il est probable que du code externe casse aussi, et des moyens sont recherchés pour réduire l’impact
- Dans la plupart des cas, le changement est annulé ou réécrit pour éviter de casser les programmes
- Certains changements peuvent rester importants et compatibles au sens de la documentation, même s’ils cassent des programmes
- Même dans ce cas, l’objectif reste de réduire la portée de l’impact et de signaler les problèmes potentiels dans les notes de release
Deux cas révélés avec Go 1.1
-
Littéraux de structure et nouveaux champs
- Dans Go 1,
net.TCPAddr était une structure avec deux champs, IP et Port, et les littéraux composites sans noms de champs compilaient aussi
- Quand le champ
Zone a été ajouté à net.TCPAddr dans Go 1.1, l’ancien code a cessé de compiler avec l’erreur « too few initializers in struct literal »
- La façon compatible d’écrire cela consiste à utiliser des littéraux avec noms de champs
var myAddr = &net.TCPAddr{
IP: net.IPv4(18, 26, 4, 9),
Port: 80,
}
- Si
Zone n’est pas précisé, ce champ prend sa zero value, c’est-à-dire la chaîne vide
- L’exigence d’utiliser des littéraux composites avec noms de champs pour les structures de la bibliothèque standard figure dans le document de compatibilité, et
go vet signale les littéraux sans noms de champs dont la compatibilité avec les versions futures exige des noms
-
Précision temporelle
- Après Go 1,
time.Now a été modifié pour renvoyer une précision à la nanoseconde au lieu de la microseconde
- Ce changement pouvait casser des tests qui attendaient l’identité après un aller-retour d’une valeur
time.Now via save et load
- Si la représentation stockée ne conservait qu’une précision à la microseconde, le test pouvait réussir avec Go 1 mais échouer avec Go 1.1
- Pour aider à corriger ce type de tests, Go a ajouté les méthodes
Round et Truncate, et les notes de release ont récapitulé les problèmes potentiels ainsi que ces nouvelles méthodes
- Comme cette précision améliorée représentait un meilleur comportement et restait dans le cadre documenté de la fonction, le changement a été publié même s’il cassait certains programmes
Trois types de changements pouvant casser la compatibilité
-
Changements de sortie
- Un changement de sortie se produit lorsqu’une fonction produit un résultat différent d’avant, mais que ce nouveau résultat est au moins aussi correct, voire plus correct, que l’ancien
- L’ajout de la précision à la nanoseconde dans
time.Now en est un exemple typique
- Dans Go 1.6, l’implémentation de
sort a été modifiée pour être environ 10 % plus rapide, ce qui a changé l’ordre des éléments considérés comme égaux
- Sortie de Go 1.5 :
[red blue green white black yellow orange indigo violet]
- Sortie de Go 1.6 :
[red blue white green black orange yellow indigo violet]
- Le tri autorise plusieurs ordres pour un même résultat, mais un programme qui attendait un ordre précis peut casser
- Dans Go 1.8,
compress/flate a été amélioré pour produire une sortie plus petite avec un surcoût CPU et mémoire comparable
- À cause de cela, les builds d’archives reproductibles internes à Google ne pouvaient plus reproduire exactement les anciennes archives
- Le projet concerné a donc forké
compress/flate et compress/gzip afin de conserver l’ancien algorithme
- Pour se prémunir contre les changements de sortie, il est préférable d’écrire les programmes et les tests de façon à accepter toutes les sorties valides
- Si une sortie strictement reproductible est indispensable, il est possible de forker le code, mais on se coupe alors aussi des corrections de bugs
-
Changements d’entrée
- Un changement d’entrée survient quand une fonction modifie les entrées qu’elle accepte ou la manière dont elle les traite
- Go 1.13 a ajouté la syntaxe des underscores pour améliorer la lisibilité des nombres, et
strconv.ParseInt a aussi été modifié pour accepter cette nouvelle syntaxe
- Du code chez un utilisateur externe, qui utilisait les nombres séparés par des underscores comme format de données distinct, s’est retrouvé cassé
- Ce code essayait d’abord
ParseInt, puis ne traitait les underscores qu’en cas d’échec, mais ParseInt n’échouait plus
net.ParseIP acceptait les adresses IP décimales avec des zéros initiaux, en suivant les premiers exemples des RFC IP
- Go interprétait
18.032.4.011 comme 18.32.4.11
- Les bibliothèques C de type BSD interprétaient au contraire les zéros initiaux comme le début d’un nombre octal, et lisaient cette même chaîne comme
18.26.4.9
- Dans Go 1.17,
net.ParseIP a été modifié pour rejeter complètement les zéros initiaux
- L’objectif était que lorsque Go et C réussissent tous deux à parser une adresse IP, ils lui donnent la même signification
- Kubernetes craignait que des configurations stockées de longue date ne puissent plus être parsées avec Go 1.17, et a commencé à utiliser un fork du
net.ParseIP d’origine
- Pour les entrées utilisateur, il est préférable de valider d’abord la syntaxe que l’on veut accepter avant de parser la valeur, mais dans certains cas un fork du code peut être nécessaire
-
Changements de protocole
- Un changement de protocole survient lorsqu’une modification d’un package devient visible dans le protocole utilisé pour communiquer avec le monde extérieur
- Go 1.6 a ajouté la prise en charge automatique de HTTP/2
- Un client Go 1.5 n’utilise que HTTP/1.1 et peut donc fonctionner correctement dans certains environnements avec des équipements réseau intermédiaires spécifiques
- Après mise à jour vers Go 1.6, HTTP/2 est utilisé, et si cet environnement ne gère pas correctement HTTP/2, le programme peut casser
- Go cherche à prendre en charge les protocoles modernes par défaut, mais l’activation de HTTP/2 peut casser des programmes sans qu’il y ait de faute du programme ou de Go lui-même
- Les notes de release de Go 1.6 ont décrit ce changement et fourni des moyens de désactiver HTTP/2
- Définir explicitement le champ
TLSNextProto
- Définir
GODEBUG=http2client=0, GODEBUG=http2server=0, ou les deux
- La prise en charge des certificats HTTPS fondés sur SHA1 est un autre exemple, plus subtil, de changement de protocole
- Les autorités de certification ont cessé d’émettre des certificats SHA1 en 2015, et les principaux navigateurs ont cessé de les accepter en 2017
- Go 1.18 a désactivé par défaut la prise en charge des certificats SHA1, tout en permettant un contournement via GODEBUG
- Certaines installations de Kubernetes utilisent encore des certificats SHA1 privés, ce qui a conduit Go à conserver ce réglage de contournement plus longtemps que prévu
Prise en charge étendue de GODEBUG dans Go 1.21
- Go 1.21 étend et formalise l’usage de GODEBUG afin de réduire même les problèmes de compatibilité subtils
- Pour les changements autorisés par les règles de compatibilité de Go 1 mais susceptibles de casser des programmes existants, un réglage GODEBUG est défini afin que chaque programme puisse refuser le nouveau comportement
- Il peut exister des cas où l’ajout d’un réglage est impossible, mais ils sont considérés comme très rares
- Les réglages GODEBUG destinés à la compatibilité sont conservés au moins 2 ans, soit 4 releases de Go
- Des réglages comme
http2client et http2server peuvent rester bien plus longtemps, voire indéfiniment selon les cas
- Quand c’est possible, chaque réglage GODEBUG est associé à un compteur
runtime/metrics
- Le nom du compteur suit le format
/godebug/non-default-behavior/<name>:events
- Par exemple, si
GODEBUG=http2client=0 est défini, /godebug/non-default-behavior/http2client:events compte le nombre de transports HTTP configurés sans HTTP/2
- La valeur par défaut de GODEBUG dans un programme est alignée sur la version de Go indiquée dans le
go.mod du package principal
- Si le
go.mod contient go 1.20 et que l’on met à jour vers la toolchain Go 1.21, les comportements contrôlés par GODEBUG qui changent en Go 1.21 conservent le comportement de Go 1.20 tant que le go.mod n’est pas passé à go 1.21
- Chaque réglage GODEBUG peut être modifié avec une directive
//go:debug dans package main
- Tous les réglages GODEBUG sont répertoriés dans une liste centrale
Le cas de panic(nil)
- Dans Go 1.21,
panic(nil) déclenche désormais une panic d’exécution non nulle
- Ce changement permet au résultat de
recover d’indiquer de façon fiable si la goroutine courante est en train de paniquer
- Le nouveau comportement est contrôlé par un réglage GODEBUG et varie selon la ligne
go du go.mod du package principal
- Avec
go 1.20 ou inférieur, panic(nil) reste autorisé
- Avec
go 1.21 ou supérieur, panic(nil) devient une panic contenant runtime.PanicNilError
- Cette valeur par défaut fondée sur la version peut être explicitement remplacée en ajoutant la ligne suivante à
package main
//go:debug panicnil=1
- Cette combinaison permet de mettre à jour vers une nouvelle toolchain tout en conservant le comportement de l’ancienne, de contrôler finement uniquement les réglages nécessaires, et de vérifier via la supervision de production si des comportements non par défaut sont utilisés
- Plus de détails sont disponibles dans « Go, Backwards Compatibility, and GODEBUG »
Go 2 ne cassera pas Go 1
- Le document « Go 1 and the Future of Go Programs » contenait à l’origine une réserve indiquant qu’une spécification Go 2 pourrait un jour voir le jour
- Go 2 au sens d’une version qui ne compilerait plus les programmes Go 1 n’arrivera pas
- Go 2, au sens d’une grande révision de Go 1 lancée en 2017, a déjà eu lieu
- Go considère que la compatibilité a bien plus de valeur qu’une rupture avec le passé, et a choisi de renforcer encore cette compatibilité
- De nouveaux travaux intéressants continueront d’arriver, mais de manière prudente et compatible afin que les mises à niveau entre toolchains restent aussi stables que possible
1 commentaires
Avis de Hacker News
La question importante en matière de compatibilité n’est pas « faut-il le faire », mais « comment le faire ». En réalité, il ne s’agit pas tant de compatibilité avec le passé que du souhait que, à partir de maintenant, mon code continue simplement de fonctionner
Go 1.21 apporte deux éléments clés qu’il est difficile de voir réunis dans d’autres écosystèmes de langages : chaque changement dispose d’un réglage GODEBUG, peut être annulé individuellement, et il existe aussi des métriques permettant de détecter l’utilisation ou non de l’ancienne implémentation. Il existe également une version de toolchain par module, et les toolchains Go plus anciennes ou plus récentes peuvent être récupérées automatiquement et en toute sécurité, comme des modules
En bonus, si l’on précise une version spécifique comme
go 1.21.2, alors même en l’exécutant avec un Go plus récent, les configurations d’opt-out concernées sont appliquées automatiquement jusqu’à ce que le nouveau comportement soit explicitement demandé. On peut le déclarer dans le code, dansgo.modou dans des variables d’environnement : une approche simple et élégante qui couvre presque tous les cas d’usage de compatibilité, des développeurs aux personnes chargées du déploiementuse v5.24en tête de fichier permet de le faire se comporter comme Perl 5.24, avec une application au niveau du fichier plutôt qu’au niveau du moduleRien que le fait de savoir si l’on prend en charge ou non une nouvelle version crée déjà un problème absurdement complexe, et cela ouvre une zone grise profonde où « nous pensons prendre en charge la nouvelle version et la nouvelle version pense nous prendre en charge, mais chacun rate quelque chose »
J’aime vraiment cette direction. Il n’y a rien de mieux que de pouvoir arriver dans une base de code Go et s’attendre à ce que tout fonctionne simplement en augmentant la version de Go
Ce qui m’inquiète, en revanche, c’est qu’il est difficile d’améliorer fortement le système de types sans changements cassants du genre « ce code était incorrect, il ne compile donc plus ». Je ne sais pas si l’équipe Go s’intéresse à ce sujet, mais il y a beaucoup de gains faciles qui pourraient fortement améliorer la robustesse à la compilation sans ajouter de fonctionnalités au langage
Par exemple : signaler les
nilnon vérifiés, vérifier les accès aux tableaux, inférer les types dans les littéraux de structures imbriquées, ou encore effectuer des vérifications d’exhaustivité sur les énumérations. C’est particulièrement pénible en Go lors de l’écriture d’appels gRPC imbriqués : il suffirait d’un niveau d’inférence de type, puisque le type est déjà présent dans la signature de la fonction appeléeSi le système de types de Go devait s’améliorer, j’aimerais qu’il ajoute ces types de données algébriques simples et élégants. Ils conviendraient vraiment très bien à Go ; faites-vous ce cadeau
Tout à fait d’accord. Si les versions de Go sont attendues, c’est parce qu’elles ajoutent souvent des choses utiles sans rien casser
Aucune partie du langage ne semble irrémédiablement cassée au point de ne pas pouvoir être corrigée par un changement rétrocompatible. Même les quelques pièges, comme l’affectation des variables de boucle, font généralement l’objet de propositions qui préservent la compatibilité
Personnellement, je partage l’enthousiasme vis-à-vis des versions de Go, mais la différence d’attitude entre les deux écosystèmes est intéressante
J’avais autrefois dressé une liste de certaines ruptures rencontrées : https://news.ycombinator.com/item?id=29763324
Avec Rust ou gcc, on met à jour le compilateur et on obtient de nouvelles fonctionnalités du langage, mais la plupart des bibliothèques complexes restent inchangées et peuvent être mises à jour séparément. Par exemple, HTTP en Rust est séparé via
hyper, et en C vialibcurlÀ l’inverse, quand on met à jour le compilateur Go, on récupère à la fois les génériques,
embed, des fonctionnalités de toolchain, mais aussi des changements danstls, les bibliothèques de sécurité et le client/serveur HTTP. Les gros blocs de bibliothèque standard commehttp,tlsetcryptoauraient largement gagné à être des bibliothèques séparées. On aurait ainsi pu mettre à jour le compilateur sans crainte, et faire évoluer les bibliothèques à son propre rythmeasync, alors que dans l’édition 2015 on pouvait créer une variable ou une fonction nomméeasync: c’était donc un changement cassantLa rétrocompatibilité est une bonne chose, mais il est difficile d’être certain qu’un avenir où Go renoncerait à telle innovation X parce qu’elle casserait la compatibilité soit acceptable
Je soutiens fortement la position selon laquelle un futur Go 2 ne rompra jamais la compatibilité avec Go 1. Si le langage devait changer à ce point, il vaudrait mieux le forker et lui donner un autre nom
Cela dit, je me demande pourquoi ne pas aller plus loin et dire clairement qu’« il n’y aura pas de Go 2 », afin de lever toute ambiguïté. Si le Go 2 théorique exécute tous les programmes Go 1, en quoi serait-il différent d’une release Go 1.xx ? Le texte dit qu’« il n’y aura pas de Go 2 qui casse les programmes Go 1 », mais il ne semble pas aller au-delà
https://github.com/golang/proposal/blob/d661ed19a203000b7c54...
Le document explique que, si ce processus fonctionne comme prévu, il n’y aura pas de Go 2 au sens important du terme, et que la transition se fera progressivement via de nouvelles fonctionnalités du langage et des bibliothèques. À un moment donné, pour des raisons marketing, on pourra appeler ça « Go 2 », mais on pourra aussi simplement sauter cette étape. L’argument est : il n’y a pas eu de C 2.0, alors pourquoi faudrait-il un Go 2.0 ?
Les langages populaires comme C, C++ ou Java sont en pratique toujours en version 1.N, et je pense que Go ferait bien de suivre cette voie. Un vrai Go 2, au sens d’un nouveau langage ou de bibliothèques centrales incompatibles, ne serait pas une bonne option pour les utilisateurs et pourrait être nuisible
Au final, cette approche me semble logique
Si la communauté veut clairement une nouvelle fonctionnalité du langage, et que la bonne façon — ou la seule — de l’ajouter passe par un nouveau mot-clé, alors une règle interdisant absolument de casser la compatibilité source empêche cette fonctionnalité d’être ajoutée pour toujours. Je suis d’accord pour dire que les grands changements touchant à la sémantique du langage sont importants, mais les changements incompatibles avec le passé ne sont pas toujours gigantesques
En théorie, l’idée d’une compatibilité ascendante qui ne change jamais est séduisante, mais dans la réalité il existe aussi des breaking changes qui ont du sens. Devoir porter indéfiniment une fonctionnalité du langage qui n’avait pas pris en compte X ou Y lors de sa conception initiale ne me semble pas forcément avantageux
J’utilise beaucoup Go, et cette orientation fait vraiment chaud au cœur
La compatibilité n’est peut-être pas très amusante du point de vue de l’équipe du langage. Il faut toujours garder un pied solidement ancré dans un « passé lointain ». Mais pour quelqu’un qui doit maintenir de grands systèmes Go, c’est un immense cadeau
Si quelque chose ne s’intégrait pas correctement, c’est que ce n’était pas encore la bonne solution, donc on réessayait ; et si ça ne s’intégrait toujours pas, c’était probablement que nous ne comprenions pas encore assez bien le problème, donc il fallait le laisser mûrir davantage
J’ai vraiment apprécié de travailler avec des gens qui réfléchissaient prudemment et cherchaient à faire entrer les bonnes idées. Je suis reconnaissant à Russ d’avoir joué le rôle de BDFL, et d’avoir travaillé avec Ian, Rob et Rob ; grâce à cela, je suis devenu un bien meilleur ingénieur
Article connexe : Forward Compatibility and Toolchain Management in Go 1.21 - https://news.ycombinator.com/item?id=37122932
La phrase « l’ennui, c’est bien. L’ennui, c’est stable. Être ennuyeux signifie que l’on peut se concentrer sur son travail sans se soucier de ce qui a changé dans Go » me parle vraiment
Dans mon travail principal, j’utilise NodeJS et l’écosystème JS en général, et c’est vraiment pénible. L’écosystème est fragmenté, chacun fait à sa façon, et il est difficile de rendre les choses stables. J’aime toujours ce travail, mais j’aimerais que l’écosystème JS dispose lui aussi d’une base moderne et stable sur laquelle on puisse compter
Beaucoup d’outils et d’API sont écrits en Go, et le fait de ne pas nécessiter de runtime séparé sur le système cible est souvent cité comme un gros avantage. Le langage semble aussi simple à apprendre et à utiliser, la prise en charge dans VSC et GoLand est bonne, et les reproches courants comme la gestion des erreurs ne ressemblent pas à des défauts rédhibitoires
Je me demande ce qui manque encore à Go pour devenir, dans les décennies à venir, un pilier du développement, ou au moins une grande part du marché de l’emploi. Dans certains endroits, il est encore considéré comme un langage de niche
Le problème est de faire en sorte que tout le monde atteigne cette baseline. Une fois là, les choses devraient aller beaucoup mieux
Par ailleurs, l’écosystème JS inclut aussi le travail d’UI frontend, et ses domaines d’application sont si nombreux qu’il est impossible d’éviter l’apparition de multiples implémentations. C’est même plutôt souhaitable
Le fait d’avoir déplacé une partie du code de Python vers Golang nous a beaucoup aidés à scaler. Je suis vraiment ravi de voir que Go continuera de respecter son engagement fondamental : la compatibilité ascendante
Une discussion sur le parsing d’IP, donc. Je me demande comment le
inet_ntoade BSD était écrit à l’origineatoi/atoletsscanfavec%d/%uparsant toujours exactement des entiers décimaux, il aurait fallu utiliser%iou unstrtouen base 0 pour produire ce genre d’effet étrangeinet_atonindique qu’il vient de 4.3BSD : https://github.com/dank101/4.3BSD-Reno/blob/master/lib/libc/...Le
inet_addrde 4.2BSD, encore antérieur, utilise aussi la même logique : https://github.com/dank101/4.2BSD/blob/master/lib/libc/inet/...inet_atonetinet_addrparsant les adresses de la manière la plus naturelle. Utiliser quelque chose commestrtoul, ou surtoutsscanf, aurait été maladroit. La beauté des pointeurs en C, c’est qu’ils rendent les petites tâches de parsing très faciles — peut-être même trop faciles/etc/hosts, j’avais complété chaque octet avec des zérosAu final, j’ai dû revenir en arrière et supprimer les zéros
En tant que concepteur de langage, je respecte les choix faits ici, y compris celui de ne pas créer un vrai Go 2
Je compte moi aussi reprendre certaines des techniques permettant de garantir la compatibilité