El nombre no garantiza la seguridad. Haskell y seguridad de tipos

Los desarrolladores de Haskell hablan mucho sobre seguridad de tipos. La comunidad de desarrollo de Haskell aboga por la idea de "describir una invariante en el nivel del sistema de tipos" y "excluir estados inválidos". ¡Suena como un objetivo inspirador! Sin embargo, no está del todo claro cómo lograrlo. Hace casi un año, publiqué un artículo "Analizar, no validar" , el primer paso para llenar este vacío.



El artículo fue seguido de discusiones productivas, pero nunca pudimos llegar a un consenso sobre el uso correcto de la construcción newtype en Haskell. La idea es bastante simple: la palabra clave newtype declara un tipo de contenedor que tiene un nombre diferente pero es representativamente equivalente al tipo que envuelve. A primera vista, esta es una forma comprensible de lograr la seguridad de tipos. Por ejemplo, considere cómo usar una declaración newtype para definir el tipo de una dirección de correo electrónico:



newtype EmailAddress = EmailAddress Text
      
      





Este truco nos da algo de significado, y cuando se combina con un constructor inteligente y un límite de encapsulación, incluso puede brindar seguridad. Pero este es un tipo de seguridad completamente diferente. Es mucho más débil y diferente al que identifiqué hace un año. Por sí mismo, newtype es solo un alias.



Los nombres no son de seguridad de tipo ©



Seguridad interna y externa



Para mostrar la diferencia entre el modelado de datos constructivo (más sobre esto en el artículo anterior ) y los contenedores de tipo nuevo, veamos un ejemplo. Supongamos que queremos el tipo "entero de 1 a 5 inclusive". Un enfoque natural del modelado constructivo es la enumeración con cinco casos:



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





Luego escribiríamos varias funciones para convertir entre Int y el 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
      
      





Esto sería suficiente para lograr el objetivo establecido, pero en realidad es inconveniente trabajar con dicha tecnología. Dado que hemos inventado un tipo completamente nuevo, no podemos reutilizar las funciones numéricas habituales proporcionadas por Haskell. Por lo tanto, muchos desarrolladores preferirían usar el contenedor newtype en su lugar:



newtype OneToFive = OneToFive Int
      
      





Como en el primer caso, podemos declarar funciones toOneToFive y fromOneToFive con 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
      
      





Si colocamos estas declaraciones en un módulo separado y elegimos no exportar el constructor OneToFive, las API son completamente intercambiables. Parece que la opción newtype es más simple y segura para los tipos. Sin embargo, esto no es del todo cierto.



Imaginemos que estamos escribiendo una función que toma el valor OneToFive como argumento. En el modelado constructivo, dicha función requiere la coincidencia de patrones con cada uno de los cinco constructores. La GHC aceptará la definición como suficiente:



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





La pantalla de tipo nuevo es diferente. Newtype es opaco, por lo que la única forma de observarlo es volver a convertirlo a Int. Por supuesto, Int puede contener muchos otros valores además de 1-5, por lo que tenemos que agregar un patrón para el resto de valores posibles.



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





En este ejemplo ficticio, es posible que no vea el problema. Pero, no obstante, demuestra una diferencia clave en las garantías proporcionadas por los dos enfoques descritos:



  • Un tipo de datos constructivo fija sus invariantes de tal manera que estén disponibles para una mayor interacción. Esto libera a la función ordinal de manejar valores no válidos, ya que ya no se pueden expresar.
  • El contenedor newtype proporciona un constructor inteligente que valida el valor, pero el resultado booleano de esta validación solo se usa para controlar el flujo; no se guarda como resultado de la función. En consecuencia, no podemos usar más el resultado de esta verificación y las restricciones introducidas; durante la ejecución posterior, interactuamos con el tipo Int.


Verificar que esté completo puede parecer un paso innecesario, pero no lo es: la explotación de errores ha señalado vulnerabilidades en nuestro sistema de tipos. Si tuviéramos que agregar otro constructor al tipo de datos OneToFive, la versión del ordinal que consume el tipo de datos constructivo sería inmediatamente no exhaustiva en el momento de la compilación. Mientras tanto, otra versión que usa el contenedor newtype seguiría compilando, pero se rompería en tiempo de ejecución y pasaría a un escenario imposible.



Todo esto es una consecuencia del hecho de que el modelado constructivo es inherentemente seguro para los tipos; es decir, las propiedades de seguridad las proporciona la declaración de tipo. De hecho, los valores no válidos son imposibles de representar: no puede mostrar 6 utilizando ninguno de los 5 constructores.



Esto no se aplica a la declaración newtype, ya que no tiene una diferencia semántica intrínseca de Int; su valor se especifica externamente a través del ingenioso constructor toOneToFive. Cualquier diferencia semántica implícita en el nuevo tipo es invisible para el sistema de tipos. El desarrollador solo tiene esto en cuenta.



Revisando listas no vacías



Se inventa el tipo de datos OneToFive, pero se aplican consideraciones similares a otros escenarios más realistas. Considere el NonEmpty sobre el que escribí anteriormente:



data NonEmpty a = a :| [a]
      
      





Para mayor claridad, imaginemos la versión de NonEmpty, declarada a través de knowtype, en comparación con las listas normales. Podemos usar la estrategia de constructor inteligente habitual para proporcionar la propiedad de no vacío deseada:



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
      
      





Al igual que con OneToFive, descubriremos rápidamente las consecuencias de no poder almacenar esta información en el sistema de tipos. Queríamos usar NonEmpty para escribir una versión segura de head, pero la versión newtype requiere una declaración diferente:



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





No parece importar: la probabilidad de que ocurra una situación así es muy poco probable. Pero tal argumento depende completamente de creer en la exactitud del módulo que define NonEmpty, mientras que la definición constructiva solo requiere confiar en la verificación de tipo GHC. Dado que asumimos de forma predeterminada que la verificación de tipos funciona correctamente, esta última es una evidencia más convincente.



Nuevos tipos como tokens



Si te encantan los nuevos tipos, este tema puede ser frustrante. No quiero decir que los nuevos tipos sean mejores que los comentarios, aunque estos últimos son eficaces para la verificación de tipos. Afortunadamente, la situación no es tan mala: los nuevos tipos pueden proporcionar una seguridad más débil.



Los límites de abstracción dan a los nuevos tipos una gran ventaja de seguridad. Si el constructor newtype no se exporta, se vuelve opaco para otros módulos. Un módulo que define un nuevo tipo (es decir, un "módulo de inicio") puede aprovechar esto para crear un límite de confianza donde se imponen invariantes internos al restringir a los clientes a una API segura.



Podemos usar el ejemplo NonEmpty anterior para ilustrar esta tecnología. Por ahora, evitemos exportar el constructor NonEmpty y proporcionemos las operaciones iniciales y finales. Creemos que están funcionando correctamente:



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"
      
      





Debido a que la única forma de crear o usar valores NonEmpty es usar funciones en la API Data.List.NonEmpty exportada, la implementación anterior evita que los clientes violen el invariante de no vacío. Los valores de los nuevos tipos opacos son como tokens: el módulo de implementación emite tokens a través de sus funciones de constructor, y estos tokens no tienen un significado interno. La única forma de hacer algo útil con ellos es ponerlos a disposición de las funciones del módulo que los usa y recuperar los valores que contienen. En este caso, estas funciones son de cabeza y cola.



Este enfoque es menos eficaz que utilizar un tipo de datos constructivo porque podría ser incorrecto y proporcionar accidentalmente un medio para crear un valor NonEmpty [] no válido. Por esta razón, el enfoque de newtype para la seguridad de tipos no es en sí mismo una prueba de que se mantenga el invariante deseado.



Sin embargo, este enfoque limita el área donde puede ocurrir la violación invariante para el módulo de definición. Para asegurarse de que el invariante realmente se mantenga, es necesario probar la API del módulo utilizando técnicas de fuzzing o pruebas basadas en propiedades.



Este compromiso puede resultar de gran utilidad. Es difícil garantizar invariantes utilizando modelos de datos constructivos, por lo que no siempre es práctico. Sin embargo, debemos tener cuidado de no proporcionar accidentalmente un mecanismo para romper la invariante. Por ejemplo, un desarrollador puede aprovechar la clase de tipos de conveniencia de GHC que se deriva de la clase de tipos genérica para NonEmpty:



{-# LANGUAGE DeriveGeneric #-}

import GHC.Generics (Generic)

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





Solo una línea proporciona un mecanismo simple para atravesar el límite de abstracción:



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





Este ejemplo no es posible en la práctica, ya que las instancias genéricas derivadas rompen fundamentalmente la abstracción. Además, este problema puede surgir en otras condiciones menos obvias. Por ejemplo, con una instancia de lectura derivada:



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





Para algunos lectores, estas trampas pueden parecer un lugar común, pero estas vulnerabilidades son muy comunes. Especialmente para tipos de datos con invariantes más complejos, ya que a veces es difícil determinar si son compatibles con la implementación de un módulo. El uso adecuado de este método requiere cuidado y atención:



  • Todos los invariantes deben quedar claros para los mantenedores del módulo de confianza. Para tipos simples como NonEmpty, el invariante es obvio, pero para tipos más complejos, se necesitan comentarios.
  • Cada cambio en un módulo confiable debe verificarse, ya que puede debilitar las invariantes deseadas.
  • Debe abstenerse de agregar lagunas inseguras que podrían comprometer las invariantes si se usan incorrectamente.
  • Es posible que se requiera una refactorización periódica para mantener pequeña el área de confianza. De lo contrario, con el tiempo, la probabilidad de interacción aumentará drásticamente, lo que provoca la violación del invariante.


Al mismo tiempo, los tipos de datos que son correctos por su construcción no tienen ninguno de los problemas anteriores. El invariante no se puede violar sin cambiar la definición del tipo de datos, esto afecta al resto del programa. No se requiere ningún esfuerzo de desarrollador porque la verificación de tipos aplica invariantes automáticamente. No existe un "código de confianza" para estos tipos de datos, ya que todas las partes del programa están igualmente sujetas a las restricciones impuestas por el tipo de datos.



En las bibliotecas, tiene sentido utilizar un nuevo concepto de seguridad (gracias al nuevo tipo) a través del encapsulado, ya que las bibliotecas a menudo proporcionan bloques de construcción que se utilizan para crear estructuras de datos más complejas. Estas bibliotecas suelen recibir más estudio y escrutinio que el código de la aplicación, especialmente porque cambian con mucha menos frecuencia.



En el código de la aplicación, estas técnicas siguen siendo útiles, pero los cambios en la base del código de producción con el tiempo debilitan los límites de la encapsulación, por lo que el diseño debe preferirse cuando sea posible.



Otros usos de newtype, abuso y mal uso



La sección anterior describe los principales usos de newtype. Sin embargo, en la práctica, los nuevos tipos generalmente se usan de manera diferente a como describimos anteriormente. Algunas de estas aplicaciones están justificadas, por ejemplo:



  • En Haskell, la idea de coherencia entre clases de tipos restringe cada tipo a una instancia de cualquier clase. Para los tipos que permiten más de una instancia útil, newtypes es la solución tradicional y se puede utilizar con éxito. Por ejemplo, newtypes Sum y Product from Data.Monoid proporcionan instancias de Monoid útiles para tipos numéricos.
  • Asimismo, los nuevos tipos se pueden utilizar para inyectar o modificar parámetros de tipo. Newtype Flip de Data.Bifunctor.Flip es un ejemplo simple que intercambia los argumentos de Bifunctor para que la instancia de Functor pueda funcionar con el orden inverso de los argumentos:


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





Los nuevos tipos son necesarios para este tipo de manipulación porque Haskell aún no admite expresiones lambda a nivel de tipo.



  • Se pueden utilizar nuevos tipos transparentes para evitar abusos cuando es necesario pasar un valor entre partes remotas de un programa y no hay razón para que el código intermedio valide el valor. Por ejemplo, un ByteString que contiene una clave secreta se puede envolver en un nuevo tipo (con la instancia Show excluida) para evitar que el código se registre accidentalmente o se exponga de otra manera.


Todas estas prácticas son buenas, pero no tienen nada que ver con la seguridad de tipos. El último punto a menudo se confunde con la seguridad y utiliza un sistema de tipos para ayudar a evitar errores lógicos. Sin embargo, sería incorrecto argumentar que tal uso previene el abuso; cualquier parte del programa puede comprobar el valor en cualquier momento.



Con demasiada frecuencia, esta ilusión de seguridad conduce a un abuso flagrante del nuevo tipo. Por ejemplo, aquí hay una definición de un código base con el que trabajo personalmente:



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





En este caso, newtype es un paso inútil. Funcionalmente, es completamente intercambiable con el tipo de nombre, ¡tanto que produce una docena de clases de tipos! Dondequiera que se use newtype, se expande inmediatamente tan pronto como se recupera del registro de cierre. Por lo tanto, la seguridad de tipos no tiene ningún beneficio en este caso. Además, no está claro por qué designar newtype como ArgumentName, si el nombre del campo ya aclara su función.



Me parece que este uso de nuevos tipos surge del deseo de usar el sistema de tipos como una forma de taxonomía (clasificación) del mundo. El nombre del argumento es más específico que el nombre genérico, por lo que, por supuesto, debe tener su propio tipo. Esta afirmación tiene sentido, pero es incorrecta: la taxonomía es útil para documentar un área de interés, pero no necesariamente para modelarla. Al programar, usamos tipos para diferentes propósitos:



  • Principalmente, los tipos resaltan las diferencias funcionales entre valores. Un valor de tipo NonEmpty a es funcionalmente diferente de un valor de tipo [a] porque es fundamentalmente diferente en estructura y permite operaciones adicionales. En este sentido, los tipos son estructurales; describen qué valores hay dentro del lenguaje de programación.
  • -, , . Distance Duration, - , , .


Tenga en cuenta que ambos objetivos son pragmáticos; entienden el sistema de tipos como una herramienta. Esta es una actitud bastante natural, ya que el sistema de tipos estáticos es literalmente una herramienta. Sin embargo, este punto de vista nos parece inusual, aunque el uso de tipos para clasificar el mundo suele generar un ruido inútil como ArgumentName.



Probablemente no sea muy práctico cuando el nuevo tipo es completamente transparente y se envuelve y despliega de nuevo en él como se desea. En este caso particular, descartaría por completo la distinción y usaría Nombre, pero en situaciones en las que diferentes etiquetas son claras, siempre puede usar el tipo de alias:



type ArgumentName = GraphQL.Name
      
      





Estos nuevos tipos son conchas reales. Saltarse varios pasos no es seguro para los tipos. Créame, los desarrolladores saltan felizmente sin pensarlo dos veces.



Conclusión y lectura recomendada



Hace tiempo que quería escribir un artículo sobre este tema. Este es probablemente un consejo muy inusual sobre los nuevos tipos en Haskell. Decidí contarlo de esta manera, porque yo mismo me gano la vida con Haskell y constantemente enfrento problemas similares en la práctica. De hecho, la idea principal es mucho más profunda.



Newtypes es uno de los mecanismos para definir los tipos de envoltura. Este concepto existe en casi todos los idiomas, incluso en aquellos que utilizan la escritura dinámica. Si no escribe Haskell, es probable que gran parte de este artículo se aplique al idioma que elija. Podemos decir que esta es una continuación de una idea que he intentado transmitir de diferentes maneras durante el último año: los sistemas de tipos son herramientas. Necesitamos ser más conscientes y concentrarnos sobre qué tipos realmente proporcionan y cómo usarlos de manera efectiva.



La razón para escribir este artículo fue el artículo recientemente publicado Tagged is not a Newtype... Esta es una gran publicación y comparto totalmente la idea principal. Pero pensé que el autor perdió la oportunidad de expresar un pensamiento más serio. De hecho, Tagged es un nuevo tipo por definición, por lo que el título del artículo nos lleva por el camino equivocado. El verdadero problema es un poco más profundo.



Los nuevos tipos son útiles cuando se aplican con cuidado, pero la seguridad no es su propiedad predeterminada. No creemos que el plástico del que está hecho el cono de tráfico proporcione seguridad vial por sí solo. ¡Es importante colocar el cono en el contexto correcto! Sin la misma cláusula, newtypes es solo una etiqueta, una forma de dar un nombre.



¡Y el nombre no es de tipo seguro!



All Articles