1 pontos por GN⁺ 5 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • 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 RateLimiter tem um campo *redis.Client e verifica r.redis != nil dentro de Allow parece 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) quando client == 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)
  • Assim, o construtor de RateLimiter nem 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 NULL ou 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 == nil dentro de RateLimiter.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 Allow valida 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 ser http.StatusBadRequest e a execução deve retornar
  • Depois da validação, req passa a ser um valor válido, e então h.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 Allow final se concentra na lógica real, sem verificação de nil
    • userID := GetUserID(req)
    • Se userID == "", retorna false, nil
    • Caso contrário, chama r.checkLimit(ctx, userID)
  • A verificação de userID vazio 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

 
GN⁺ 5 시간 전
Opiniões no Lobste.rs
  • Peço novamente aos outros programadores Go: por favor, façam wrapping dos erros

    redisClient, err := NewRedisClient(addr)  
    if err != nil {  
      return nil, fmt.Errorf("Couldn't obtain new RedisClient: %w", err)  
    }  
    

    Conforme a pilha de chamadas vai sendo desenrolada, o contexto sobre o erro deve se acumular

    • Um exemplo mais idiomático ficaria assim
      redisClient, err := NewRedisClient(addr)  
      if err != nil {  
        return nil, fmt.Errorf("NewRedisClient: %w", err)  
      }  
      
      Depois, cada camada acrescenta apenas onde o erro ocorreu, enquanto o err mais interno informa o que aconteceu
    • Infelizmente, não existe um stack trace unificado e de facto padrão para erros
      Na prática, “wrapping” muitas vezes vira dar grep na string do erro, torcer para que ela seja única e forçar a criatividade para tornar essa string única
    • Há quem reclame que a pilha de erros fica longa demais, mas a maioria considera esse tipo de mensagem acionável e útil
      Antigamente, 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
    • Pelo que me lembro, a abordagem atual é usar errors.Join
  • Acho que Go cria dois problemas aqui

    1. Se Go tivesse nulabilidade (nullability) explícita, esse problema praticamente desapareceria
    2. Parece não haver uma forma de impedir a inicialização zero de tipos nomeáveis, então erros podem se infiltrar a qualquer momento
    • Sinto que esta frase do texto expõe bem o problema fundamental
      É a parte que diz: “como não dá para controlar o que será recebido, é razoável verificar se é nil nessa 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ência
      O 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áveis
    Acho que, em 2026, não deveríamos mais precisar lidar com esse tipo de problema

    • Olhando pelo lado oposto, a capacidade de expressar “ausente” ou “faltando” de forma concisa é muito útil, especialmente ao lidar com estruturas de dados arbitrárias como JSON
      Em uma linguagem, a sintaxe costuma ser uma parte menos interessante, mas escrever foo.bar.baz na sua linguagem de script favorita é muito mais fácil do que foo.unwrap().bar.unwrap().baz em Rust
      Digo 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 grandes
    • Se você não usa ponteiros, também não há null, viva… 😭
  • Entendo 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 como T*, mas T* não significa necessariamente Option<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++

    • A desreferenciação de nil em Go é melhor do que a desreferenciação de ponteiro null em C, porque gera um panic de forma determinística, mas ainda não é tão boa, pois o erro só aparece quando o ponteiro de fato é desreferenciado
      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 complexo
      Portanto, fazer assert logo dentro de NewRateLimiter traz um benefício claro. No código de exemplo, isso equivaleria a trocar
      if client == nil {  
          return nil, errors.New("redis client is nil")  
      }  
      
      por
      if client == nil {  
          panic("redis client is nil")  
      }  
      
      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 capturado
    • Verificações de null e assert são coisas completamente diferentes
      Assert 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 removida
      Dizer 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 nil aparecem 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 invariantes
    Em 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

    • Fazer assert de invariantes é ótimo quando se começa assim desde o início e se mantém isso continuamente
      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 de if (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 checagem err != nil, o código fica com a impressão de estar cheio desse padrão
    Considero 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 != nil são funcionalmente obrigatórias em praticamente todo lugar, e por isso os linters também passam a exigi-las

  • Quando 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?

    • Não sou fã de Go, mas esse enquadramento me incomoda
      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