Verificações excessivas de ponteiro nil em Go
(konradreiche.com)- A verificação de nil em Go pode evitar pânicos, mas, se for repetida no lugar errado, o código deixa de explicar por si mesmo “o que pode ser nil”
- Verificar dependências obrigatórias, como um cliente Redis, dentro de métodos internos faz com que uma falha de criação seja tratada como se fosse um caminho normal de execução
- Filtrar nil apenas no construtor não basta; é preciso tratar a falha imediatamente no ponto de inicialização, como em
NewRedisClient(addr) - Valores vindos de fora, como objetos de requisição, devem ser validados na camada de fronteira, como handlers HTTP, dispatch de RPC ou consumidores de fila, e a lógica interna deve confiar nessa garantia
- Quando estados que deveriam ser impossíveis são silenciosamente permitidos, a falha se torna silenciosa, atrasada e ambígua, e depois surge o custo de reconstruir esse sinal perdido com métricas, dashboards e alertas
Verificação de nil nem sempre é programação defensiva
- Para evitar pânicos em produção, é preciso programação defensiva que valide entradas, intervalos e ponteiros antes mesmo de um
deferred recover - Verificações de nil no lugar certo ajudam a criar código seguro, mas verificações no lugar errado são um sinal de que não se consegue rastrear quais valores podem ser nil
- Esse padrão aparece com mais frequência em código gerado, mas não é um fenômeno novo nem algo limitado à IA
- Verificações de nil parecem baratas e seguras, mas deixam para a próxima pessoa que ler o código a mensagem “este valor pode ser nil”, e muitas vezes transmitem um significado errado
O problema de verificar nil em dependências
- Um código em que
RateLimitertem um campo*redis.Cliente verificar.redis != nildentro deAllowparece seguro à primeira vista - Se o cliente Redis for nil, o problema já aconteceu no momento da criação, e não na execução de
Allow - Ao verificar nil em um método interno, o código passa a tratar como aceitável um estado em que a criação falhou, mas a execução continua
- Esse tipo de verificação é um sinal de que o código perdeu a noção da origem do objeto, da responsabilidade pela inicialização e das invariantes segundo as quais nil não deveria ser possível
Verificar nil só no construtor não basta
- Retornar erro em
NewRateLimiter(client *redis.Client)quandoclient == nilé melhor, mas não é uma solução completa - O simples fato de um ponteiro nil ter chegado até essa função já significa que um estado inválido entrou no sistema
- O erro real deve ser tratado no ponto de inicialização em que o cliente Redis é criado
- Se
redisClient, err := NewRedisClient(addr)retornar erro, ele deve ser retornado imediatamente - Depois disso, apenas um cliente válido deve ser passado para
NewRateLimiter(redisClient)
- Se
- Assim, o construtor de
RateLimiternem precisa retornar erro - Se for necessário permitir que o repositório fique temporariamente indisponível, não se deve propagar nil; em vez disso, ele deve ser encapsulado em um tipo externo sempre non-nil, que trate internamente retry ou degradação de desempenho
- Isso é parecido com
NOT NULLou restrições de chave estrangeira em banco de dados- Se linhas inválidas não podem existir desde o início, cada query não precisa validar os dados de novo
- O mesmo vale para valores em runtime: depois que a invariante é estabelecida uma vez, o restante do código pode evitar verificações repetidas
O custo da falha silenciosa
- Pode parecer estável usar verificações de nil ou apenas logs para evitar interromper o programa por causa de uma pequena mudança
- Na prática, a escolha se parece menos com “crash ou continuar executando” e mais com falhar alto versus falhar em silêncio
- Um erro retornado explicitamente tem três propriedades
- Clareza: é possível saber que houve uma falha
- Imediatismo: a falha é percebida perto da causa
- Atribuição: quem chamou consegue ligar a falha à operação correspondente
- Um erro engolido faz o oposto
- A falha desaparece em silêncio
- Mais código continua executando e o problema só aparece depois como sintoma
- Quando o sintoma aparece, identificar a causa fica difícil
- Quanto mais chamadas sobrevivem em estado incorreto, maior fica a distância entre causa e sintoma
- A correção adequada não é esconder localmente a falha, mas entender para onde os erros se propagam e onde eles se transformam em rejeição da requisição, falha de job, retry, alerta ou encerramento
- Se retornar erro interrompe mais do que o sistema precisa, então o problema não está na função em si, mas na fronteira de tratamento de erros
O custo secundário de recriar um sinal perdido
- Quando a falha se torna silenciosa, bugs podem ficar escondidos porque já não é possível saber o que realmente aconteceu
- Então é preciso construir infraestrutura de observabilidade, como métricas, dashboards e alertas, para detectar a ausência de comportamento
- Sempre que se permite um estado impossível ou não tratado, paga-se um custo de engenharia para restaurar depois, via observabilidade, o sinal que foi descartado
O papel das camadas externas e internas
- O ponto em que a execução começa e os dados externos entram é a camada externa; o código mais profundo alcançado por essa chamada é a camada interna
- No início da execução, nada está garantido, mas também ainda nenhum trabalho foi feito
- Durante a inicialização, é preciso configurar os elementos dos quais o programa depende e decidir quais são obrigatórios e quais podem ficar temporariamente indisponíveis
- O design deve sempre pender para dependências sempre disponíveis e minimizar dependências que podem desaparecer no meio da execução
Dados no escopo da requisição devem ser validados na fronteira
- Objetos de requisição, campos da requisição e valores derivados dela são diferentes de dependências fixas
- Requisições entram de fora a cada chamada, via handlers HTTP, RPC, filas, helpers de teste ou outros pacotes
- Verificar
req == nildentro deRateLimiter.Allow(ctx, req)é o mesmo tipo de erro que verificar nil em dependências - A requisição não entrou pela primeira vez em
Allow; ela já entrou antes, na fronteira de transporte, e depois percorreu o código - Quando uma função interna como
Allowvalida isso de novo, ela revalida algo que deveria ser garantido pela camada externa, espalhando a incerteza
Depois da validação na fronteira, a lógica interna confia nas invariantes
- A verificação de nil deve existir no ponto de fronteira onde bytes não confiáveis são convertidos em tipos internos como
*Request - No exemplo de um handler HTTP, se
DecodeRequest(r)falhar, a resposta deve serhttp.StatusBadRequeste a execução deve retornar - Depois da validação,
reqpassa a ser um valor válido, e entãoh.limiter.Allow(r.Context(), req)pode confiar nele - Como dados externos não estão sob controle, faz sentido validar nil e outras restrições necessárias na fronteira
- Depois que os dados atravessam essa fronteira, eles são mapeados para tipos internos e lógica de negócio, e então se tornam invariantes do sistema
- O
Allowfinal se concentra na lógica real, sem verificação de niluserID := GetUserID(req)- Se
userID == "", retornafalse, nil - Caso contrário, chama
r.checkLimit(ctx, userID)
- A verificação de
userIDvazio também poderia subir para a camada HTTP, mas, no exemplo, a política continua pertencendo ao limitador de taxa
Verificações repetidas de nil criam novos ramos e novos comportamentos
- Um sistema estruturado assim é mais fácil de raciocinar e de alterar
- Já um sistema sem invariantes acaba acumulando verificações por toda parte, e cada uma exige decidir o que fazer
- Cada verificação de nil cria um novo ramo, e cada ramo passa a definir um comportamento para um estado que não deveria existir
- Verificações de nil são úteis quando reforçam fronteiras documentadas ou modelam estados opcionais deliberados
- Verificações de nil que tratam silenciosamente estados que o programa considera impossíveis devem gerar desconfiança
- Se verificações de nil aparecem por toda parte, então há um de dois casos
- Código normal protegendo entradas não confiáveis em uma fronteira
- Um problema de design em que o codebase não conseguiu estabelecer invariantes
- Em sistemas onde nenhum parâmetro é confiável, talvez seja necessário adicionar verificações imediatamente, mas o trabalho real é estabelecer as invariantes que essas verificações estão substituindo e transformá-las em garantias confiáveis
1 comentários
Opiniões no Lobste.rs
Peço novamente aos outros programadores Go: por favor, façam wrapping dos erros
Conforme a pilha de chamadas vai sendo desenrolada, o contexto sobre o erro deve se acumular
errmais interno informa o que aconteceuNa prática, “wrapping” muitas vezes vira dar
grepna string do erro, torcer para que ela seja única e forçar a criatividade para tornar essa string únicaAntigamente, em um produto de redes, um engenheiro passou um mês corrigindo centenas de mensagens de erro, porque imprimir “What the f-ck?” nos logs não ajudava o usuário final
Foi preciso transformar essas mensagens em algo útil e, pelos motivos acima, também adicionar a pilha de erros
Acho que Go cria dois problemas aqui
É a parte que diz: “como não dá para controlar o que será recebido, é razoável verificar se é
nilnessa fronteira”Isso faz sentido para entradas externas, mas, se todo ponteiro pode ser
nil, rastrear fronteiras seguras dentro da base de código exige inferênciaO problema de Go é que ele força essa inferência a acontecer na cabeça de todos os programadores, em vez de no compilador
Rust tem
Option<T>e C# tem tipos anuláveisAcho que, em 2026, não deveríamos mais precisar lidar com esse tipo de problema
Em uma linguagem, a sintaxe costuma ser uma parte menos interessante, mas escrever
foo.bar.bazna sua linguagem de script favorita é muito mais fácil do quefoo.unwrap().bar.unwrap().bazem RustDigo isso mesmo gostando de Rust; e, embora Go e Rust muitas vezes sejam colocadas no mesmo pacote, vejo Go muito mais como uma linguagem de script recriada por programadores C
Ainda assim, se a linguagem usa null, o melhor padrão é não anulável. Especialmente se houver uma sintaxe curta como
?ou.?, o custo sintático vale a pena em projetos grandesEntendo que Go não é uma linguagem que modele bem objetos não anuláveis
Nesse ponto, ela se parece com C, e
Option<T>pode ser representado comoT*, masT*não significa necessariamenteOption<T>No geral, concordo com o texto. Quando eu trabalhava em uma empresa de firmware embarcado, também tentei convencer as pessoas a usar assert no código C++ em vez de espalhar verificações de null por toda parte
Assert facilita a depuração, não aparece como ramificação do ponto de vista de cobertura e comunica claramente as condições esperadas ao leitor. Como é removido em builds de release, também é mais eficiente
No entanto, em Go, entendo que a desreferenciação de nil já fornece boas informações de depuração, então o benefício de assert não é tão grande quanto em C++
No exemplo do texto, ele estouraria bem dentro de
checkLimit, e aí seria preciso rastrear de volta a origem do nil. Dependendo do sistema ou da arquitetura, isso pode ser bastante complexoPortanto, fazer assert logo dentro de
NewRateLimitertraz um benefício claro. No código de exemplo, isso equivaleria a trocar por Ainda assim, a equipe de Go é fortemente contra assertions, e panic também não é ideal, pois derruba todo o runtime se não for capturadoAssert significa “este estado não é válido”, e uma macro de assert pode transformar essa verificação de null em uma não operação em builds de release
Dependendo de como a macro de assert é definida, otimizações relacionadas a comportamento indefinido podem acontecer, removendo verificações posteriores e levando a crashes confusos
Por exemplo, já vi uma definição de assert em que, em
assert(p); if (!p) { ... }, a verificação posterior era removidaDizer cegamente “não faça verificação de null, use assert” pode fazer sentido para invariantes de estado, mas não para verificação de erros
Há bons conselhos na conclusão
Se verificações de
nilaparecem por toda parte, é uma de duas coisas. Ou é código normal para se defender de entradas de fronteira não confiáveis, ou é um problema de design em que a base de código não conseguiu estabelecer invariantesEm um sistema em que nenhum parâmetro é confiável, a solução não é adicionar mais verificações. Talvez seja preciso fazer isso no curto prazo, mas o trabalho de verdade é estabelecer as invariantes que essas verificações estão substituindo e, aos poucos, transformar o ruído nascido do medo em garantias das quais o sistema possa depender
Acho que isso vai além de checagens de nil. Adicionar checagens ou código defensivo nas “folhas” do sistema muitas vezes aparece como uma forma de tratar o sintoma de invariantes insuficientes ou mal impostas
“Adicionar mais uma checagem” é fácil de adotar como padrão, mas tem limite de escala. Em algum momento, a lógica de checagem passa a ser maior que a lógica funcional, e a complexidade total cresce de forma incontrolável
Uma checagem extra para evitar um ou dois bugs geralmente não faz mal, mas quando se sente que a quantidade e a complexidade das checagens aumentaram demais, no longo prazo costuma ser melhor para o sistema e para a vida de quem o mantém dar um passo atrás e procurar a causa raiz, em vez de continuar consertando só as folhas
Mas treinar desenvolvedores para parar com a programação defensiva é um problema mais difícil
Invariantes desse tipo, aqui algo como não nulabilidade, podem ser modeladas muito melhor em sistemas de tipos mais expressivos que o de Go
Meu texto favorito sobre esse assunto é o post de Alexis King de 2019, Parse, don't validate
O princípio é aplicável em qualquer lugar, mas no sistema de tipos de Haskell isso parece realmente fácil. Tentei seguir o conselho da Alexis por anos em TypeScript, mas não foi fácil
Em resumo, o problema não é haver checagens demais, e sim embrulhar nil como valor
Esse problema já apareceu repetidas vezes, e vejo isso como resultado de uma linguagem em que tratamento de erros não é um recurso de primeira classe
Pelo que me lembro, como já apareceu em outros threads, os linters praticamente padrão acabam impondo essa estrutura
Não sei se essas checagens de nil são logicamente ruins. Muitas linguagens têm tratamento de erros embutido, e a diferença está mais na consistência e simplicidade da propagação
As opções para lidar com uma interface que retorna erro são basicamente quatro: tratar e recuperar, ignorar, propagar o erro, ou descartar o erro e propagar o seu próprio erro, sendo que a última opção também pode encapsular o erro existente
Linguagens em que tratamento de erros é um recurso de primeira classe geralmente tornam as opções 2 e 3 fáceis, e isso é ainda mais verdadeiro em linguagens modernas. Por isso, a opção 4 também pode ficar bastante limpa dependendo da linguagem
A opção 1 é algo em que o suporte de primeira classe não ajuda muito, além de deixar mais explícito que esse tratamento é necessário
Fundamentalmente, se uma função pode gerar erro, toda linguagem, independentemente de como implemente isso, está fazendo algo como
{error,result} = functioncall()seguido deif (error) { ... }Como Go não trata erros como recurso de primeira classe, muitas funções retornam preventivamente uma tupla
(result, err), e, com o linter praticamente forçando a checagemerr != nil, o código fica com a impressão de estar cheio desse padrãoConsidero uma falha de design da linguagem o fato de ela não lidar diretamente com tratamento correto de erros, mas, estando nessa posição, esse modelo provavelmente parece próximo do melhor possível
Não sei bem se código Go, de forma idiomática, usa tipos de retorno opcionais para distinguir erros funcionalmente ignoráveis de erros “com os quais é preciso se importar”. Mesmo nesses casos, se o idiomático for sempre retornar um tipo de erro, o linter provavelmente sempre vai impor esse padrão
Não é que eu odeie Go; só discordo de uma escolha de design. Dá para reclamar de escolhas de design em praticamente qualquer linguagem
Vejo o maior erro de Go no fato de que checagens explícitas
err != nilsão funcionalmente obrigatórias em praticamente todo lugar, e por isso os linters também passam a exigi-lasQuando Go surgiu, centenas de pessoas já apontaram o quanto toda essa estrutura era ridícula
Mas a linguagem ganhou muita popularidade, e as críticas foram ignoradas em meio ao clima de que Rob Pike sabia mais
É bom ver só agora as pessoas discutindo normalmente com argumentos lógicos
Não é como se isso não fosse conhecido há décadas como uma má ideia, mas, se o Google faz, deve ser bom… certo?
Porque, ao chamar de “bobagem ridícula”, fica fácil sufocar exatamente o pensamento lógico que se diz querer ver mais
Não lembro em qual podcast da Oxide foi, mas Bryan Cantrill disse algo como “quero estudar isso para odiar melhor”
Nesse sentido, quero entender por que as pessoas se empolgaram tanto com Go nos anos 2010. Parte disso certamente foi hype, e vi pessoalmente no trabalho, na época, desenvolvedores empolgados sem conseguir explicar por que era bom
Mas não deve ter sido só hype puro. Fico curioso para saber qual teria sido o argumento mais forte, em versão steel-man, a favor de usar Go naquela época