- Go 1.21 se enfoca en reducir la carga de actualización al ampliar la compatibilidad basada en GODEBUG, para que el nuevo toolchain implemente de la forma más estable posible incluso el comportamiento de versiones anteriores de Go
- Desde Go 1, en 2012, Go ha prometido compatibilidad de código fuente, y ha reducido las rupturas por eliminaciones o cambios mediante revisiones de API públicas y pruebas internas a gran escala
- Incluso mejoras permitidas por la documentación pueden romper programas existentes, como la mayor precisión de
time.Now, cambios en la implementación de sort, cambios en la salida de compress/flate, la ampliación de entradas de strconv.ParseInt o cambios en el parsing de net.ParseIP
- A partir de Go 1.21, las configuraciones GODEBUG para compatibilidad se mantienen por al menos 2 años o 4 releases de Go, y pueden preservar el comportamiento anterior como valor predeterminado según la versión
go en go.mod
- Go 2 no llegará como una nueva especificación que rompa programas de Go 1; Go mantiene estables las actualizaciones del toolchain al priorizar la compatibilidad, incluso cuando agrega nuevas funciones
Principios básicos de la compatibilidad de Go 1
- En Go 1, en 2012, Go estableció en el documento “Go 1 and the Future of Go Programs” el objetivo de que los programas escritos conforme a la especificación de Go 1 sigan compilando y ejecutándose correctamente, sin cambios, durante la vida de esa especificación
- El centro de esta promesa es la compatibilidad de código fuente
- Al actualizar a una nueva versión de Go, el código debe recompilarse
- Se pueden agregar nuevas API, pero se deben evitar agregados que rompan código existente
- No es posible garantizar que ningún cambio futuro rompa absolutamente todos los programas
- Si un programa depende del comportamiento de un bug, puede romperse cuando ese bug se corrige
- Go busca mantener actualizaciones estables reduciendo las rupturas tanto como sea posible
Prevención de rupturas de compatibilidad mediante revisión de API públicas
- En el proceso de desarrollo de Go, la lista de API públicas de cada paquete se mantiene en archivos separados de los paquetes reales
- Por ejemplo,
go/api/go1.21.txt registra elementos de funciones, métodos y tipos de bytes, cmp, context, entre otros
- Las pruebas estándar verifican que la API real de los paquetes coincida con esos archivos
- Si se agrega una API nueva, también debe agregarse al archivo de API para que pasen las pruebas
- Si se cambia o elimina una API existente, las pruebas también fallan
- No solo eliminar una API puede romper la compatibilidad; cambiar un tipo también puede hacerlo
os.Stdout es una variable global de tipo *os.File
- Si se cambiara por una interfaz con los mismos métodos, se rompería el código que exige
*os.File, como greet(f *os.File)
- La revisión de API es útil para detectar cambios o eliminaciones de API, pero no impide todos los cambios incompatibles posibles en Go
Rupturas sutiles reveladas por las pruebas
- Las versiones de desarrollo de cada nuevo release de Go se prueban continuamente contra todo el código Go interno de Google
- Si las pruebas pasan, ese commit se instala como el toolchain Go de producción de Google
- Si las pruebas internas se rompen, se asume que también podría romperse código externo y se busca una forma de reducir el impacto
- En la mayoría de los casos, el cambio se revierte o se reescribe para no romper programas
- Algunos cambios pueden seguir siendo importantes y quedar como cambios compatibles según la documentación, aunque rompan programas
- Incluso en esos casos, se reduce el alcance del impacto y se dejan posibles problemas en las notas del release
Dos casos aparecidos en Go 1.1
-
Literales de estructuras y campos nuevos
- En Go 1,
net.TCPAddr era una estructura con dos campos, IP y Port, y también compilaban los literales compuestos sin nombres de campo
- Cuando en Go 1.1 se agregó el campo
Zone a net.TCPAddr, el código existente dejó de compilar con el error “too few initializers in struct literal”
- La forma compatible de escribirlo es usar literales con etiquetas
var myAddr = &net.TCPAddr{
IP: net.IPv4(18, 26, 4, 9),
Port: 80,
}
- Si no se especifica
Zone, ese campo usa su zero value, una cadena vacía
- La documentación de compatibilidad incluye el requisito de usar literales compuestos con etiquetas para las estructuras de la biblioteca estándar, y
go vet reporta literales sin etiquetas cuando estas son necesarias para compatibilidad con versiones posteriores
-
Precisión del tiempo
- Después de Go 1,
time.Now cambió para devolver precisión de nanosegundos en lugar de precisión de microsegundos
- Este cambio podía romper pruebas que esperaban igualdad después de hacer un ida y vuelta de un valor de
time.Now mediante save y load
- Si la representación almacenada solo preservaba precisión de microsegundos, podía pasar en Go 1 pero fallar en Go 1.1
- Para ayudar a corregir esas pruebas, Go agregó los métodos
Round y Truncate, y en las notas del release resumió los posibles problemas y los nuevos métodos
- La mayor precisión era un mejor comportamiento y estaba permitida dentro del rango documentado de la función, por lo que se publicó aunque rompiera algunos programas
Tres tipos de cambios que pueden romper la compatibilidad
-
Cambios de salida
- Un cambio de salida ocurre cuando una función produce una salida distinta de la anterior, pero la nueva salida es tan correcta como la anterior, o más correcta
- El agregado de precisión de nanosegundos en
time.Now es un ejemplo representativo
- En Go 1.6, la implementación de
sort se cambió para hacerla aproximadamente un 10% más rápida, y con ello cambió el orden de elementos considerados iguales
- Salida de Go 1.5:
[red blue green white black yellow orange indigo violet]
- Salida de Go 1.6:
[red blue white green black orange yellow indigo violet]
- El ordenamiento puede devolver resultados equivalentes en cualquier orden, pero los programas que esperaban un orden específico se rompieron
- En Go 1.8,
compress/flate se mejoró para producir salidas más pequeñas con una sobrecarga similar de CPU y memoria
- Por eso, las compilaciones de archivos reproducibles internas de Google ya no pudieron reproducir exactamente los archivos anteriores
- Ese proyecto hizo un fork de
compress/flate y compress/gzip para mantener el algoritmo anterior
- Para prepararse ante cambios de salida, conviene escribir programas y pruebas de forma que acepten todas las salidas válidas
- Si se necesita una salida realmente reproducible, se puede hacer un fork del código, pero eso también separa al proyecto de las correcciones de bugs
-
Cambios de entrada
- Un cambio de entrada ocurre cuando una función cambia las entradas que acepta o la forma en que las procesa
- Go 1.13 agregó sintaxis con guiones bajos para mejorar la legibilidad de números, y
strconv.ParseInt también cambió para aceptar esta nueva sintaxis
- Se rompió el código de un usuario externo que usaba números separados por guiones bajos como un formato de datos aparte
- Ese código primero intentaba
ParseInt y solo procesaba los guiones bajos si fallaba, pero ParseInt dejó de fallar
net.ParseIP aceptaba direcciones IP decimales con ceros iniciales siguiendo ejemplos de las primeras RFC de IP
- Go leía
18.032.4.011 como 18.32.4.11
- Las bibliotecas C de la familia BSD interpretan los ceros iniciales como inicio de un número octal, por lo que leen la misma cadena como
18.26.4.9
- Go 1.17 cambió
net.ParseIP para rechazar por completo los ceros iniciales
- Fue una decisión para que, cuando tanto Go como C logren parsear una dirección IP, esta tenga el mismo significado
- Kubernetes se preocupó por la posibilidad de que configuraciones guardadas existentes ya no se pudieran parsear en Go 1.17, y empezó a usar un fork del
net.ParseIP original
- Para entradas de usuario, conviene validar primero la sintaxis que se aceptará antes de parsear los valores, aunque en algunos casos puede ser necesario hacer un fork del código
-
Cambios de protocolo
- Un cambio de protocolo ocurre cuando un cambio en un paquete se manifiesta de forma visible en el protocolo usado para comunicarse con el mundo externo
- Go 1.6 agregó soporte automático para HTTP/2
- Un cliente de Go 1.5 usa solo HTTP/1.1, por lo que puede funcionar correctamente en ciertos entornos con equipos intermedios de red
- Al actualizar a Go 1.6 se usa HTTP/2 y, si HTTP/2 no funciona en ese entorno, el programa puede romperse
- Go busca soportar protocolos modernos por defecto, pero activar HTTP/2 puede romper programas incluso sin errores del programa ni de Go en sí
- Go 1.6 resumió el cambio en las notas del release y ofreció formas de desactivar HTTP/2
- Configurar explícitamente el campo
TLSNextProto
- Configurar
GODEBUG=http2client=0, GODEBUG=http2server=0, o ambos
- El soporte de certificados HTTPS basados en SHA1 también es un caso más sutil de cambio de protocolo
- Las autoridades certificadoras dejaron de emitir certificados SHA1 en 2015, y los principales navegadores dejaron de aceptarlos en 2017
- Go 1.18 desactivó por defecto el soporte de certificados SHA1 y permitió omitirlo mediante GODEBUG
- Como algunas instalaciones de Kubernetes seguían usando certificados SHA1 privados, Go decidió mantener la configuración de excepción por más tiempo del previsto
Soporte GODEBUG ampliado en Go 1.21
- Go 1.21 amplía y formaliza el uso de GODEBUG para reducir incluso los problemas sutiles de compatibilidad
- Para cambios permitidos por las reglas de compatibilidad de Go 1 pero que pueden romper programas existentes, se define una configuración GODEBUG que permite a cada programa rechazar el nuevo comportamiento
- Puede haber casos en los que no sea posible agregar una configuración, pero se tratan como muy poco frecuentes
- Las configuraciones GODEBUG para compatibilidad se mantienen por al menos 2 años, es decir, 4 releases de Go
- Configuraciones como
http2client y http2server pueden mantenerse mucho más tiempo, en algunos casos indefinidamente
- Cuando es posible, cada configuración GODEBUG tiene asociado un contador de
runtime/metrics
- El nombre del contador tiene el formato
/godebug/non-default-behavior/<name>:events
- Por ejemplo, si se configura
GODEBUG=http2client=0, /godebug/non-default-behavior/http2client:events cuenta la cantidad de HTTP transports configurados sin HTTP/2
- Los valores predeterminados de GODEBUG de un programa se ajustan a la versión de Go escrita en el
go.mod del paquete principal
- Si
go.mod dice go 1.20 y se actualiza al toolchain de Go 1.21, el comportamiento controlado por GODEBUG que cambió en Go 1.21 mantiene el comportamiento de Go 1.20 hasta que se cambie go.mod a go 1.21
- Cada configuración GODEBUG puede modificarse con una línea
//go:debug en package main
- Todas las configuraciones GODEBUG están resumidas en una lista central
Caso de panic(nil)
- En Go 1.21,
panic(nil) ahora provoca un panic de runtime no nil
- Con este cambio, el resultado de
recover puede indicar de forma confiable si la goroutine actual está en panic
- El nuevo comportamiento se controla con una configuración GODEBUG y depende de la línea
go del go.mod del paquete principal
- Si es
go 1.20 o anterior, panic(nil) sigue permitido
- Si es
go 1.21 o posterior, panic(nil) se convierte en un panic con runtime.PanicNilError
- El valor predeterminado basado en versión puede sobrescribirse explícitamente agregando la siguiente línea a
package main
//go:debug panicnil=1
- Esta combinación permite actualizar a un toolchain nuevo mientras se preserva el comportamiento del toolchain anterior, controlar con precisión solo las configuraciones necesarias y detectar mediante monitoreo de producción si se está usando comportamiento no predeterminado
- Más detalles están resumidos en “Go, Backwards Compatibility, and GODEBUG”
Go 2 no romperá Go 1
- El documento “Go 1 and the Future of Go Programs” incluía la salvedad de que algún día podría aparecer una especificación de Go 2
- No habrá un Go 2 en el sentido de que los programas de Go 1 dejen de compilar
- Go 2, en el sentido de una gran revisión de Go 1 iniciada en 2017, ya ocurrió
- Go considera que la compatibilidad es mucho más valiosa que romper con el pasado, y eligió reforzar aún más la compatibilidad
- En el futuro seguirán llegando trabajos nuevos e interesantes, pero se harán de forma cuidadosa y compatible para que las actualizaciones entre toolchains sean lo más estables posible
1 comentarios
Opiniones de Hacker News
La pregunta importante sobre la compatibilidad no es “si hacerlo”, sino “cómo hacerlo”. En realidad, más que compatibilidad hacia atrás, se parece más a querer que, de ahora en adelante, mi código simplemente siga funcionando
Go 1.21 ofrece dos elementos clave que son difíciles de ver al mismo tiempo en otros ecosistemas de lenguajes: cada cambio tiene una configuración GODEBUG, se puede revertir cambio por cambio, y también hay métricas para detectar si se está usando la implementación anterior. Además, existe una versión de toolchain por módulo, y las toolchains de Go más antiguas y más nuevas pueden obtenerse automáticamente de forma segura como si fueran módulos
Como bonus, si se especifica una versión concreta como
go 1.21.2, incluso al ejecutarlo con un Go más nuevo, se aplican automáticamente las configuraciones de opt-out relacionadas hasta que se solicite explícitamente el nuevo comportamiento. Se puede declarar en el código, engo.modo en variables de entorno, una forma simple y elegante que cubre casi todos los casos de uso de compatibilidad, desde desarrolladores hasta responsables de despliegueuse v5.24al inicio de un archivo, puede hacer que se comporte como Perl 5.24, y se aplica por archivo, no por móduloSolo decidir si se soporta o no una versión nueva ya genera problemas absurdamente complejos, y aparece un valle profundo de “creemos que soportamos la versión nueva y la versión nueva cree que nos soporta, pero ambos nos estamos perdiendo cosas”
Me gusta mucho esta dirección. No hay nada mejor que entrar a una base de código en Go y poder esperar que solo con subir la versión de Go todo siga funcionando bien
Sin embargo, me preocupa que sea difícil mejorar mucho el sistema de tipos sin cambios rompientes del estilo “esto era código incorrecto y ahora ya no compila”. No sé si al equipo de Go le interesa esto, pero hay muchas oportunidades fáciles para aumentar mucho la robustez en tiempo de compilación sin agregar funcionalidades al lenguaje
Por ejemplo, reportes de
nilno verificados, verificación de accesos a arreglos, inferencia de tipos en literales de structs anidados y verificación exhaustiva de enumeraciones. En especial, al escribir llamadas gRPC anidadas, solo hace falta un nivel de inferencia de tipos, porque el tipo ya está en la firma de la función llamada, pero en Go es muy dolorosoSi el sistema de tipos de Go mejora, me gustaría que se agregaran estos tipos de datos algebraicos simples y elegantes. Encajarían muy bien en Go, así que ojalá se den ese regalo
Totalmente de acuerdo. La razón por la que espero con ganas los releases de Go es que suelen agregar cosas útiles sin romper nada
Ninguna parte del lenguaje parece tan rota como para no poder arreglarse con cambios compatibles hacia atrás. Incluso las pocas trampas, como la asignación de variables en loops, por lo general tienen propuestas que mantienen la compatibilidad
Personalmente coincido con la perspectiva de esperar con ganas los releases de Go, pero la diferencia de actitud entre los dos ecosistemas es interesante
Hace un tiempo recopilé algunas de las roturas que viví: https://news.ycombinator.com/item?id=29763324
Con Rust o gcc, al subir el compilador obtienes funcionalidades del lenguaje, pero la mayoría de las bibliotecas complejas permanecen igual y puedes actualizarlas por separado. Por ejemplo, en Rust HTTP está separado como
hyper, y en C comolibcurlEn cambio, al subir el compilador de Go no solo llegan genéricos,
embedy funcionalidades de toolchain, sino también cambios entls, bibliotecas de seguridad y cliente/servidor HTTP. Grandes bloques de la biblioteca estándar comohttp,tlsycryptohabrían estado mucho mejor como bibliotecas separadas. Así uno podría actualizar el compilador sin miedo y las bibliotecas a su propio ritmoasync, pero en la edición 2015 se podía crear una variable o función llamadaasync, así que fue un cambio rompienteLa compatibilidad hacia atrás es buena, pero no estoy seguro de que esté bien un futuro en el que Go no pueda hacer alguna innovación X porque rompería la compatibilidad
Apoyo mucho la postura de que el futuro Go 2 nunca romperá la compatibilidad con Go 1. Si hay que cambiar tanto el lenguaje, creo que sería mejor simplemente hacer un fork y cambiarle el nombre.
Sin embargo, me pregunto por qué no van más allá y dicen “Go 2 no existirá” para eliminar la ambigüedad. Si el Go 2 teórico ejecuta todos los programas de Go 1, ¿en qué se diferenciaría de una versión Go 1.xx? El texto dice que “no habrá un Go 2 que rompa programas de Go 1”, pero parece que no llega más lejos que eso.
https://github.com/golang/proposal/blob/d661ed19a203000b7c54...
Explican que, si el proceso anterior funciona según lo previsto, en un sentido importante no habrá Go 2, y se irá migrando poco a poco con nuevas funciones del lenguaje y de la biblioteca. En algún momento podrían llamarlo por marketing “ahora Go 2”, pero también podrían simplemente saltárselo. La lógica es: si no hubo C 2.0, ¿por qué tendría que haber Go 2.0?
Lenguajes populares como C, C++ y Java también son, en la práctica, siempre versiones 1.N, y creo que a Go le conviene seguir ese camino. Un verdadero Go 2 en el sentido de un nuevo lenguaje o una biblioteca central incompatible no es una buena opción para los usuarios y podría ser perjudicial.
Al final, creo que ese enfoque tiene sentido.
Si hay una nueva funcionalidad del lenguaje que la comunidad claramente quiere, y la forma correcta o la única forma de incorporarla es una nueva palabra clave, entonces la regla de nunca romper la compatibilidad de código fuente impide agregar esa funcionalidad para siempre. Estoy de acuerdo con los grandes cambios en la semántica del lenguaje, pero un cambio incompatible con el pasado no siempre es un cambio enorme.
En teoría, la idea de que la compatibilidad hacia atrás nunca cambie suena bien, pero en la realidad también hay cambios incompatibles que tienen sentido. No necesariamente se siente como una ventaja cargar para siempre con funcionalidades del lenguaje cuyo diseño inicial no consideró X o Y.
Uso mucho Go, y esta dirección realmente me reconforta.
La compatibilidad puede no ser muy divertida para el equipo del lenguaje. Siempre obliga a mantener un pie firmemente plantado en “el pasado lejano”. Pero para quienes tenemos que mantener sistemas grandes en Go, es un regalo enorme.
Si algo no podía encajar correctamente, todavía no era lo correcto, así que volvíamos a intentarlo; y si aun así no encajaba, probablemente no entendíamos el problema lo suficiente, por lo que convenía dejarlo madurar más tiempo.
Me encantó trabajar con gente que pensaba con cuidado y se esforzaba por que entraran las ideas correctas. Agradezco que Russ haya cumplido el rol de BDFL, y haber trabajado con Ian, Rob y Rob; gracias a eso me convertí en un ingeniero mucho mejor.
Artículo relacionado: Forward Compatibility and Toolchain Management in Go 1.21 - https://news.ycombinator.com/item?id=37122932
Me llega mucho la frase: “Lo aburrido es bueno. Lo aburrido es estable. Aburrido significa que puedes concentrarte en tu trabajo sin preocuparte por lo que cambió en Go”.
En mi trabajo principal uso NodeJS y el ecosistema de JS en general, y la verdad se sufre bastante. El ecosistema está fragmentado y cada quien hace las cosas a su manera, así que es difícil volverlo estable. Aun así, todavía disfruto este trabajo, pero me gustaría que en el ecosistema JS hubiera una base moderna y estable en la que se pudiera confiar y de la que se pudiera depender.
Muchas herramientas y APIs están escritas en Go, y el hecho de no necesitar un runtime separado en el sistema de destino suele citarse como una gran ventaja. El lenguaje también parece simple de aprender y usar, el soporte de VSC y GoLand es bueno, y quejas frecuentes como el manejo de errores no parecen defectos decisivos.
Me pregunto qué más necesita Go para convertirse en la corriente principal del desarrollo durante las próximas décadas, o al menos ocupar una gran parte del mercado laboral. En algunos lugares todavía se lo considera un lenguaje de nicho.
El problema es lograr que todo el mundo llegue a esa línea base. Una vez ahí, creo que todo mejorará mucho.
Además, el ecosistema JS incluye hasta el trabajo de UI en frontend, y como tiene muchísimos campos de aplicación, es inevitable que aparezcan múltiples implementaciones. Incluso puede ser algo deseable.
Pasar parte del código de Python a Golang nos ayudó mucho a escalar. Me alegra mucho saber que Go seguirá manteniendo su declaración central de compatibilidad hacia atrás.
¿Hablando de parsing de IP? Me da curiosidad cómo se habrá escrito originalmente el
inet_ntoade BSD.sscanfusandoatoi/atoly%d/%usiempre parsea exactamente como enteros decimales, así que para producir este efecto raro seguramente tuvieron que usar%iostrtoucon base 0.inet_atondice que viene de 4.3BSD: https://github.com/dank101/4.3BSD-Reno/blob/master/lib/libc/...El
inet_addrdel 4.2BSD anterior también usa la misma lógica: https://github.com/dank101/4.2BSD/blob/master/lib/libc/inet/...inet_atoneinet_addrparsean las direcciones de la forma más natural. Usar algo comostrtoulo, especialmente,sscanfhabría sido incómodo. La belleza de los punteros en C está en que hacen muy fáciles las tareas simples de parsing; quizá demasiado fáciles./etc/hosts, le hice padding con ceros a cada octeto.Al final tuve que volver atrás y quitar los ceros.
Como diseñador de lenguajes, respeto las decisiones que se tomaron aquí, incluida la de no crear un verdadero Go 2.
Yo también pienso aprovechar las técnicas para garantizar la compatibilidad.