3 pontos por GN⁺ 2024-07-23 | 1 comentários | Compartilhar no WhatsApp
  • Em design orientado por tipos, parsing — que preserva o resultado da verificação em um tipo mais preciso — aumenta mais a segurança do código posterior do que a validação, que apenas verifica a entrada e a descarta
  • Funções como head :: [a] -> a, que falham para algumas entradas, podem enfraquecer o tipo de retorno, mas isso faz com que quem chama continue carregando ramificações de falha desnecessárias
  • NonEmpty a preserva no tipo o estado de lista não vazia, reduzindo verificações duplicadas e tratamento de erros que “jamais deveriam acontecer”
  • Misturar validações temporárias por todo o código de processamento vira shotgun parsing, em que erros de entrada podem ser descobertos só depois de algumas mudanças de estado
  • Na prática, é preciso primeiro expressar na assinatura da função a representação de dados desejada e elevar os invariantes até a fronteira dos tipos com Map, tipos abstratos, construtores inteligentes etc.

O ponto de partida do design orientado por tipos

  • “Parse, don’t validate” é uma frase que resume o design orientado por tipos em três palavras
  • Um sistema de tipos estático revela antes de escrever o código a pergunta “é possível implementar esta função?”
  • No exemplo em Haskell, foo :: Integer -> Void não pode produzir um valor real porque Void não tem valores
  • head :: [a] -> a também não é definida para todas as entradas, já que pode receber uma lista vazia []
    • O GHC avisa que o pattern matching não trata []
    • É uma função parcial que não é definida para todas as entradas possíveis

Duas formas de transformar uma função parcial em total

  • Enfraquecer o tipo de retorno

    • Se mudar para head :: [a] -> Maybe a, a função pode retornar Nothing para uma lista vazia e se torna total
    • A implementação fica simples, mas quem chama sempre precisa tratar a possibilidade de Nothing
    • Aparece o exemplo em que, mesmo após ler a variável de ambiente CONFIG_DIRS e já verificar que a lista não está vazia, main ainda precisa tratar de novo o ramo Nothing do resultado de head
    • Verificações duplicadas deixam o código desorganizado e, em casos mais complexos, também podem acumular custo de desempenho
    • Mesmo que a verificação inicial seja removida, o erro “que jamais deveria acontecer” não aparece no tipo mais adiante
    • No fim, isso cria um buraco no sistema de tipos e faz a detecção de bugs depender de testes ou revisão manual
  • Fortalecer o tipo do argumento

    • Sem enfraquecer o tipo de retorno, é possível eliminar a chance de head ser chamada com uma lista vazia fortalecendo o tipo do argumento
    • NonEmpty a, de Data.List.NonEmpty, representa uma lista não vazia
    • A definição é data NonEmpty a = a :| [a]
    • O primeiro elemento a e o restante da lista [a] ficam separados, então mesmo que a cauda esteja vazia o primeiro elemento sempre existe
    • head :: NonEmpty a -> a pode ser implementada com um único pattern e se torna uma função total
    • Se o tipo de retorno mudar para algo como getConfigurationDirectories :: IO (NonEmpty FilePath), o fato de não estar vazia fica preservado no tipo
    • nonEmpty :: [a] -> Maybe (NonEmpty a) converte uma lista comum em NonEmpty
    • O tratamento de Nothing é feito uma única vez na fronteira de entrada
    • Em main, pode-se usar initializeCache (head configDirs) sem ramificações duplicadas
    • Se depois getConfigurationDirectories deixar de garantir que o resultado não está vazio, o tipo de retorno também precisará mudar, e main falhará na checagem de tipos

A diferença entre validação e parsing

  • validateNonEmpty :: [a] -> IO () e parseNonEmpty :: [a] -> IO (NonEmpty a) verificam se a lista está vazia e falham em caso positivo
  • A diferença está no tipo de retorno
    • validateNonEmpty retorna (), que não carrega informação nenhuma, então o resultado da verificação é descartado
    • parseNonEmpty retorna NonEmpty a, preservando no sistema de tipos o conhecimento obtido pela verificação
  • Um parser pode ser visto como uma função que consome uma entrada menos estruturada e produz uma saída mais estruturada
  • Nessa definição, parseNonEmpty é um parser simples que transforma uma lista em uma lista não vazia
  • O parsing permite concluir as verificações na fronteira entre o programa e o mundo externo, evitando repetir a mesma checagem depois

A fronteira de parsing no ecossistema Haskell

  • Aplicações Haskell usam vários tipos de parser nos pontos em que encontram o mundo externo
    • aeson: fornece o tipo Parser para fazer parsing de dados JSON em tipos de domínio
    • optparse-applicative: fornece combinadores de parser para argumentos de linha de comando
    • persistent, postgresql-simple: fornecem mecanismos para fazer parsing de valores vindos de armazenamentos de dados externos
    • servant: faz parsing de tipos de dados Haskell a partir de componentes de rota, parâmetros de consulta, headers HTTP etc.
  • O mundo externo não fala em tipos produto e tipos soma, mas em fluxos de bytes, então parsing é inevitável
  • Fazer o parsing antes de usar os dados ajuda a evitar vários tipos de bugs, alguns dos quais podem até virar vulnerabilidades de segurança
  • Para fazer parsing de tudo logo no início, às vezes é preciso interpretar valores muito antes do momento em que seriam usados de fato
  • Em um sistema de tipos estático, quando a lógica de parsing e a lógica de processamento saem de sincronia, o programa não compila

Os riscos de uma abordagem centrada em validação

  • Validações ad hoc podem levar ao shotgun parsing, termo usado no campo de language-theoretic security
  • No artigo de 2016 The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them, shotgun parsing é o antipadrão em que parsing e validação de entrada ficam espalhados e misturados ao código de processamento
  • Se a entrada não for toda parseada logo no começo, o programa pode processar parte de uma entrada válida e só descobrir depois um erro em outra parte
    • Nesse caso, é preciso desfazer mudanças de estado já executadas
    • Em alguns casos isso é possível com rollback, como em transações de SGBD relacional, mas em geral isso nem sempre é viável
  • Uma abordagem baseada em validação torna difícil ou impossível garantir que todas as verificações realmente terminaram no início
  • O parsing divide o programa em uma fase de parsing e uma fase de execução, restringindo à primeira fase as falhas causadas por entradas inválidas

Como aplicar isso na prática

  • O projeto parte de escrever primeiro na assinatura de tipo a representação de dados que a função deseja, e depois preencher a diferença em relação à representação atualmente disponível
  • Se uma função recebe uma lista [(k, v)] em que chaves duplicadas não deveriam ser permitidas, uma verificação separada como checkNoDuplicateKeys :: ... => [(k, v)] -> m () pode ser facilmente esquecida
  • Uma forma melhor é receber como argumento um Map, que estruturalmente não permite chaves duplicadas
    • O ponto de chamada pode falhar na checagem de tipos
    • Isso empurra para cima, pela cadeia de chamadas, a conversão da lista em Map
    • Quando se chega ao ponto em que o valor é criado, ou ao ponto em que duplicatas realmente precisam ser permitidas, entra uma verificação no formato [(k, v)] -> m (Map k v)
  • Nesse caso, a verificação não pode ser omitida, porque o resultado dela é necessário para a execução posterior
  • Dois princípios se repetem
    • usar estruturas de dados que impeçam representar estados impossíveis
    • empurrar o ônus da prova o máximo possível para cima, sem ir além do ponto em que isso é necessário

Diretrizes adicionais de design e limites

  • Deixe que os tipos de dados conduzam o código e evite a tentação de colocar um Bool em um registro apenas por causa da função que você está escrevendo agora
  • Funções que retornam m () merecem desconfiança
    • Elas podem ser necessárias quando só executam efeitos imperativos e não produzem resultado significativo
    • Mas, se o objetivo principal é apenas lançar erro, provavelmente existe uma forma melhor
  • Não é preciso ter medo de fazer parsing dos dados em várias etapas
    • Evitar shotgun parsing não significa que você nunca pode agir sobre os dados antes do parsing completo; significa que não deve agir sobre os dados de entrada antes de concluir o parsing da parte relevante
    • Também é possível usar parte da entrada para decidir como fazer parsing de outra parte
  • Representações de dados desnormalizadas devem ser evitadas, especialmente quando podem ser modificadas
    • Duplicar o mesmo dado em vários lugares facilita representar estados inconsistentes entre si
    • Se a desnormalização for realmente necessária, ela deve ficar escondida atrás de uma fronteira de abstração, deixando a responsabilidade de sincronização para um pequeno módulo confiável
  • Quando é difícil expressar certos invariantes apenas com as ferramentas do Haskell, newtype abstrato e construtores inteligentes permitem transformar validadores em parsers
  • Não é preciso introduzir singletons e refatorar a aplicação inteira só para eliminar todo error "impossible", mas nesses casos os invariantes devem ser tratados com cuidado, por exemplo deixando comentários

Leituras adicionais e cuidados práticos

  • Não é necessário ter PhD nem dominar as extensões mais recentes de linguagem do GHC para tirar bom proveito do sistema de tipos de Haskell
  • O ponto de partida está mais próximo do princípio simples “escreva funções totais”, mas aplicar isso em código real pode não ser fácil
  • Como a comunidade Haskell é pequena, padrões e técnicas de design às vezes ficam mais na tradição oral do que na documentação
  • Um material relacionado é Type Safety Back and Forth, de Matt Parson
  • Em um nível mais avançado, o artigo de 2018 de Matt Noonan, Ghosts of Departed Proofs, aborda técnicas para colocar invariantes mais complexos dentro do sistema de tipos
  • Em programas reais, pode ser difícil representar certos invariantes no sistema de tipos, e esses princípios se aproximam mais de um ideal a perseguir do que de exigências rígidas

1 comentários

 
GN⁺ 2024-07-23
Opiniões no Hacker News
  • Conselhos muito bons e um excelente texto. Há um motivo para ele voltar a aparecer neste site de vez em quando.
    Mesmo para quem não usa linguagens funcionais com tipagem estática, essa ideia transcende paradigmas. Dá para ver conceitos muito parecidos na literatura de orientação a objetos dos anos 80 e 90, por exemplo em Design by Contract; e provavelmente também em artigos, discussões e especificações ainda mais antigos.
    Vejo TypeScript sendo escrito com frequência de um jeito que vai estreitando tipos em tempo de execução. Design by Contract também deve ter influenciado o spec do Clojure, que é uma linguagem dinâmica.
    No fundo, é uma questão de suposições e garantias. Se você consegue verificar uma suposição e transformá-la em uma garantia, outras partes do programa não precisam verificar a mesma suposição de novo.
    Ao ler código, o que mais me confunde é ver propriedades já garantidas sendo checadas de novo em outro lugar. Isso torna o raciocínio e as melhorias mais difíceis.

    • Essa “propriedade já garantida” pode desaparecer em algum momento. Mais precisamente, o procedimento que implementa e executa essa garantia pode, por algum motivo, deixar de cumprir seu papel.
      Estatisticamente, isso acaba acontecendo, e então outros processos, scripts ou códigos que dependiam do procedimento de validação “original” ficam em uma situação bem complicada.
    • Em linguagens com um sistema de tipos forte, isso acaba sendo uma das vantagens práticas que dão liberdade à medida que o programa cresce e fica mais complexo.
      Mas é preciso realmente usar esse recurso. Por exemplo, ter classes UncheckedEmail, ValidEmail e VerifiedEmail, e obrigar que a passagem de uma etapa para a seguinte aconteça necessariamente pelo processo de validação do e-mail.
      Assim, não é preciso adivinhar se o endereço de e-mail não foi verificado, se é válido em termos de formato ou se já foi verificado; e também não é necessário um booleano como is_email_verified, que alguém pode esquecer de atualizar ou checar. Se você usar um valor errado no lugar errado, o verificador de tipos reclama, e as pessoas podem se concentrar no que importa.
    • Ao passar os olhos pelos comentários de versões antigas deste texto, parece que um dos maiores problemas dele é o título. O título funciona como uma âncora, e muita gente acaba rebatendo algo que não está no corpo do texto e que só é sugerido pelo título, fora de contexto.
      Por isso, algumas pessoas entendem como se o autor estivesse dizendo para não validar nada e apenas fazer parsing, mas o texto, na verdade, trata de onde validar os dados e o que fazer com o resultado. Não é um texto defendendo eliminar toda validação.
  • É um texto de 2019, mas ainda é um conselho bastante bom. Esse padrão também combina muito bem com C# moderno, e ainda economiza espaço porque dá para omitir a declaração explícita da variável.
    if(!Whatever.TryParse(input, out var output)) output = some-sane-default;
    Ou
    if(!Whatever.TryParse(input, out var output)) throw new ApplicationException($"Not a valid Thingy: {input}");
    Dica profissional: não faça o segundo em um driver em modo kernel.

    • Dica profissional: não faça nenhum dos dois. Especialmente não faça o primeiro, de jeito nenhum.
      Um tratamento explícito é sempre melhor do que um valor padrão implícito usado no lugar de um valor que você achava que estava correto, mas não estava.
      O que se deve fazer é interromper cedo e tratar como falha de parsing, e então definir de forma muito clara o processo e o protocolo para lidar com um arquivo que não pode ser carregado. Isso força você a se fazer as perguntas difíceis que as duas opções acima não respondem.
      O problema real no caso recente do driver em modo kernel da CrowdStrike que falhou ao fazer parsing de algum arquivo def/config foi que desenvolvedores, responsáveis pelo produto e analistas de negócio não perguntaram: “o que acontece se tentarmos carregar um arquivo inválido?”
    • Por que “bastante bom”, e não mais que isso? E o que isso tem a ver com o ano em que o texto foi publicado? Quer dizer que, se tivesse sido publicado antes de 2019, o conselho do texto teria mais autoridade?
    • Eu preferiria que o primeiro jeito não fosse usado. É preciso tratar o caso ruim. Recorrer a um “valor padrão razoável” deve ser algo extremamente raro.
      Tratamento explícito > tratamento implícito
    • if(!Whatever.TryParse(input, out var output)) output = some-sane-default;
      Eu detesto muito esse jeito. Acho que erros de entrada inválida devem ser tratados fora da função de parsing. Em F#, isso é fácil.
      type Whatever =
      static member create input =
      match input with
      | ValidWhatever x -> Some x
      | _ -> None
      match Whatever.create input with
      | Some x -> // processar os dados parseados
      | None -> // tratar o caso em que não foi possível fazer o parsing corretamente
      Ou também dá para tornar uma pipeline de operações encadeadas mais conveniente com Option.map/Option.bind.
      Assim, as instâncias só podem ser criadas por meio do método create, que faz o parsing da entrada.
      Na prática, porém, é bem provável que você queira usar result em vez de option, mas isso é uma questão secundária.
    • Quase nunca, talvez nunca mesmo, consigo imaginar uma situação em que eu gostaria de ver um código como if(!Whatever.TryParse(input, out var output)) output = some-sane-default;.
      Se a entrada não foi fornecida de jeito nenhum, ou seja, se o parâmetro é opcional, faz sentido usar um valor padrão razoável.
      Mas, se uma entrada incorreta foi fornecida, eu não gostaria que o código fingisse que está tudo bem.
      Se alguém entra em uma floricultura e pede café, a resposta correta não é entregar rosas. Se a pessoa tentar beber aquilo, vai rasgar toda a boca.
      Para esse conjunto de entradas, o método, módulo ou programa não tem uma saída definida. É preciso deixar isso claro, em vez de fazer silenciosamente algo errado ou ambíguo e rapidamente tornar o programa impossível de raciocinar. Em vez de deixar isso virar um bug de comportamento estranho alguns meses depois, é melhor fazer o problema aparecer claramente, com um stack trace que leve direto ao ponto do problema — isso também é melhor para você.
  • É um conselho para usar um sistema de tipos forte e tornar impossível representar estados de erro. É excelente para reduzir bugs no software como um todo
    Pensar mais profundamente no problema e fazer esse tipo de design exige mais tempo, mas, em muitos casos, esse tempo vale muito a pena

    • Eu diria, sem medo, que em linguagens com suporte a tipos de dados algébricos essa abordagem não leva mais tempo. Ela simplesmente acontece naturalmente
      Claro que, em linguagens que exigem muito procedimento consciente para modelar dados, como C++, Java, C#, Python, Go e JavaScript, leva mais tempo
  • “Agora tenho um slogan curto e forte que resume o que design orientado por tipos significa para mim, e o melhor é que são só três palavras: Parse, don’t validate.”
    Meu slogan está mais para sempre valide apenas em um único construtor. Pode ser uma função construtora, sem problema
    Assim, objetos inválidos nem sequer podem existir, e você sempre tem uma única fonte da verdade. Se quiser modificar o objeto, basta implementar isso chamando o mesmo construtor novamente para criar um novo estado

    • Não é a mesma coisa
      O ponto central é que, se você apenas valida, essa informação desaparece depois
      Por exemplo, validar que um certo int é positivo tem benefícios limitados. Se você não parsear esse valor como um inteiro positivo, essa informação não permanece no nível do tipo depois. O mesmo vale para arrays e listas não vazios: o consumidor posterior talvez precise verificar novamente se aquela lista realmente não está vazia
      Esse tipo de informação nem sempre pode ser codificado em um objeto ou construtor
  • Material relacionado: Making Impossible States Impossible, de Richard Feldman
    https://www.youtube.com/watch?v=IcgmSRJHu_8

  • Já houve boas discussões antes
    https://news.ycombinator.com/item?id=35053118
    https://news.ycombinator.com/item?id=21476261

  • Toda vez que esse assunto aparece, lembro da seção 5 de https://cr.yp.to/qmail/guarantee.html. Lá há frases como “não faça parsing” e “há dois tipos de interfaces de comando no mundo da computação: boas interfaces e interfaces de usuário”
    Se eu desse uma aula não sobre programação em pequena ou grande escala, mas sobre programação em escala média, gostaria de passar aos alunos um ensaio comparando e contrastando essas propostas. Há algo a aprender com cada uma delas, e talvez elas não sejam tão contraditórias quanto parecem à primeira vista

  • Lembro de um comentário que vi em meados dos anos 2000, durante a febre do XML. Dizia que o motivo de muitas organizações terem implementado linguagens específicas de domínio, incluindo linguagens de configuração, em XML provavelmente era que o XML fornecia um parser, e a maioria das organizações não queria escrever seu próprio parser
    Não sei por que as pessoas não queriam escrever parsers. Escrever parser não é tão difícil e é bem divertido

  • É um dos meus textos favoritos entre os que li na minha carreira. Vi muita gente ler só o título e presumir que parsing e validação são, somehow, mutuamente exclusivos, mas na prática não são. Parsing muitas vezes inclui validação
    Isso é abordado na seção “Use abstract datatypes to make validators ‘look like’ parsers” do texto
    Está no mesmo território da ideia de evitar obsessão por tipos primitivos