Parsing, não validação (2019)
(lexi-lambda.github.io)- 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 apreserva 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 -> Voidnão pode produzir um valor real porqueVoidnão tem valores head :: [a] -> atambé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
- O GHC avisa que o pattern matching não trata
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 retornarNothingpara 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_DIRSe já verificar que a lista não está vazia,mainainda precisa tratar de novo o ramoNothingdo resultado dehead - 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
- Se mudar para
-
Fortalecer o tipo do argumento
- Sem enfraquecer o tipo de retorno, é possível eliminar a chance de
headser chamada com uma lista vazia fortalecendo o tipo do argumento NonEmpty a, deData.List.NonEmpty, representa uma lista não vazia- A definição é
data NonEmpty a = a :| [a] - O primeiro elemento
ae o restante da lista[a]ficam separados, então mesmo que a cauda esteja vazia o primeiro elemento sempre existe head :: NonEmpty a -> apode 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 emNonEmpty- O tratamento de
Nothingé feito uma única vez na fronteira de entrada - Em
main, pode-se usarinitializeCache (head configDirs)sem ramificações duplicadas - Se depois
getConfigurationDirectoriesdeixar de garantir que o resultado não está vazio, o tipo de retorno também precisará mudar, emainfalhará na checagem de tipos
- Sem enfraquecer o tipo de retorno, é possível eliminar a chance de
A diferença entre validação e parsing
validateNonEmpty :: [a] -> IO ()eparseNonEmpty :: [a] -> IO (NonEmpty a)verificam se a lista está vazia e falham em caso positivo- A diferença está no tipo de retorno
validateNonEmptyretorna(), que não carrega informação nenhuma, então o resultado da verificação é descartadoparseNonEmptyretornaNonEmpty 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
Parserpara 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.
- aeson: fornece o tipo
- 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 comocheckNoDuplicateKeys :: ... => [(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
Boolem 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,
newtypeabstrato 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
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
specdo 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.
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.
Mas é preciso realmente usar esse recurso. Por exemplo, ter classes
UncheckedEmail,ValidEmaileVerifiedEmail, 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.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.
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?”
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| _ -> Nonematch Whatever.create input with| Some x -> // processar os dados parseados| None -> // tratar o caso em que não foi possível fazer o parsing corretamenteOu 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
resultem vez deoption, mas isso é uma questão secundária.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
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
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á vaziaEsse 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