- O Go 1.21 amplia a compatibilidade baseada em GODEBUG para que a nova toolchain implemente, de forma tão estável quanto possível, até o comportamento de versões antigas do Go, com foco em reduzir o peso das atualizações
- Desde o Go 1, em 2012, o Go promete compatibilidade de código-fonte e tem reduzido quebras causadas por remoções e alterações por meio de verificações de APIs públicas e testes internos em larga escala
- Mesmo melhorias permitidas pela documentação, como maior precisão em
time.Now, mudanças na implementação de sort, alterações na saída de compress/flate, ampliação das entradas aceitas por strconv.ParseInt e mudanças no parsing de net.ParseIP, podem quebrar programas existentes
- A partir do Go 1.21, as configurações GODEBUG para compatibilidade são mantidas por no mínimo 2 anos ou 4 releases do Go, e o comportamento anterior pode ser preservado como padrão conforme a versão
go no go.mod
- O Go 2 não virá como uma nova especificação que quebre programas Go 1; mesmo ao adicionar novos recursos, o Go prioriza a compatibilidade para manter estáveis as atualizações da toolchain
Princípios básicos da compatibilidade do Go 1
- Em 2012, no Go 1, o Go estabeleceu no documento “Go 1 and the Future of Go Programs” a meta de que programas escritos de acordo com a especificação do Go 1 continuassem compilando e executando corretamente sem alterações durante a vida útil dessa especificação
- O centro dessa promessa é a compatibilidade de código-fonte
- Ao atualizar para uma nova versão do Go, o código precisa ser recompilado
- Novas APIs podem ser adicionadas, mas deve-se evitar adicioná-las de uma forma que quebre código existente
- Não é possível garantir que nenhuma mudança futura jamais quebrará qualquer programa
- Se um programa depende de um comportamento com bug, ele pode quebrar quando esse bug for corrigido
- O Go tenta manter atualizações estáveis reduzindo quebras tanto quanto possível
Prevenção de quebras de compatibilidade com verificações de APIs públicas
- No processo de desenvolvimento do Go, a lista de APIs públicas de cada pacote é mantida em arquivos separados dos pacotes reais
- Por exemplo,
go/api/go1.21.txt registra entradas de funções, métodos e tipos de bytes, cmp, context e outros
- Os testes padrão verificam se as APIs reais dos pacotes correspondem a esses arquivos
- Ao adicionar uma nova API, ela também precisa ser adicionada ao arquivo de API para que os testes passem
- Alterar ou remover uma API existente também faz os testes falharem
- Não apenas a remoção de APIs, mas também mudanças de tipo podem quebrar a compatibilidade
os.Stdout é uma variável global do tipo *os.File
- Se ela fosse trocada por uma interface com os mesmos métodos, código que exige
*os.File, como greet(f *os.File), quebraria
- A verificação de APIs é útil para capturar alterações e remoções de APIs, mas não impede todos os tipos possíveis de mudanças incompatíveis em Go
Quebras sutis reveladas por testes
- Versões de desenvolvimento de novos releases do Go são testadas continuamente contra todo o código Go interno do Google
- Quando os testes passam, o commit é instalado como a toolchain Go de produção do Google
- Quando testes internos quebram, considera-se que código externo também pode quebrar, e busca-se uma forma de reduzir o impacto
- Na maioria dos casos, a mudança é revertida ou reescrita para não quebrar programas
- Algumas mudanças, mesmo quebrando programas, podem permanecer por serem importantes e compatíveis de acordo com a documentação
- Mesmo nesses casos, o escopo do impacto é reduzido e possíveis problemas são registrados nas notas de release
Dois casos observados no Go 1.1
-
Literais de struct e novos campos
- No Go 1,
net.TCPAddr era uma struct com dois campos, IP e Port, e literais compostos sem nomes de campo também compilavam
- Quando o campo
Zone foi adicionado a net.TCPAddr no Go 1.1, código existente passou a não compilar, com o erro “too few initializers in struct literal”
- A forma compatível de escrever é usar literais com tags
var myAddr = &net.TCPAddr{
IP: net.IPv4(18, 26, 4, 9),
Port: 80,
}
- Se
Zone não for especificado, o campo usa seu zero value, uma string vazia
- A documentação de compatibilidade inclui a exigência de usar literais compostos com tags para structs da biblioteca padrão, e o
go vet reporta literais sem tags quando elas são necessárias para compatibilidade com versões futuras
-
Precisão de tempo
- Depois do Go 1,
time.Now mudou para retornar precisão de nanossegundos em vez de microssegundos
- Essa mudança podia quebrar testes que esperavam igualdade após fazer um valor de
time.Now ir e voltar por save e load
- Se a representação de armazenamento preservasse apenas precisão de microssegundos, o teste passaria no Go 1, mas poderia falhar no Go 1.1
- Para ajudar a corrigir esses testes, o Go adicionou os métodos
Round e Truncate e documentou nas notas de release o possível problema e os novos métodos
- A precisão aprimorada era um comportamento melhor e permitido dentro do escopo documentado da função, por isso foi lançada mesmo quebrando alguns programas
Três tipos de mudanças que podem quebrar compatibilidade
-
Mudanças de saída
- Mudanças de saída ocorrem quando uma função produz uma saída diferente da anterior, mas a nova saída é tão correta quanto a anterior ou mais correta
- A adição de precisão de nanossegundos em
time.Now é um exemplo representativo
- No Go 1.6, a implementação de
sort foi alterada para ficar cerca de 10% mais rápida, e a ordem de elementos considerados iguais mudou
- Saída do Go 1.5:
[red blue green white black yellow orange indigo violet]
- Saída do Go 1.6:
[red blue white green black orange yellow indigo violet]
- A ordenação pode retornar resultados equivalentes em qualquer ordem, mas programas que esperavam uma ordem específica quebraram
- No Go 1.8,
compress/flate foi aprimorado para gerar uma saída menor com overhead parecido de CPU e memória
- Com isso, builds de arquivos reproduzíveis dentro do Google deixaram de conseguir reproduzir exatamente os arquivos antigos
- Esse projeto fez um fork de
compress/flate e compress/gzip para manter o algoritmo anterior
- Para se preparar para mudanças de saída, é melhor escrever programas e testes de modo que aceitem todas as saídas válidas
- Se uma saída verdadeiramente reproduzível for necessária, é possível fazer um fork do código, mas isso também afasta o projeto das correções de bugs
-
Mudanças de entrada
- Mudanças de entrada ocorrem quando uma função muda as entradas que aceita ou a forma como as processa
- O Go 1.13 adicionou a sintaxe com underscores para melhorar a legibilidade de números, e
strconv.ParseInt também mudou para aceitar essa nova sintaxe
- Isso quebrou o código de um usuário externo que usava números separados por underscores como um formato de dados distinto
- Esse código primeiro tentava
ParseInt e só fazia o tratamento dos underscores quando a chamada falhava, mas ParseInt deixou de falhar
net.ParseIP aceitava endereços IP decimais com zeros à esquerda, seguindo exemplos dos RFCs iniciais de IP
- O Go lia
18.032.4.011 como 18.32.4.11
- Bibliotecas C da família BSD interpretam zeros à esquerda como início de octal e leem a mesma string como
18.26.4.9
- O Go 1.17 mudou
net.ParseIP para rejeitar completamente zeros à esquerda
- Foi uma escolha para garantir que, quando tanto Go quanto C conseguissem fazer o parsing de um endereço IP, o significado fosse o mesmo
- O Kubernetes se preocupou com a possibilidade de configurações armazenadas existentes não serem parseadas no Go 1.17 e passou a usar um fork do
net.ParseIP original
- Para entradas de usuário, é melhor validar primeiro a sintaxe a ser aceita antes de parsear o valor, mas em alguns casos pode ser necessário fazer um fork do código
-
Mudanças de protocolo
- Mudanças de protocolo ocorrem quando uma alteração em um pacote aparece de forma visível no protocolo usado para se comunicar com o mundo externo
- O Go 1.6 adicionou suporte automático a HTTP/2
- Um cliente Go 1.5 usa apenas HTTP/1.1, então pode funcionar corretamente em certos ambientes com equipamentos intermediários de rede
- Ao atualizar para o Go 1.6, HTTP/2 passa a ser usado e, se HTTP/2 não funcionar naquele ambiente, o programa pode quebrar
- O Go tenta dar suporte por padrão a protocolos modernos, mas a ativação de HTTP/2 pode quebrar programas mesmo sem erro do programa ou do próprio Go
- O Go 1.6 documentou a mudança nas notas de release e forneceu formas de desativar HTTP/2
- Definir explicitamente o campo
TLSNextProto
- Definir
GODEBUG=http2client=0, GODEBUG=http2server=0 ou ambos
- O suporte a certificados HTTPS baseados em SHA1 é um caso ainda mais sutil de mudança de protocolo
- Autoridades certificadoras pararam de emitir certificados SHA1 em 2015, e os principais navegadores deixaram de aceitá-los em 2017
- O Go 1.18 desativou por padrão o suporte a certificados SHA1 e permitiu contornar isso com GODEBUG
- Como algumas instalações do Kubernetes continuavam usando certificados SHA1 privados, o Go decidiu manter a configuração de contorno por mais tempo do que o planejado
Suporte ampliado a GODEBUG no Go 1.21
- O Go 1.21 amplia e formaliza o uso de GODEBUG para reduzir até problemas sutis de compatibilidade
- Para mudanças permitidas pelas regras de compatibilidade do Go 1, mas que podem quebrar programas existentes, é definida uma configuração GODEBUG que permite a cada programa recusar o novo comportamento
- Pode haver casos em que não seja possível adicionar uma configuração, mas eles são tratados como muito raros
- Configurações GODEBUG para compatibilidade são mantidas por no mínimo 2 anos, ou seja, 4 releases do Go
- Configurações como
http2client e http2server podem ser mantidas por muito mais tempo e, em alguns casos, indefinidamente
- Quando possível, cada configuração GODEBUG é ligada a um contador de
runtime/metrics
- O nome do contador segue o formato
/godebug/non-default-behavior/<name>:events
- Por exemplo, quando
GODEBUG=http2client=0 é definido, /godebug/non-default-behavior/http2client:events conta o número de transports HTTP configurados sem HTTP/2
- Os valores padrão de GODEBUG de um programa são alinhados à versão do Go escrita no
go.mod do pacote principal
- Se o
go.mod contém go 1.20 e você atualiza para a toolchain Go 1.21, comportamentos controlados por GODEBUG que mudaram no Go 1.21 mantêm o comportamento do Go 1.20 até que o go.mod seja alterado para go 1.21
- Configurações GODEBUG individuais podem ser alteradas com uma linha
//go:debug em package main
- Todas as configurações GODEBUG estão organizadas em uma lista central
O caso panic(nil)
- No Go 1.21,
panic(nil) agora causa um panic de runtime que não é nil
- Com essa mudança, o resultado de
recover pode indicar de forma confiável se a goroutine atual está em panic
- O novo comportamento é controlado por uma configuração GODEBUG e varia conforme a linha
go no go.mod do pacote principal
- Com
go 1.20 ou anterior, panic(nil) continua sendo permitido
- Com
go 1.21 ou posterior, panic(nil) vira um panic com runtime.PanicNilError
- O padrão baseado em versão pode ser sobrescrito explicitamente adicionando a seguinte linha a
package main
//go:debug panicnil=1
- Essa combinação permite atualizar para uma nova toolchain preservando o comportamento da toolchain anterior, controlar com granularidade apenas as configurações necessárias e identificar, com monitoramento em produção, o uso de comportamento não padrão
- Mais detalhes estão documentados em “Go, Backwards Compatibility, and GODEBUG”
Go 2 não quebrará o Go 1
- O documento “Go 1 and the Future of Go Programs” incluía a ressalva de que algum dia poderia surgir uma especificação Go 2
- Não haverá um Go 2 no sentido de não compilar mais programas Go 1
- O Go 2 no sentido de uma grande revisão do Go 1, iniciado em 2017, já aconteceu
- O Go considera a compatibilidade muito mais valiosa do que romper com o passado e escolheu o caminho de reforçá-la ainda mais
- Trabalhos novos e empolgantes continuarão no futuro, mas serão conduzidos de forma cuidadosa e compatível para que as atualizações entre toolchains sejam tão estáveis quanto possível
1 comentários
Opiniões do Hacker News
A pergunta importante em compatibilidade não é “se vamos fazer”, mas “como vamos fazer”. Na verdade, mais do que compatibilidade com o passado, é algo mais próximo de querer que meu código simplesmente continue funcionando daqui para a frente
O Go 1.21 oferece duas coisas essenciais que são difíceis de ver ao mesmo tempo em outros ecossistemas de linguagens: cada mudança tem uma configuração GODEBUG, pode ser revertida individualmente, e há também métricas para detectar se a implementação anterior está sendo usada. Além disso, há uma versão da toolchain por módulo, e toolchains do Go mais antigas e mais novas podem ser buscadas automaticamente com segurança, como módulos
Como bônus, ao especificar uma versão específica, como
go 1.21.2, mesmo ao executar em um Go mais novo, as configurações relevantes de opt-out são aplicadas automaticamente até que o novo comportamento seja solicitado explicitamente. É uma abordagem simples e bonita, que pode ser declarada no código, nogo.modou em variáveis de ambiente, cobrindo quase todos os casos de uso de compatibilidade, de desenvolvedores a distribuidoresuse v5.24no começo do arquivo faz com que ele se comporte como Perl 5.24, e isso se aplica por arquivo, não por móduloSó decidir se uma nova versão é ou não suportada já cria um problema absurdamente complexo, e surge um vale profundo em que “achamos que damos suporte à nova versão e a nova versão acha que dá suporte a nós, mas os dois lados deixam passar coisas”
Gosto muito dessa direção. Não há nada melhor do que entrar em uma base de código Go e poder esperar que só aumentar a versão do Go faça tudo continuar funcionando bem
Ainda assim, me preocupa que seja difícil melhorar muito o sistema de tipos sem mudanças incompatíveis do tipo “isso era código errado, então agora não compila mais”. Não sei se a equipe do Go tem interesse nisso, mas há muitos frutos baixos que poderiam aumentar bastante a robustez em tempo de compilação sem adicionar recursos à linguagem
Por exemplo, relatos de
nilnão verificado, verificação de acesso a arrays, inferência de tipos em literais de structs aninhadas e checagem exaustiva de enums. Em especial, ao escrever chamadas gRPC aninhadas, bastaria um nível de inferência de tipos; o tipo já está na assinatura da função chamada, então isso é doloroso demais em GoSe o sistema de tipos do Go for melhorado, eu gostaria que esses tipos de dados algébricos simples e bonitos fossem adicionados. Eles combinariam muito bem com Go, então espero que a linguagem se dê esse presente
Concordo totalmente. O motivo de eu ficar ansioso pelos releases do Go é que muitas vezes eles acrescentam coisas úteis sem quebrar nada
Nenhuma parte da linguagem parece tão quebrada que não possa ser corrigida com uma mudança compatível com versões anteriores. Até as poucas armadilhas, como a atribuição de variáveis de loop, em geral têm propostas que preservam a compatibilidade
Pessoalmente, concordo com a visão de esperar pelos releases do Go, mas é interessante a diferença de atitude entre os dois ecossistemas
Já organizei algumas das quebras que enfrentei no passado: https://news.ycombinator.com/item?id=29763324
Com Rust ou gcc, você atualiza o compilador e ganha recursos da linguagem, mas a maior parte das bibliotecas complexas permanece igual e pode ser atualizada separadamente. Por exemplo, HTTP em Rust fica em
hyper, e em C emlibcurl, separadosJá no Go, ao atualizar o compilador, além de genéricos,
embede recursos da toolchain, vêm junto mudanças emtls, bibliotecas de segurança e cliente/servidor HTTP. Grandes blocos da biblioteca padrão comohttp,tlsecryptoteriam sido muito melhores se fossem bibliotecas separadas. Assim, seria possível atualizar o compilador sem medo e atualizar as bibliotecas no meu próprio ritmoasync, e na edição 2015 era possível criar variáveis ou funções chamadasasync, então foi uma mudança incompatívelCompatibilidade com versões anteriores é boa, mas é difícil ter certeza de que será aceitável um futuro em que Go não consiga fazer alguma inovação X porque isso quebraria a compatibilidade
Apoio muito a posição de que um futuro Go 2 jamais deve quebrar a compatibilidade com Go 1. Se for preciso mudar a linguagem de forma tão grande, acho melhor simplesmente fazer um fork e mudar o nome
Mas fico me perguntando por que não ir além e dizer “não haverá Go 2”, eliminando a ambiguidade. Se um Go 2 teórico roda todos os programas Go 1, em que ele seria diferente de uma versão Go 1.xx? O texto diz que “não haverá um Go 2 que quebre programas Go 1”, mas parece não ir além disso
https://github.com/golang/proposal/blob/d661ed19a203000b7c54...
Explicavam que, se o processo acima funcionasse como planejado, em um sentido importante não haveria Go 2, e a transição ocorreria lentamente por meio de novos recursos da linguagem e das bibliotecas. Em algum momento, por razões de marketing, poderiam chamar isso de “agora é Go 2”, mas também poderiam simplesmente pular esse passo. A lógica é: se não houve C 2.0, por que Go 2.0 seria necessário?
Linguagens populares como C, C++ e Java também são, na prática, sempre versões 1.N, e acho melhor Go seguir esse caminho. Um Go 2 de verdade, no sentido de uma nova linguagem ou biblioteca central incompatível, não é uma boa opção para os usuários e pode ser prejudicial
No fim, acho que esse modelo faz sentido
Se houver um novo recurso de linguagem que a comunidade claramente quer, e a maneira correta — ou a única maneira — de adicioná-lo for uma nova palavra-chave, então, por causa da regra de jamais quebrar a compatibilidade de código-fonte, esse recurso nunca poderá ser acrescentado. Concordo no caso de grandes mudanças que alteram a semântica da linguagem, mas mudanças incompatíveis com versões anteriores nem sempre são enormes
Em teoria, a ideia de que a compatibilidade retroativa nunca muda é boa, mas, no mundo real, também existem breaking changes significativos. Não necessariamente parece vantajoso carregar para sempre um recurso de linguagem cujo design inicial não considerou X ou Y
Uso bastante Go, e essa direção realmente aquece o coração
Compatibilidade pode não ser muito divertida do ponto de vista da equipe da linguagem, porque é preciso manter sempre um pé firmemente no “passado distante”. Mas, para quem precisa manter grandes sistemas em Go, isso é um presente enorme
Se não dava para encaixar corretamente, então ainda não era a coisa certa e tentávamos de novo; se ainda assim não encaixasse, provavelmente não entendíamos o problema o suficiente, então o certo era deixá-lo amadurecer por mais tempo
Foi muito bom trabalhar com pessoas que pensavam com cuidado e se esforçavam para que as ideias certas entrassem. Sou grato ao Russ por ter feito o papel de BDFL, e a Ian, Rob e Rob por ter trabalhado com eles; isso me tornou um engenheiro muito melhor
Texto relacionado: Forward Compatibility and Toolchain Management in Go 1.21 - https://news.ycombinator.com/item?id=37122932
A frase “o chato é bom. O chato é estável. Chato significa que você pode se concentrar no seu trabalho sem se preocupar com o que mudou em Go” realmente me marcou
No meu trabalho principal uso NodeJS e o ecossistema JS em geral, e a dor é enorme. O ecossistema é fragmentado e todo mundo faz as coisas do seu próprio jeito, o que dificulta criar algo estável. Ainda assim, continuo gostando desse trabalho, mas seria ótimo se o ecossistema JS tivesse uma base moderna e estável em que se pudesse confiar e da qual se pudesse depender
Muitas ferramentas e APIs são escritas em Go, e o fato de não exigir um runtime separado no sistema de destino é frequentemente apontado como uma grande vantagem. A linguagem também parece simples de aprender e usar, o suporte no VSC e o GoLand são bons, e reclamações comuns como o tratamento de erros não parecem defeitos decisivos
Fico curioso sobre o que ainda falta ao Go para se tornar mainstream no desenvolvimento pelas próximas décadas, ou pelo menos ocupar uma grande parte do mercado de trabalho. Em alguns lugares, ele ainda é visto como uma linguagem de nicho
O problema é fazer todo mundo subir até essa linha de base. Quando chegarmos lá, acho que tudo ficará muito melhor
Além disso, o ecossistema JS inclui até trabalho de UI de front-end, e os campos de aplicação são tantos que é inevitável surgirem várias implementações. Na verdade, isso pode até ser desejável
Migrar parte do código de Python para Golang ajudou muito na escalabilidade. Fico realmente feliz em ver que Go continuará mantendo sua declaração central de compatibilidade retroativa
Falar de parsing de IP, hein. Sempre fico curioso sobre como o
inet_ntoaoriginal do BSD foi escritoComo
atoi/atolesscanfcom%d/%usempre fazem parsing exatamente como inteiros decimais, para produzir esse efeito estranho teria sido preciso usar%iou umstrtoucom base 0inet_atondiz que veio do 4.3BSD: https://github.com/dank101/4.3BSD-Reno/blob/master/lib/libc/...O
inet_addrdo 4.2BSD, ainda anterior, também usa a mesma lógica: https://github.com/dank101/4.2BSD/blob/master/lib/libc/inet/...inet_atoneinet_addrfazem o parsing do endereço da forma mais natural. Usar algo comostrtoul, ou especialmentesscanf, teria ficado estranho. A beleza dos ponteiros em C está em tornar tarefas simples de parsing muito fáceis, talvez até fáceis demais/etc/hostspreenchendo cada octeto com zeros à esquerdaNo fim, tive que voltar e remover os zeros
Como designer de linguagens, respeito as escolhas feitas aqui, incluindo a decisão de não criar um verdadeiro Go 2
Também pretendo aproveitar essas técnicas para garantir compatibilidade