- Go 1.22 busca reducir uno de los errores más representativos de Go al cambiar las variables del bucle
for de un alcance de todo el bucle a un alcance por iteración, para que los closures no capturen por error la misma variable
- Con la semántica anterior, incluso sin goroutines, una función ejecutada después de la iteración puede referenciar el mismo
v o i, ver solo el valor final o hacer que una prueba pase incorrectamente
- Los analizadores
loopclosure de go vet y gopls solo detectan los casos seguros, por lo que hay falsos negativos, y los analizadores más agresivos pueden aumentar código innecesario como x:= x por culpa de falsos positivos
- La nueva semántica solo se aplica a módulos que declaren
go 1.22 o superior en go.mod, y en Go 1.21 se puede ejecutar una vista previa con GOEXPERIMENT=loopvar
- Google forzó este modo en todas las compilaciones de su toolchain interna de Go desde inicios de mayo de 2023, y en 4 meses no hubo reportes de problemas en producción, aunque sí salieron a la luz pruebas mal escritas
La trampa de captura de variables en el for tradicional
- En la semántica anterior de Go, las variables de un bucle
for tienen alcance de todo el bucle, así que el código que las referencia después de terminar la iteración puede ver valores distintos a los esperados
- Si se recorre
values := []string{"a", "b", "c"} y se crean tres goroutines, cada goroutine imprime la misma variable v en lugar del v de su iteración
- El mismo problema ocurre incluso sin concurrencia
- Si dentro del bucle se guarda
func() { fmt.Println(i) } en un slice y luego se ejecuta más tarde, cada función referencia el mismo i en lugar del valor de cada iteración
Fallas en producción y límites de los analizadores
- Este error ha provocado problemas en producción en varias empresas, y el issue público de Let’s Encrypt es uno de esos casos
- En el caso de Let’s Encrypt, durante un recorrido de map se copió
k con kCopy := k, pero como modelToAuthzPB(&v) usaba punteros a campos de v al construir el resultado, también era necesario copiar v por separado
- Como la captura de variables se extendía por varias funciones, era difícil detectar el problema
- A las herramientas de análisis estático les cuesta determinar si una variable sigue viva después de la iteración, así que deben hacer un compromiso entre falsos positivos y falsos negativos
- Los analizadores
loopclosure de go vet y gopls reportan solo problemas seguros, aceptando así los falsos negativos
- Los analizadores más agresivos pueden marcar código correcto como si fuera incorrecto
- Si se revisan commits en código Go de código abierto que agregan líneas como
x := x, se ve que no solo había correcciones reales de bugs, sino también muchos cambios innecesarios
- Había casos donde los desarrolladores agregaban código innecesario solo para satisfacer al analizador
- Entre dos diff como
informer := informer y a := a, uno podía ser una corrección real y el otro un cambio innecesario, pero sin información de tipos y funciones es difícil distinguirlos
La nueva semántica de bucles en Go 1.22
- En Go 1.22, las variables de los bucles
for pasarán a tener un alcance separado por iteración
- Los ejemplos anteriores dejarán de ser programas Go con bugs, y también disminuirán los problemas en producción y la necesidad de analizadores imprecisos para este tipo de error
- Para mantener compatibilidad hacia atrás, la nueva semántica solo se aplica a los paquetes de módulos que declaren
go 1.22 o superior en go.mod
- Esto permite migrar de forma gradual sin cambiar toda la base de código de una sola vez
- También se puede controlar por archivo con líneas
//go:build
- El código existente mantiene exactamente la semántica actual
- El cambio solo se aplica a código nuevo o actualizado
- Los desarrolladores pueden controlar en qué momento cambia la semántica en un paquete específico
Salvaguardas en versiones anteriores de Go
- Según el trabajo de forward compatibility de Go, Go 1.21 no compila código que declare
go 1.22 o superior
- Las point releases Go 1.20.8 y Go 1.19.13 también incluyen un manejo especial para lograr el mismo efecto
- Una vez lanzado Go 1.22, el código escrito dependiendo de la nueva semántica no se compilará con la semántica anterior, salvo que se usen versiones de Go fuera de soporte muy antiguas
Probar la vista previa en Go 1.21
- Go 1.21 incluye una vista previa del cambio de alcance en los bucles
- Si se compila con
GOEXPERIMENT=loopvar, se ignora la línea go de go.mod y la nueva semántica se aplica a todos los bucles
- Para comprobar que un paquete y todas sus dependencias siguen pasando las pruebas con la nueva semántica de bucles, se ejecuta así
GOEXPERIMENT=loopvar go test
- En Go Playground se puede probar la nueva semántica poniendo el comentario
// GOEXPERIMENT=loopvar al inicio del programa
- La toolchain interna de Go en Google fue parcheada a inicios de mayo de 2023 para forzar este modo en todas las compilaciones, y en los 4 meses siguientes no hubo reportes de problemas en código de producción
Bugs de pruebas que deja ver la nueva semántica
- La nueva semántica de bucles no causó problemas en código de producción, pero sí dejó ver pruebas que pasaban incorrectamente
- En el ejemplo de subpruebas con
t.Parallel, Go 1.21 bloquea cada subprueba hasta que termina todo el bucle y luego las ejecuta en paralelo
- Cuando termina el bucle,
v siempre vale 6, así que todas las subpruebas verifican que 6 es par y pasan
- Pero como entre los casos de prueba realmente está
1, la prueba debería fallar
- En Go 1.21 se mejoró la precisión del analizador
loopclosure, que ahora puede identificar y reportar este problema
- Ejemplo de reporte en Go Playground: programa de ejemplo
- Si
go vet reporta este tipo de problema en pruebas, corregirlo ayuda a prepararse para Go 1.22
- La FAQ reúne herramientas y ejemplos para encontrar los bucles que hacen fallar pruebas específicas al aplicar la nueva semántica
Para seguir leyendo
1 comentarios
Opiniones de Hacker News
Seguramente hay ejemplos mucho más antiguos, pero la advertencia más vieja sobre este comportamiento que encontré con una búsqueda de 60 segundos fue la FAQ de comp.lang.lisp, publicada hace más de 30 años, en 1992.
Ahí se explica que
DOTIMES,DOLISTyDOusan asignación, no binding, al actualizar la variable de iteración, por lo que si unlambdacapturancomo en el ejemplo, los 10 closures se construyen todos sobre el valor de la misma variableN.Si se captura por referencia, en realidad es el comportamiento esperado.
Aun así, una vez que aprendes cómo funciona, deja de ser un problema, y si hace falta puedes elegir la forma y expandir la macro para revisar cómo está implementada.
El equipo del lenguaje C# también tuvo el mismo problema después de introducir closures ligeros en C# 4.0, y pronto quedó claro que era una trampa.
Los usuarios casi siempre usaban mal la variable del loop, y en C# 5.0 introdujeron un cambio que rompía compatibilidad.
Eric Lippert escribió un artículo que explica muy bien el “por qué” desde esa perspectiva: https://ericlippert.com/2009/11/12/closing-over-the-loop-var...
El post original del anuncio de C# 5 fue difícil de encontrar; ojalá no haya desaparecido durante las varias migraciones de blogs en dominios de Microsoft desde 2012.
Si pensamos en el caos que causó solo el cambio del tipo de strings al pasar de Python 2 a 3, no parece que este cambio vaya a entrar antes de Python 4.0.
Y seguramente alguien dirá que Python es malo por no corregir esto, y luego volverá a insultar a Python porque no le corre un script hecho en 2003.
Creo que tuvo un papel importante para superar la barrera de “rechazar de entrada” que toda propuesta de cambio de lenguaje debería tener por defecto.
Otro punto que convenció mucho fue escanear bases de código open source y ver el balance entre bugs que se corregirían y bugs nuevos que aparecerían.
Como es paso por valor, captura el estado de la variable en el momento de la llamada y reduce la ambigüedad del código.
Si intentas capturar variables de forma rara, por ejemplo al acumular en una colección para convertir un array en un map, las colecciones y las variables declaradas terminan comportándose de manera distinta.
Go parece intentar equilibrarlo aplicando este comportamiento solo a los contadores de loop, pero algunas variables siguen comportándose de forma extraña.
En especial, me da curiosidad qué pasará cuando se definan varias variables de loop para escanear directamente la entrada.
for(let).https://eli.thegreenplace.net/2019/go-internals-capturing-lo... parece explicar este problema con más detalle.
i := ifuncionara por una razón totalmente distinta a la que yo pensaba.Al principio creía que, como el nuevo
ise pasaba a la goroutine, el análisis de escape lo marcaba como escapando fuera del ámbito léxico, por lo que se asignaba en el heap, y cada iteración generaba una asignación en el heap para que cada goroutine referenciara una ubicación de memoria única.En realidad, el compilador de Go tiene heurísticas para elegir entre captura por referencia y captura por valor, y una de las condiciones es capturar por valor los valores que no se actualizan después de la inicialización.
El nuevo
iestá en el ámbito del cuerpo delfory el loop en sí no lo actualiza, así que se considera un valor que no se actualiza tras la inicialización, y se genera código que lo captura por valor, sin asignación en el heap.Entiendo que esto último es mejor, pero me gustaría escuchar de alguien que conozca Go en profundidad por qué no ocurre también lo primero.
¿Este cambio no romperá programas que dependen del comportamiento actual?
go 1.22o superior engo.modTambién se puede determinar por archivo usando una línea
//go:buildEsa promesa dice que los programas escritos con la especificación de Go 1 deben seguir compilando y ejecutándose correctamente sin cambios durante la vida útil de la especificación, y que aunque algún día podría haber una especificación de Go 2, hasta entonces los programas Go que funcionan hoy deben seguir funcionando también en lanzamientos puntuales como Go 1.1 o Go 1.2
Creo que, por este diseño, la cantidad de personas que creaban bugs sin querer era mucho mayor que la cantidad de personas afectadas por la corrección
Según recuerdo, decía que casi no había casos en el código base de Google o en código de GitHub donde este cambio rompiera el comportamiento esperado
Solo decidieron romper la compatibilidad hacia atrás después de confirmar lo pocas que eran las bases de código afectadas y de crear un mecanismo por el cual, para usar el nuevo comportamiento, había que modificar activamente el código mediante la versión indicada en
go.modhttps://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 mayoría no se mencionó en absoluto en el documento de la propuesta
También tuve este problema en Python, aunque no recientemente
No estoy seguro de si Python cambió o si yo aprendí a detectar el problema
Que todavía puede ser un problema en Python queda bastante claro solo con este código:
funcs = [(lambda: x) for x in range(3)];funcs[0]()imprime2Python antes era peor y compartía el alcance incluso fuera de la comprensión de listas
Si usas una lambda dentro de una comprensión de listas o un loop, no captura el valor actual de
x, sino una referencia a la variablexCuando llamas a
funcs[0](),xya quedó establecido en el último valor derange, que es2Para obtener el comportamiento deseado, puedes pasar
xcomo argumento por defecto de la lambda:funcs = [(lambda x=x: x) for x in range(3)]He usado Go solo un poco y entiendo el problema general que resuelve este cambio, pero no termino de entender los ejemplos más sutiles, como el caso de letsencrypt o
"range c.informerMap"frente a"range alarms"En
for k, v := range someMap, ¿ves del tipo del valor del map y hay un único binding para todo el loop, que se copia en cada iteración? Si es así, el problema se explica, pero yo habría esperado quevfuera una referencia al interior del mapAl revisar rápido “For statements with range clause” en la especificación no encontré la respuesta, aunque como casi no uso Go quizá estaba mirando en el lugar equivocado: https://go.dev/ref/spec#For_statements
Edición: la respuesta estaba en la tabla con formato de bloque de código. Parece que la pasé por alto como si fuera un banner. Me sorprende que
vsea un valor copiado y no una referenciaSí admite punteros a posiciones de un array, pero
for rangecopia en vez de darte un puntero a cada posiciónvesintEs un valor, no un puntero a
intSi te da curiosidad, puedes revisarlos: https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
Básicamente, debido a la desreferenciación automática, el compilador está convirtiendo
go a.Monitor(b)en(&a).Monitor(b)Me da curiosidad cómo funciona la parte que dice: “Como resultado del trabajo de compatibilidad hacia adelante, Go 1.21 no intenta compilar código que declare
go 1.22o superior. También agregamos un tratamiento especial con el mismo efecto en los lanzamientos puntuales Go 1.20.8 y Go 1.19.13, así que cuando salga Go 1.22, el código escrito dependiendo de la nueva semántica nunca se compilará con la semántica anterior, a menos que se use una versión de Go muy antigua y sin soporte”.Si algún paquete fijó 1.22 y yo compilo con 1.18, ¿compila o da un error diciendo que se necesita el compilador 1.22?
Como en Go 1.21 cambiaron el formato del número de versión del archivo
go.mod, si intentas compilar con Go 1.18 aparece un error comogo.mod:3: invalid go version '1.21.0': must match format 1.23.Pero eso solo pasa cuando creaste el módulo con
go mod init; si escribes manualmentego 1.21engo.mod, compila sin quejarse.Es una función bastante genial, pero es un comportamiento sorprendente, y me genera un poco de dudas que se conecte a un servidor controlado por Google para descargar binarios.
Junto con el proxy de módulos, es una de las funciones de Go sobre las que tengo sentimientos más encontrados, y creo que me sentiría mucho más cómodo si Go fuera administrado por una fundación en la que Google solo tuviera participación.
Edición: pensándolo bien, esto aplica cuando el módulo actual declara otra versión, no cuando una dependencia lo hace, así que no es lo mismo que la pregunta original.
Por eso usar Go 1.18 se vuelve activamente peligroso.
En Go 1.19 debería dar un error del compilador.
De todos modos, Go no aplica correcciones de bugs de seguridad a lanzamientos antiguos ni a sus bibliotecas estándar, así que considero que usar esas versiones ya es peligroso en sí mismo.
Pero incluso si compilas con Go 1.22, tu código sigue teniendo la semántica de Go 1.18.
Go es un lenguaje muy extraño en ciertos aspectos.
Es un lenguaje con opiniones muy fuertes y, al mismo tiempo, parece un lenguaje sin suficiente opinión.
No estoy del todo seguro de cuál es la diferencia entre el código que recorre
c.informerMapy el que recorrealarms, pero si tuviera que adivinar, en un caso la variable del loop es un puntero y en el otro es un valor.Como la llamada al método usa un receptor por puntero, ¿será que, cuando es un valor, el compilador inserta automáticamente una referencia al receptor?
https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
La diferencia es que, en uno,
informeres una interfaz, así que la llamada al método se resuelve directamente comoinformer.Runy no hay problema.En el otro,
aes una estructuraAlarmy se copia por valor, mientras que el métodoMonitorrecibe un receptor por puntero.Por eso el compilador, en la práctica, convierte
go a.Monitor(b)engo (&a).Monitor(b), y eso crea una referencia a la variable del loop, causando el problema.En el segundo caso, supongo que ocurre el problema original descrito en el artículo porque
atermina teniendo solo el valor del último elemento dealarms.Mi conocimiento interno llega hasta ahí, pero los slices tienen un arreglo subyacente en el heap, así que hay punteros o referencias involucrados en cierta medida.
Leer esto me deja muy tranquilo.
Se está corrigiendo uno de los mayores defectos de Go.
Si escribes
foo, err := getFoo(); if err != nil ...y luegobar, err := getBar(); fmt.Println(bar), se te puede pasar verificar el error degetBar.Por las reglas de alcance, el patrón
if foo, err := getFoo(); err != nilse vuelve inmanejable apenas la anidación crece un poco.Además introduce estados inválidos. Cuando
getFoodevuelve un error, ¿qué debería devolver? Terminas dudando entre cambiar la API para que devuelva un puntero y así poder devolvernil, o dejar un objeto parcialmente creado en un estado inválido.