- O Go 1.22 pretende reduzir um dos erros mais clássicos da linguagem ao mudar as variáveis de loop
for de escopo do loop inteiro para escopo por iteração, evitando que closures capturem incorretamente a mesma variável
- Na semântica antiga, mesmo sem goroutine, funções executadas após a iteração podem referenciar o mesmo
v ou i, vendo apenas o valor final ou fazendo um teste passar incorretamente
- Os analisadores
loopclosure do go vet e do gopls detectam apenas casos certos, gerando falsos negativos, enquanto verificadores mais agressivos podem aumentar código desnecessário como x:= x por causa de falsos positivos
- A nova semântica se aplica apenas a módulos que declaram
go 1.22 ou superior no go.mod, e no Go 1.21 é possível executar uma prévia com GOEXPERIMENT=loopvar
- Desde o início de maio de 2023, o Google forçou esse modo em todos os builds de sua toolchain interna de Go e, em 4 meses, não houve relatos de problemas em produção, embora testes escritos incorretamente tenham sido revelados
Armadilha de captura de variáveis no for antigo
- As variáveis do
for na semântica antiga do Go têm escopo do loop inteiro, então código que referencia essa variável depois do fim da iteração pode ver um valor diferente do pretendido
- Ao percorrer
values := []string{"a", "b", "c"} e criar três goroutines, cada goroutine imprime a mesma variável v, em vez do v de cada iteração
- O mesmo problema ocorre mesmo sem concorrência
- Se você guardar
func() { fmt.Println(i) } em um slice dentro do loop e executá-la depois, cada função referenciará o mesmo i, e não o valor de cada iteração
Falhas em produção e limites dos analisadores
- Esse erro já levou a problemas em produção em várias empresas, e a issue pública do Let’s Encrypt é um desses casos
- No caso do Let’s Encrypt, durante a iteração de um map,
k foi copiado com kCopy := k, mas modelToAuthzPB(&v) usava ponteiros para campos de v ao gerar o resultado, então v também precisava ser copiado separadamente
- Como a captura da variável se espalhava por várias funções, era difícil perceber o problema
- Ferramentas de análise estática têm dificuldade para determinar se uma variável sobrevive além da iteração, então precisam equilibrar falsos positivos e falsos negativos
- Os analisadores
loopclosure do go vet e do gopls relatam apenas problemas certos, aceitando deixar passar alguns casos
- Verificadores mais agressivos podem marcar como errado até código correto
- Ao examinar commits em código Go open source que adicionaram linhas como
x := x, havia uma mistura de correções reais de bug e mudanças desnecessárias
- Havia casos em que desenvolvedores adicionavam código desnecessário apenas para satisfazer o analisador
- Entre dois diffs como
informer := informer e a := a, apenas um corrigia um bug e o outro era desnecessário, mas sem informações de tipo e função é difícil distingui-los
Nova semântica de loop no Go 1.22
- No Go 1.22, as variáveis do loop
for passarão a ter escopo separado por iteração
- Os exemplos anteriores deixarão de ser programas Go com bug, e também diminuirá a necessidade de ferramentas de verificação imprecisas e os problemas de produção causados por esse tipo de erro
- Para manter compatibilidade retroativa, a nova semântica só se aplica aos pacotes de módulos que declaram
go 1.22 ou superior no go.mod
- Isso permite migrar gradualmente, sem precisar mudar toda a base de código de uma vez
- Também é possível controlar isso por arquivo com linhas
//go:build
- O código existente continuará com a semântica atual
- A mudança se aplica apenas a código novo ou atualizado
- O desenvolvedor pode controlar em que momento a semântica muda em cada pacote específico
Proteções em versões anteriores do Go
- De acordo com o trabalho de forward compatibility do Go, o Go 1.21 não compila código que declara
go 1.22 ou superior
- As point releases Go 1.20.8 e Go 1.19.13 também receberam um tratamento especial com o mesmo efeito
- Depois do lançamento do Go 1.22, código escrito dependendo da nova semântica não será compilado com a semântica antiga, a menos que se use uma versão do Go sem suporte e muito antiga
Testando a prévia no Go 1.21
- O Go 1.21 inclui uma prévia da mudança de escopo do loop
- Ao compilar com
GOEXPERIMENT=loopvar, a linha go do go.mod é ignorada e a nova semântica passa a valer para todos os loops
- Para verificar se um pacote e todas as suas dependências passam nos testes com a nova semântica de loop, execute o seguinte
GOEXPERIMENT=loopvar go test
- No Go Playground, é possível testar a nova semântica adicionando o comentário
// GOEXPERIMENT=loopvar no topo do programa
- A toolchain interna de Go do Google foi modificada para forçar esse modo em todos os builds desde o início de maio de 2023, e depois disso não houve relatos de problemas em código de produção durante 4 meses
Bugs de teste revelados pela nova semântica
- A nova semântica de loop não causou problemas em código de produção, mas revelou testes que estavam passando incorretamente
- No exemplo de subtestes com
t.Parallel, o Go 1.21 bloqueia cada subteste até o fim do loop inteiro e só então os executa em paralelo
- Quando o loop termina,
v é sempre 6, então todos os subtestes verificam se 6 é par e passam
- Como o conjunto real de teste inclui
1, o teste deveria falhar
- No Go 1.21, a precisão do analisador
loopclosure foi melhorada para identificar e relatar esse problema
- Exemplo de relatório no Go Playground: Exemplo de programa
- Se o
go vet relatar esse tipo de problema nos testes, corrigi-lo ajuda na preparação para o Go 1.22
- Ferramentas e exemplos para encontrar loops que fazem testes específicos falharem com a nova semântica estão reunidos no FAQ
Leitura adicional
1 comentários
Opiniões no Hacker News
Pode haver exemplos bem mais antigos, mas o alerta mais antigo sobre esse comportamento que encontrei com uma busca de 60 segundos foi o FAQ da comp.lang.lisp, publicado em 1992, há mais de 30 anos
Ele explica que
DOTIMES,DOLISTeDOusam atribuição, não binding, ao atualizar a variável de iteração; portanto, se umlambdacapturan, como no exemplo, todos os 10 closures são criados sobre o valor da mesma variávelNSe a captura é por referência, esse é, na verdade, o comportamento esperado
Ainda assim, depois que se aprende como funciona, deixa de ser um problema; e, se necessário, dá para escolher a forma e expandir a macro para verificar como ela é implementada
A equipe da linguagem C# também passou pelo mesmo problema depois de introduzir closures leves no C# 4.0, e logo ficou claro que isso era uma armadilha
Os usuários quase sempre usavam a variável do loop de forma errada, e no C# 5.0 foi feita uma mudança que quebrava compatibilidade
Eric Lippert escreveu um texto explicando bem o “porquê” sob essa perspectiva: https://ericlippert.com/2009/11/12/closing-over-the-loop-var...
O post original de anúncio do C# 5 foi difícil de encontrar; espero que não tenha desaparecido nas várias migrações de blogs em domínios da Microsoft depois de 2012
Considerando a confusão que a mudança do tipo de string já causou na transição do Python 2 para o 3, não parece provável que essa mudança entre antes do Python 4.0
E alguém provavelmente vai reclamar que Python é ruim por não corrigir esse tipo de coisa, e depois reclamar de novo do Python porque um script feito em 2003 deixou de funcionar
Acho que ele teve um papel importante em superar a barreira de “rejeitar por padrão” que propostas de mudança de linguagem basicamente precisam enfrentar
Outro ponto bastante convincente foi o resultado de varrer bases de código open source para analisar o equilíbrio entre bugs corrigidos e novos bugs introduzidos
Como a passagem é por valor, isso captura o estado da variável no momento da chamada e reduz a ambiguidade do código
Se você tenta capturar variáveis de um jeito estranho, por exemplo, variáveis declaradas e uma coleção acumulada para transformar um array em um map passam a se comportar de maneiras diferentes
Go parece estar tentando encontrar um equilíbrio ao aplicar esse comportamento apenas ao contador do loop, mas ainda assim algumas variáveis continuam se comportando de forma estranha
Tenho curiosidade, em especial, sobre o que acontece quando se definem várias variáveis de loop para escanear diretamente a entrada
for(let)https://eli.thegreenplace.net/2019/go-internals-capturing-lo... parece explicar esse problema em mais detalhes
i := ifuncione por um motivo totalmente diferente do que eu imaginavaNo começo, eu achava que, como o novo
ié passado para a goroutine, a análise de escape o marcaria como escapando para fora do escopo léxico; por isso ele seria alocado no heap e, a cada iteração, haveria uma alocação no heap, fazendo cada goroutine referenciar uma posição de memória própriaNa prática, o compilador Go tem heurísticas para escolher entre captura por referência e captura por valor, e uma das condições é capturar por valor valores que não são atualizados depois da inicialização
O novo
ifica no escopo do corpo dofor, e o loop em si não o atualiza; então ele é considerado um valor que não é atualizado após a inicialização, e o compilador gera código que o captura por valor, sem alocação no heapEntendo que a segunda abordagem é melhor, mas gostaria de ouvir de alguém que conheça Go a fundo por que a primeira não acontece junto
Essa mudança não vai quebrar programas que dependem do comportamento atual?
go 1.22ou superior nogo.modTambém é possível decidir por arquivo usando a linha
//go:buildEssa promessa diz que programas escritos conforme a especificação do Go 1 devem continuar compilando e executando corretamente, sem alterações, durante a vida útil da especificação; e que, embora uma especificação Go 2 possa surgir algum dia, até lá programas Go que funcionam hoje devem continuar funcionando também em point releases como Go 1.1 e Go 1.2
A impressão era que o número de pessoas que criaram bugs sem querer por causa desse design era muito maior do que o número de pessoas que seriam afetadas pela correção
Pelo que me lembro, dizia que havia pouquíssimos casos em que essa mudança quebraria o comportamento esperado no código-base do Google ou em código no GitHub
Só decidiram quebrar a compatibilidade retroativa depois de confirmar quão poucos codebases seriam afetados e de criar, por meio da declaração de versão no
go.mod, um mecanismo que exige alterar ativamente o código para usar o novo comportamentohttps://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
A maior parte não foi mencionada em nenhum momento no documento da proposta
Também já passei por esse problema em Python, embora não recentemente
Não tenho certeza se foi o Python que mudou ou se fui eu que passei a perceber o problema
O fato de ainda poder ser um problema em Python fica bem claro só com este código:
funcs = [(lambda: x) for x in range(3)];funcs[0]()imprime2Antes o Python era pior e compartilhava até o escopo fora da list comprehension
Ao usar uma lambda dentro de uma list comprehension ou de um loop, ela captura uma referência à variável
x, não o valor atual dexQuando
funcs[0]()é chamado,xjá foi definido como2, o último valor dorangePara obter o comportamento desejado, passe
xcomo argumento padrão da lambda:funcs = [(lambda x=x: x) for x in range(3)]Usei Go só um pouco e entendo o problema geral que essa mudança resolve, mas não compreendo muito bem os exemplos mais sutis, como o caso da letsencrypt ou
"range c.informerMap"versus"range alarms"Em
for k, v := range someMap,vtem o tipo do valor do mapa, e existe um único binding para o loop inteiro que é copiado a cada iteração? Se for isso, o problema fica explicado, mas eu esperaria quevfosse uma referência apontando para dentro do mapaDei uma olhada rápida em “For statements with range clause” na especificação e não encontrei a resposta; como quase não mexo com Go, provavelmente olhei no lugar errado: https://go.dev/ref/spec#For_statements
Edit: a resposta estava na tabela em formato de bloco de código. Acho que passei por ela como se fosse um banner. É surpreendente que
vseja um valor copiado, e não uma referênciaEle dá suporte a ponteiros para slots de arrays, mas
for rangecopia em vez de fornecer um ponteiro para cada slotvéintÉ um valor, não um ponteiro para
intSe tiver curiosidade, pode conferir: https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
Basicamente, por causa da desreferenciação automática, o compilador está transformando
go a.Monitor(b)em(&a).Monitor(b)Fiquei curioso sobre como funciona a parte que diz: “Como resultado do trabalho de compatibilidade futura, o Go 1.21 não tentará compilar código que declare
go 1.22ou superior. Também colocamos um tratamento especial com o mesmo efeito nos lançamentos pontuais Go 1.20.8 e Go 1.19.13, de modo que, quando o Go 1.22 for lançado, código escrito dependendo da nova semântica nunca será compilado com a semântica antiga, a menos que se use uma versão do Go muito antiga e sem suporte”Se algum pacote fixou a versão em 1.22 e eu compilar com 1.18, ele compila ou dá erro dizendo que precisa do compilador 1.22?
Como no Go 1.21 eles mudaram o formato do número de versão no arquivo
go.mod, ao tentar fazer build com Go 1.18 aparece um erro comogo.mod:3: invalid go version '1.21.0': must match format 1.23Só que isso acontece apenas quando você cria o módulo com
go mod init; se escrever manualmentego 1.21nogo.mod, ele faz o build sem reclamarÉ um recurso bem legal, mas é um comportamento surpreendente, e fico um pouco receoso pelo fato de ele se conectar a um servidor controlado pelo Google para baixar binários
Junto com o proxy de módulos, é um dos recursos do Go em relação aos quais tenho sentimentos mais ambivalentes, e eu me sentiria muito mais tranquilo se o Go fosse gerido por uma fundação na qual o Google tivesse apenas uma participação
Edição: pensando bem, isso se aplica quando o módulo atual declara outra versão, não quando uma dependência declara, então é diferente da pergunta original
Por isso, usar Go 1.18 passa a ser ativamente perigoso
No Go 1.19 deve dar erro de compilação
De todo modo, como o Go não aplica correções de bugs de segurança a releases antigos e à biblioteca padrão deles, acho que usar essas versões já é perigoso por si só
Mas, mesmo compilando com Go 1.22, seu código ainda terá a semântica do Go 1.18
Go é uma linguagem muito estranha em alguns aspectos
É uma linguagem com opiniões muito fortes e, ao mesmo tempo, parece uma linguagem sem opinião demais
Não tenho certeza de qual é a diferença entre o código que percorre
c.informerMape o código que percorrealarms, mas, chutando, a variável de loop de um lado pode ser um ponteiro e a do outro pode ser um valorComo a chamada de método usa um receptor por ponteiro, talvez, no caso do valor, o compilador insira automaticamente uma referência ao receptor?
https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
A diferença é que, em um caso,
informeré uma interface, então a chamada de método é resolvida imediatamente comoinformer.Run, sem problemaNo outro,
aé uma structAlarme é copiado por valor, enquanto o métodoMonitorrecebe um receptor por ponteiroEntão o compilador, na prática, transforma
go a.Monitor(b)emgo (&a).Monitor(b), e isso cria uma referência à variável de loop, causando o problemaNo segundo, imagino que o problema original descrito no texto aconteça porque
aacaba tendo apenas o valor do último elemento dealarmsMeu conhecimento interno vai só até aí, mas slices têm um array de apoio no heap, então ponteiros ou referências ficam envolvidos de alguma forma
Ler isso me deixou muito aliviado
Estão corrigindo uma das maiores falhas do Go
Se você escreve
foo, err := getFoo(); if err != nil ...e depoisbar, err := getBar(); fmt.Println(bar), pode deixar passar a verificação de erro degetBarPor causa das regras de escopo, o padrão
if foo, err := getFoo(); err != nilfica difícil de aguentar com apenas um pouco mais de aninhamentoAlém disso, introduz estados inválidos. Quando
getFooretorna um erro, o que ele deve retornar? Você fica em dúvida se muda a API para retornar ponteiro e devolvernil, ou se deixa um objeto parcialmente criado em um estado inválido