1 pontos por GN⁺ 2023-09-20 | 1 comentários | Compartilhar no WhatsApp
  • 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

 
GN⁺ 2023-09-20
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, DOLIST e DO usam atribuição, não binding, ao atualizar a variável de iteração; portanto, se um lambda captura n, como no exemplo, todos os 10 closures são criados sobre o valor da mesma variável N

    • D também tem o mesmo problema: https://issues.dlang.org/show_bug.cgi?id=2043
      Se a captura é por referência, esse é, na verdade, o comportamento esperado
    • A especificação não declara se esses loops alteram o valor ou fazem rebinding, então, se você captura a variável, deve assumir que não há rebinding
      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

    • Python também recebeu várias vezes, ao longo dos anos, a mesma solicitação de recurso, mas a resposta sempre foi que “o ganho é pequeno e quebra código existente”: https://discuss.python.org/t/make-lambdas-proper-closures/10...
      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
    • jaredpar, da equipe de C#, fez o primeiro comentário na discussão do GitHub sobre esta proposta em Go: https://github.com/golang/go/discussions/56010
      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
    • Java também tinha esse problema com classes anônimas, e normalmente ele é resolvido introduzindo objetos de função
      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
    • JavaScript também tinha o mesmo problema e introduziu loops for(let)
    • Bem ao estilo Go: ignorar esse comportamento sem aprender com linguagens anteriores e depois tentar consertá-lo mais tarde
  • https://eli.thegreenplace.net/2019/go-internals-capturing-lo... parece explicar esse problema em mais detalhes

    • É interessante que o antigo truque i := i funcione por um motivo totalmente diferente do que eu imaginava
      No 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ópria
      Na 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 i fica no escopo do corpo do for, 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 heap
      Entendo 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?

    • Para garantir compatibilidade retroativa com o código existente, a nova semântica só se aplica a pacotes dentro de módulos que declaram go 1.22 ou superior no go.mod
      Também é possível decidir por arquivo usando a linha //go:build
    • Não sei por que estão dando downvote, mas, na prática, é sim uma mudança que quebra a promessa de compatibilidade do Go 1
      Essa 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
    • Durante a preparação do Go 1.21, eles analisaram um corpus muito grande de código Go para ver o que seria afetado, e disseram que o número era muito, muito pequeno
      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
    • A proposta original tratava com bastante detalhe da investigação dos casos de uso existentes dessa sintaxe
      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 comportamento
    • Bastante coisa
      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
      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]() imprime 2

    • É o comportamento correto
      Antes o Python era pior e compartilhava até o escopo fora da list comprehension
    • Esse comportamento acontece por causa do late binding dos closures em Python
      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 de x
      Quando funcs[0]() é chamado, x já foi definido como 2, o último valor do range
      Para obter o comportamento desejado, passe x como 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, v tem 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 que v fosse uma referência apontando para dentro do mapa
    Dei 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 v seja um valor copiado, e não uma referência

  • 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.22 ou 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?

    • Usaram um jeito meio engenhoso
      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 como go.mod:3: invalid go version '1.21.0': must match format 1.23
      Só que isso acontece apenas quando você cria o módulo com go mod init; se escrever manualmente go 1.21 no go.mod, ele faz o build sem reclamar
    • Curiosamente, no Go 1.21, quando um módulo declara uma versão mais alta do Go, o comportamento padrão é baixar uma toolchain mais nova e usá-la no lugar: https://go.dev/blog/toolchain
      É 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
    • Pelo que entendo, no Go 1.18 um módulo 1.22 ainda compila se vier como dependência, e, se depender desse recurso, pode produzir lógica incorreta
      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ó
    • Deve dar erro de compilação
      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.informerMap e o código que percorre alarms, mas, chutando, a variável de loop de um lado pode ser um ponteiro e a do outro pode ser um valor
    Como a chamada de método usa um receptor por ponteiro, talvez, no caso do valor, o compilador insira automaticamente uma referência ao receptor?

    • Encontrei, pela busca de código do GitHub, os originais que contêm esse trecho
      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 como informer.Run, sem problema
      No outro, a é uma struct Alarm e é copiado por valor, enquanto o método Monitor recebe um receptor por ponteiro
      Então o compilador, na prática, transforma go a.Monitor(b) em go (&a).Monitor(b), e isso cria uma referência à variável de loop, causando o problema
    • Ao iterar sobre um mapa em Go, os valores sempre são copiados, então o primeiro código parece se comportar como esperado
      No segundo, imagino que o problema original descrito no texto aconteça porque a acaba tendo apenas o valor do último elemento de alarms
    • Pelo nome, o de cima é um mapa e o de baixo é uma slice
      Meu 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
    • Com certeza parece haver algo no compilador que sabe capturar o valor
  • Ler isso me deixou muito aliviado
    Estão corrigindo uma das maiores falhas do Go

    • Não, a maior falha é o tratamento de erros
      Se você escreve foo, err := getFoo(); if err != nil ... e depois bar, err := getBar(); fmt.Println(bar), pode deixar passar a verificação de erro de getBar
      Por causa das regras de escopo, o padrão if foo, err := getFoo(); err != nil fica difícil de aguentar com apenas um pouco mais de aninhamento
      Além disso, introduz estados inválidos. Quando getFoo retorna um erro, o que ele deve retornar? Você fica em dúvida se muda a API para retornar ponteiro e devolver nil, ou se deixa um objeto parcialmente criado em um estado inválido
    • Na próxima, é só corrigir a verificação de nil em interfaces