O nome não garante segurança. Haskell e segurança de tipo

Os desenvolvedores de Haskell falam muito sobre segurança de tipos. A comunidade de desenvolvimento de Haskell defende a ideia de "descrever um invariante no nível do sistema de tipos" e "excluir estados inválidos". Parece um objetivo inspirador! No entanto, não está totalmente claro como alcançá-lo. Quase um ano atrás, publiquei um artigo "Analisar, não validar" - o primeiro passo para preencher essa lacuna.



O artigo foi seguido por discussões produtivas, mas nunca fomos capazes de chegar a um consenso sobre o uso correto da construção newtype em Haskell. A ideia é bastante simples: a palavra-chave newtype declara um tipo de invólucro que tem um nome diferente, mas é representativamente equivalente ao tipo que envolve. À primeira vista, essa é uma maneira compreensível de obter segurança de tipo. Por exemplo, considere como usar uma declaração newtype para definir o tipo de um endereço de e-mail:



newtype EmailAddress = EmailAddress Text
      
      





Esse truque nos fornece algum significado e, quando combinado com um construtor inteligente e limite de encapsulamento, pode até fornecer segurança. Mas este é um tipo completamente diferente de segurança. É muito mais fraco e diferente do que identifiquei há um ano. Por si só, newtype é apenas um apelido.



Os nomes não são de segurança de tipo ©



Segurança interna e externa



Para mostrar a diferença entre a modelagem construtiva de dados (mais sobre isso no artigo anterior ) e os wrappers newtype, vamos ver um exemplo. Suponha que queremos o tipo "inteiro de 1 a 5 inclusive". Uma abordagem natural para a modelagem construtiva é a enumeração com cinco casos:



data OneToFive
  = One
  | Two
  | Three
  | Four
  | Five
      
      





Em seguida, escreveríamos várias funções para converter entre Int e o tipo OneToFive:



toOneToFive :: Int -> Maybe OneToFive
toOneToFive 1 = Just One
toOneToFive 2 = Just Two
toOneToFive 3 = Just Three
toOneToFive 4 = Just Four
toOneToFive 5 = Just Five
toOneToFive _ = Nothing

fromOneToFive :: OneToFive -> Int
fromOneToFive One   = 1
fromOneToFive Two   = 2
fromOneToFive Three = 3
fromOneToFive Four  = 4
fromOneToFive Five  = 5
      
      





Isso seria o suficiente para atingir o objetivo declarado, mas na realidade é inconveniente trabalhar com essa tecnologia. Visto que inventamos um tipo completamente novo, não podemos reutilizar as funções numéricas usuais fornecidas por Haskell. Portanto, muitos desenvolvedores preferem usar o wrapper newtype:



newtype OneToFive = OneToFive Int
      
      





Como no primeiro caso, podemos declarar funções toOneToFive e fromOneToFive com tipos idênticos:



toOneToFive :: Int -> Maybe OneToFive
toOneToFive n
  | n >= 1 && n <= 5 = Just $ OneToFive n
  | otherwise        = Nothing

fromOneToFive :: OneToFive -> Int
fromOneToFive (OneToFive n) = n
      
      





Se colocarmos essas declarações em um módulo separado e escolhermos não exportar o construtor OneToFive, as APIs serão completamente intercambiáveis. Parece que a opção newtype é mais simples e segura. No entanto, isso não é bem verdade.



Vamos imaginar que estamos escrevendo uma função que recebe o valor OneToFive como argumento. Na modelagem construtiva, tal função requer correspondência de padrões com cada um dos cinco construtores. O GHC aceitará a definição como suficiente:



ordinal :: OneToFive -> Text
ordinal One   = "first"
ordinal Two   = "second"
ordinal Three = "third"
ordinal Four  = "fourth"
ordinal Five  = "fifth"
      
      





A exibição do novo tipo é diferente. Newtype é opaco, então a única maneira de observá-lo é convertendo de volta para Int. Obviamente, Int pode conter muitos outros valores além de 1-5, então temos que adicionar um padrão para o restante dos valores possíveis.



ordinal :: OneToFive -> Text
ordinal n = case fromOneToFive n of
  1 -> "first"
  2 -> "second"
  3 -> "third"
  4 -> "fourth"
  5 -> "fifth"
  _ -> error "impossible: bad OneToFive value"
      
      





Neste exemplo fictício, você pode não ver o problema. Mas, no entanto, demonstra uma diferença fundamental nas garantias fornecidas pelas duas abordagens descritas:



  • Um tipo de dado construtivo fixa seus invariantes de tal forma que eles estão disponíveis para interação posterior. Isso libera a função ordinal de manipular valores inválidos, uma vez que eles não podem mais ser expressos.
  • O wrapper newtype fornece um construtor inteligente que valida o valor, mas o resultado booleano dessa validação é usado apenas para o fluxo de controle; não é salvo como resultado da função. Consequentemente, não podemos usar o resultado desta verificação e as restrições introduzidas; durante a execução subsequente, interagimos com o tipo Int.


Verificar a integridade pode parecer uma etapa desnecessária, mas não é: a exploração de bugs apontou vulnerabilidades em nosso sistema de tipos. Se tivéssemos que adicionar outro construtor ao tipo de dados OneToFive, a versão do ordinal que consome o tipo de dados construtivo seria imediatamente não exaustiva no momento da compilação. Nesse ínterim, outra versão que usa o wrapper newtype continuaria compilando, mas quebraria no tempo de execução e iria para um cenário impossível.



Tudo isso é consequência do fato de que a modelagem construtiva é inerentemente segura para o tipo; ou seja, as propriedades de segurança são fornecidas pela declaração de tipo. Valores inválidos são realmente impossíveis de representar: você não pode exibir 6 usando qualquer um dos 5 construtores.



Isso não se aplica à declaração newtype, uma vez que não tem diferença semântica intrínseca de Int; seu valor é especificado externamente por meio do construtor toOneToFive inteligente. Qualquer diferença semântica implícita no newtype é invisível para o sistema de tipos. O desenvolvedor apenas mantém isso em mente.



Revisitando listas não vazias



O tipo de dados OneToFive foi inventado, mas considerações semelhantes se aplicam a outros cenários mais realistas. Considere o NonEmpty sobre o qual escrevi anteriormente:



data NonEmpty a = a :| [a]
      
      





Para maior clareza, vamos imaginar a versão de NonEmpty, declarada via knowntype, em comparação com listas regulares. Podemos usar a estratégia usual do construtor inteligente para fornecer a propriedade de não-vazio desejada:



newtype NonEmpty a = NonEmpty [a]

nonEmpty :: [a] -> Maybe (NonEmpty a)
nonEmpty [] = Nothing
nonEmpty xs = Just $ NonEmpty xs

instance Foldable NonEmpty where
  toList (NonEmpty xs) = xs
      
      





Tal como acontece com OneToFive, descobriremos rapidamente as consequências de não sermos capazes de armazenar essas informações no sistema de tipos. Queríamos usar NonEmpty para escrever uma versão segura do head, mas a versão newtype requer uma declaração diferente:



head :: NonEmpty a -> a
head xs = case toList xs of
  x:_ -> x
  []  -> error "impossible: empty NonEmpty value"
      
      





Não parece importar: a probabilidade de que tal situação possa ocorrer é muito improvável. Mas tal argumento depende inteiramente de acreditar na correção do módulo que define o NonEmpty, enquanto a definição construtiva requer apenas confiar na verificação de tipo do GHC. Como assumimos por padrão que a verificação de tipo funciona corretamente, a última é uma evidência mais convincente.



Newtypes as tokens



Se você adora newtypes, este tópico pode ser frustrante. Não quero dizer que newtypes sejam melhores do que comentários, embora os últimos sejam eficazes para verificação de tipo. Felizmente, a situação não é tão ruim: newtypes podem fornecer segurança mais fraca.



Os limites de abstração fornecem aos novos tipos uma grande vantagem de segurança. Se o construtor newtype não for exportado, ele se tornará opaco para outros módulos. Um módulo que define um novo tipo (ou seja, um "módulo inicial") pode tirar vantagem disso para criar um limite de confiança onde invariantes internos são impostos pela restrição de clientes a uma API segura.



Podemos usar o exemplo NonEmpty acima para ilustrar essa tecnologia. Por enquanto, vamos abster-nos de exportar o construtor NonEmpty e fornecer as operações inicial e final. Acreditamos que eles estão funcionando corretamente:



module Data.List.NonEmpty.Newtype
  ( NonEmpty
  , cons
  , nonEmpty
  , head
  , tail
  ) where

newtype NonEmpty a = NonEmpty [a]

cons :: a -> [a] -> NonEmpty a
cons x xs = NonEmpty (x:xs)

nonEmpty :: [a] -> Maybe (NonEmpty a)
nonEmpty [] = Nothing
nonEmpty xs = Just $ NonEmpty xs

head :: NonEmpty a -> a
head (NonEmpty (x:_)) = x
head (NonEmpty [])    = error "impossible: empty NonEmpty value"

tail :: NonEmpty a -> [a]
tail (NonEmpty (_:xs)) = xs
tail (NonEmpty [])     = error "impossible: empty NonEmpty value"
      
      





Como a única maneira de criar ou usar valores NonEmpty é usar funções na API Data.List.NonEmpty exportada, a implementação acima impede que os clientes violem a invariante de não vazio. Os valores de newtypes opacos são como tokens: o módulo de implementação emite tokens por meio de suas funções de construtor, e esses tokens não têm significado interno. A única maneira de fazer algo útil com eles é disponibilizá-los para funções no módulo que os usa e recuperar os valores que contêm. Nesse caso, essas funções são cabeça e cauda.



Essa abordagem é menos eficiente do que usar um tipo de dados construtivo porque pode estar errado e, acidentalmente, fornecer um meio de criar um valor NonEmpty [] inválido. Por esta razão, a abordagem do novo tipo para segurança de tipo não é em si mesma prova de que o invariante desejado é válido.



No entanto, esta abordagem limita a área onde a violação invariável para o módulo de definição pode ocorrer. Para ter certeza de que o invariante realmente se mantém, é necessário testar a API do módulo usando técnicas de difusão ou testes baseados em propriedades.



Este compromisso pode ser extremamente útil. É difícil garantir invariantes usando modelagem construtiva de dados, por isso nem sempre é prático. No entanto, precisamos ter cuidado para não fornecer acidentalmente um mecanismo para quebrar o invariante. Por exemplo, um desenvolvedor pode tirar proveito da typeclass de conveniência GHC que deriva da typeclass genérica para NonEmpty:



{-# LANGUAGE DeriveGeneric #-}

import GHC.Generics (Generic)

newtype NonEmpty a = NonEmpty [a]
  deriving (Generic)
      
      





Apenas uma linha fornece um mecanismo simples para atravessar o limite de abstração:



ghci> GHC.Generics.to @(NonEmpty ()) (M1 $ M1 $ M1 $ K1 [])
NonEmpty []
      
      





Este exemplo não é possível na prática, pois as instâncias genéricas derivadas quebram fundamentalmente a abstração. Além disso, esse problema pode surgir em outras condições menos óbvias. Por exemplo, com uma instância Read derivada:



ghci> read @(NonEmpty ()) "NonEmpty []"
NonEmpty []
      
      





Para alguns leitores, essas armadilhas podem parecer comuns, mas tais vulnerabilidades são muito comuns. Especialmente para tipos de dados com invariantes mais complexos, pois às vezes é difícil determinar se eles são suportados por uma implementação de módulo. O uso adequado deste método requer cuidado e atenção:



  • Todas as invariantes devem ser claras para os mantenedores do módulo confiável. Para tipos simples como NonEmpty, o invariante é óbvio, mas para tipos mais complexos, comentários são necessários.
  • Cada mudança em um módulo confiável precisa ser verificada, pois pode enfraquecer as invariantes desejadas.
  • Você deve evitar adicionar brechas inseguras que possam comprometer invariantes se mal utilizadas.
  • A refatoração periódica pode ser necessária para manter pequena a área confiável. Do contrário, com o tempo, a probabilidade de interação aumentará drasticamente, o que causa violação do invariante.


Ao mesmo tempo, os tipos de dados que são corretos por sua construção não apresentam nenhum dos problemas acima. O invariante não pode ser violado sem alterar a definição do tipo de dados, isso afeta o resto do programa. Nenhum esforço do desenvolvedor é necessário porque a verificação de tipo aplica invariantes automaticamente. Não existe um "código confiável" para esses tipos de dados, uma vez que todas as partes do programa estão igualmente sujeitas às restrições impostas pelo tipo de dados.



Em bibliotecas, faz sentido usar um novo conceito de segurança (graças ao newtype) por meio do encapsulamento, já que as bibliotecas costumam fornecer blocos de construção usados ​​para criar estruturas de dados mais complexas. Essas bibliotecas geralmente recebem mais estudo e escrutínio do que o código do aplicativo, especialmente porque mudam com muito menos frequência.



No código do aplicativo, essas técnicas ainda são úteis, mas as alterações na base de código de produção ao longo do tempo enfraquecem os limites do encapsulamento, portanto, o design deve ser preferido quando possível.



Outros usos de newtype, abuso e mau uso



A seção anterior descreve os principais usos do newtype. No entanto, na prática, os novos tipos geralmente são usados ​​de maneira diferente da descrita acima. Algumas dessas aplicações são justificadas, por exemplo:



  • Em Haskell, a ideia de consistência de typeclass restringe cada tipo a uma instância de qualquer classe. Para tipos que permitem mais de uma instância útil, newtypes é a solução tradicional e pode ser usado com sucesso. Por exemplo, newtypes Sum e Product from Data.Monoid fornecem instâncias Monoid úteis para tipos numéricos.
  • Da mesma forma, newtypes podem ser usados ​​para injetar ou modificar parâmetros de tipo. Newtype Flip from Data.Bifunctor.Flip é um exemplo simples que troca os argumentos do Bifunctor para que a instância do Functor possa trabalhar com a ordem inversa dos argumentos:


newtype Flip p a b = Flip { runFlip :: p b a }
      
      





Novos tipos são necessários para esse tipo de manipulação porque Haskell ainda não oferece suporte a expressões lambda em nível de tipo.



  • Novos tipos transparentes podem ser usados ​​para evitar abusos quando um valor precisa ser passado entre partes remotas de um programa e não há razão para o código intermediário validar o valor. Por exemplo, um ByteString contendo uma chave secreta pode ser agrupado em um novo tipo (com a instância Show excluída) para evitar que o código seja acidentalmente registrado ou exposto de outra forma.


Todas essas práticas são boas, mas não têm nada a ver com a segurança de tipo. O último ponto é freqüentemente confundido com segurança e usa um sistema de tipos para ajudar a evitar erros lógicos. No entanto, seria errado argumentar que tal uso evita abusos; qualquer parte do programa pode verificar o valor a qualquer momento.



Muitas vezes, essa ilusão de segurança leva ao abuso flagrante do novo tipo. Por exemplo, aqui está uma definição de uma base de código com a qual trabalho pessoalmente:



newtype ArgumentName = ArgumentName { unArgumentName :: GraphQL.Name }
  deriving ( Show, Eq, FromJSON, ToJSON, FromJSONKey, ToJSONKey
           , Hashable, ToTxt, Lift, Generic, NFData, Cacheable )
      
      





Nesse caso, newtype é uma etapa inútil. Funcionalmente, é completamente intercambiável com o tipo Name, tanto que produz uma dúzia de classes de tipo! Sempre que newtype é usado, ele se expande imediatamente assim que é recuperado do registro de fechamento. Portanto, não há benefício em digitar segurança nesse caso. Além disso, não está claro por que designar newtype como ArgumentName, se o nome do campo já esclarece sua função.



Parece-me que esse uso de newtypes surge do desejo de usar o sistema de tipos como uma forma de taxonomia (classificação) do mundo. O nome do argumento é mais específico do que o nome genérico, portanto, é claro que deve ter seu próprio tipo. Esta declaração faz sentido, mas está errada: a taxonomia é útil para documentar uma área de interesse, mas não necessariamente útil para modelá-la. Ao programar, usamos tipos para diferentes fins:



  • Primeiramente, os tipos destacam as diferenças funcionais entre os valores. Um valor do tipo NonEmpty a é funcionalmente diferente de um valor do tipo [a] porque é fundamentalmente diferente na estrutura e permite operações adicionais. Nesse sentido, os tipos são estruturais; eles descrevem quais valores estão dentro da linguagem de programação.
  • -, , . Distance Duration, - , , .


Observe que esses dois objetivos são pragmáticos; eles entendem o sistema de tipos como uma ferramenta. Essa é uma atitude bastante natural, já que o sistema de tipos estáticos é literalmente uma ferramenta. No entanto, esse ponto de vista parece incomum para nós, embora o uso de tipos para classificar o mundo geralmente crie ruídos inúteis como ArgumentName.



Provavelmente não é muito prático quando o novo tipo é completamente transparente e empacotado e implantado de volta nele conforme desejado. Nesse caso específico, eu descartaria completamente a distinção e usaria Nome, mas em situações em que rótulos diferentes são claros, você sempre pode usar o tipo de alias:



type ArgumentName = GraphQL.Name
      
      





Esses novos tipos são conchas reais. Ignorar várias etapas não é seguro para o tipo. Acredite em mim, os desenvolvedores ficarão felizes sem pensar duas vezes.



Conclusão e leitura recomendada



Há muito tempo quero escrever um artigo sobre esse assunto. Esta é provavelmente uma dica muito incomum sobre novos tipos em Haskell. Decidi contar desta forma, porque eu mesmo ganho a vida com Haskell e constantemente enfrento problemas semelhantes na prática. Na verdade, a ideia principal é muito mais profunda.



Newtypes é um dos mecanismos para definir tipos de invólucro. Esse conceito existe em quase todos os idiomas, mesmo aqueles que usam digitação dinâmica. Se você não escreve Haskell, muito deste artigo provavelmente se aplica ao idioma de sua escolha. Podemos dizer que esta é a continuação de uma ideia que tentei transmitir de diferentes maneiras ao longo do ano passado: os sistemas de tipos são ferramentas. Precisamos estar mais conscientes e focados sobre o que os tipos realmente fornecem e como usá-los de forma eficaz.



A razão para escrever este artigo foi o artigo recentemente publicado Tagged is not a Newtype... Este é um ótimo post e eu compartilho totalmente a ideia principal. Mas achei que o autor perdeu a oportunidade de expressar um pensamento mais sério. Na verdade, Tagged é um novo tipo por definição, então o título do artigo está nos levando para o caminho errado. O verdadeiro problema é um pouco mais profundo.



Novos tipos são úteis quando aplicados com cuidado, mas a segurança não é sua propriedade padrão. Não acreditamos que o plástico com o qual é feito o cone de trânsito proporcione segurança viária por si só. É importante colocar o cone no contexto certo! Sem a mesma cláusula, newtypes é apenas um rótulo, uma forma de dar um nome.



E o nome não é seguro para tipo!



All Articles