3 puntos por GN⁺ 2024-07-23 | 1 comentarios | Compartir por WhatsApp
  • En el diseño guiado por tipos, en lugar de validar una entrada solo para descartarla, parsear y conservar el resultado de esa comprobación como un tipo más preciso aumenta la seguridad del código posterior
  • Una función que falla con algunas entradas, como head:: [a] -> a, podría debilitar el tipo de retorno, pero eso obliga a quien la llama a cargar siempre con ramas de falla innecesarias
  • NonEmpty a conserva en el tipo el estado de que una lista no está vacía, reduciendo comprobaciones duplicadas y el manejo de errores que “nunca deberían ocurrir”
  • Si se mezclan validaciones ad hoc en distintas partes del código de procesamiento, se cae en shotgun parsing, donde un error de entrada puede descubrirse recién después de modificar parte del estado
  • En la práctica, conviene poner primero en la firma de la función la representación de datos deseada y elevar los invariantes al límite de tipos usando Map, tipos abstractos, constructores inteligentes, etc.

Punto de partida del diseño guiado por tipos

  • “Parse, don’t validate” es una frase que condensa el diseño guiado por tipos en tres palabras
  • Un sistema de tipos estático deja ver, antes de escribir el código, la pregunta “¿se puede escribir esta función?”
  • En el ejemplo de Haskell, foo :: Integer -> Void no puede producir un valor real porque Void no tiene valores
  • head :: [a] -> a tampoco está definida para todas las entradas, porque puede recibir una lista vacía []
    • GHC advierte que el pattern matching no maneja []
    • Es una función parcial, no definida para todas las entradas posibles

Dos formas de convertir una función parcial en una función total

  • Debilitar el tipo de retorno

    • Si se cambia a head :: [a] -> Maybe a, puede devolver Nothing ante una lista vacía, por lo que se vuelve una función total
    • La implementación se vuelve más fácil, pero quien la llama siempre debe manejar la posibilidad de Nothing
    • Se muestra un ejemplo en el que, incluso después de leer la variable de entorno CONFIG_DIRS y comprobar que la lista no está vacía, main debe volver a manejar la rama Nothing del resultado de head
    • Las comprobaciones duplicadas ensucian el código y, en casos complejos, también pueden acumular costos de rendimiento
    • Aunque se elimine la comprobación inicial, el error posterior de “esto nunca debería ocurrir” no queda expuesto en el tipo
    • Al final aparece un agujero en el sistema de tipos, y la detección de bugs pasa a depender de tests o revisiones manuales
  • Fortalecer el tipo del argumento

    • Si no se debilita el tipo de retorno y se fortalece el tipo del argumento, puede eliminarse la posibilidad de llamar a head con una lista vacía
    • NonEmpty a de Data.List.NonEmpty representa una lista no vacía
    • Su definición es data NonEmpty a = a :| [a]
    • Separa el primer elemento a del resto de la lista [a], de modo que aunque la cola esté vacía, el primer elemento siempre existe
    • head :: NonEmpty a -> a se implementa con un solo patrón y se vuelve una función total
    • Si se cambia el tipo de retorno a algo como getConfigurationDirectories :: IO (NonEmpty FilePath), el hecho de que la lista no esté vacía queda conservado en el tipo
    • nonEmpty :: [a] -> Maybe (NonEmpty a) convierte una lista normal en NonEmpty
    • El manejo de Nothing se realiza una sola vez en el límite de entrada
    • En main puede usarse sin ramas duplicadas, como initializeCache (head configDirs)
    • Si más adelante getConfigurationDirectories cambia y deja de garantizar que la lista no esté vacía, también deberá cambiar su tipo de retorno, y main fallará en la verificación de tipos

Diferencia entre validación y parseo

  • validateNonEmpty :: [a] -> IO () y parseNonEmpty :: [a] -> IO (NonEmpty a) comprueban ambos si la lista está vacía y producen un error si falla
  • La diferencia está en el tipo de retorno
    • validateNonEmpty devuelve (), que no contiene información, y descarta el resultado de la comprobación
    • parseNonEmpty devuelve NonEmpty a, dejando en el sistema de tipos el conocimiento obtenido por la comprobación
  • Un parser puede verse como una función que consume una entrada menos estructurada y produce una salida más estructurada
  • Bajo esta definición, parseNonEmpty es un parser simple que parsea una lista como una lista no vacía
  • El parseo permite completar primero las comprobaciones en el límite entre el programa y el mundo exterior, y evita repetir la misma comprobación después

Límites de parseo en el ecosistema Haskell

  • Las aplicaciones Haskell usan varios tipos de parsers en los puntos donde se encuentran con el mundo exterior
    • aeson: ofrece el tipo Parser para parsear datos JSON a tipos de dominio
    • optparse-applicative: ofrece combinadores de parsers para argumentos de línea de comandos
    • persistent, postgresql-simple: ofrecen mecanismos para parsear valores de almacenes de datos externos
    • servant: parsea tipos de datos de Haskell a partir de componentes de ruta, parámetros de consulta, encabezados HTTP, etc.
  • El mundo exterior no habla en tipos producto y tipos suma, sino en flujos de bytes, por lo que el parseo es inevitable
  • Si se parsean los datos al inicio, antes de usarlos, se pueden evitar varios tipos de bugs, algunos de los cuales podrían derivar en vulnerabilidades de seguridad
  • Para parsearlo todo al inicio, a veces hay que parsear valores mucho antes de su uso real
  • En un sistema de tipos estático, si la lógica de parseo y la lógica de procesamiento no coinciden, el programa no compila

Riesgos de un enfoque centrado en la validación

  • La validación ad hoc puede llevar al shotgun parsing, un concepto del área de language-theoretic security
  • En el artículo de 2016 The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them, el shotgun parsing se define como un antipatrón en el que el código de parseo y validación de entradas queda mezclado y disperso dentro del código de procesamiento
  • Si no se parsea toda la entrada al inicio, el programa puede procesar parte de una entrada válida y recién después descubrir un error en otra parte
    • En ese caso hay que revertir los cambios de estado ya ejecutados
    • A veces se puede hacer rollback, como en una transacción de un RDBMS, pero en general no siempre es posible
  • Un enfoque basado en validación hace difícil o imposible comprobar que todas las validaciones realmente terminaron al inicio
  • El parseo divide el programa en una fase de parseo y una fase de ejecución, limitando las fallas por entradas inválidas a la primera fase

Cómo aplicarlo en la práctica

  • Primero se escribe en la firma de tipos la representación de datos que la función quiere, y se diseña cubriendo la brecha con la representación disponible actualmente
  • Si una función recibe una lista [(k, v)] que no debe permitir claves duplicadas, una comprobación separada como checkNoDuplicateKeys :: ... => [(k, v)] -> m () puede omitirse fácilmente
  • Una mejor forma es recibir como argumento de la función un Map, que estructuralmente no permite claves duplicadas
    • El punto de llamada puede fallar en la verificación de tipos
    • A lo largo de la cadena de llamadas, el trabajo de convertir la lista en un Map se empuja hacia arriba
    • Cuando se llega al lugar donde se crea el valor, o donde los duplicados realmente deberían permitirse, se agrega una comprobación de la forma [(k, v)] -> m (Map k v)
  • En ese punto no se puede omitir la comprobación, porque su resultado es necesario para la ejecución posterior
  • Se repiten dos principios
    • Usar estructuras de datos que hagan que los estados imposibles no puedan representarse
    • Empujar la carga de la prueba lo más arriba posible, pero no más allá del punto necesario

Guías de diseño adicionales y límites

  • Hay que dejar que los tipos de datos guíen el código y evitar la tentación de poner simplemente un Bool en un registro por la función que se está escribiendo en ese momento
  • Conviene mirar con sospecha las funciones que devuelven m ()
    • Pueden ser necesarias cuando solo realizan efectos imperativos y no tienen un resultado significativo
    • Si su objetivo principal es producir un error, probablemente haya una forma mejor
  • No hay que temer parsear los datos en varias etapas
    • Evitar el shotgun parsing significa no actuar sobre los datos de entrada antes de parsearlos por completo
    • Sí es posible usar parte de la entrada para decidir cómo parsear otra parte
  • Las representaciones de datos desnormalizadas deben evitarse especialmente cuando son mutables
    • Si se replica el mismo dato en varios lugares, se vuelve fácil representar estados inconsistentes
    • Si la desnormalización es imprescindible, debe esconderse detrás de un límite de abstracción y solo un módulo pequeño y confiable debe asumir la responsabilidad de sincronización
  • Cuando una condición invariante es realmente difícil de expresar solo con las herramientas de Haskell, se puede usar un newtype abstracto y constructores inteligentes para convertir un validador en algo parecido a un parser
  • No hace falta introducir singletons ni refactorizar toda la aplicación solo para eliminar todos los error "impossible", pero en esos casos conviene proceder con cuidado, por ejemplo dejando los invariantes en comentarios

Más lecturas y advertencias prácticas

  • Para aprovechar bien el sistema de tipos de Haskell no hace falta tener un PhD ni usar las extensiones más recientes del lenguaje de GHC
  • El punto de partida se parece a un principio simple: “escribe funciones totales”, aunque aplicarlo al código real puede no ser fácil
  • Como la comunidad Haskell es pequeña, a veces los patrones y técnicas de diseño quedan como conocimiento transmitido oralmente más que como documentación
  • Un recurso relacionado es Type Safety Back and Forth, de Matt Parson
  • Como tema más avanzado, el artículo de 2018 de Matt Noonan Ghosts of Departed Proofs trata técnicas para incorporar invariantes más complejos al sistema de tipos
  • En programas reales puede ser difícil incorporar ciertos invariantes al sistema de tipos, y estos principios son más un ideal al que apuntar que requisitos estrictos

1 comentarios

 
GN⁺ 2024-07-23
Opiniones de Hacker News
  • Muy buen consejo y excelente artículo. Hay una razón por la que este sitio lo vuelve a subir de vez en cuando.
    Incluso para quienes no usan lenguajes funcionales con tipado estático, esta idea trasciende paradigmas. En la literatura de orientación a objetos de los años 80 y 90, por ejemplo en Design by Contract, se pueden ver conceptos muy parecidos, y probablemente también se encuentren papers, debates y especificaciones aún más antiguos.
    Creo que TypeScript también suele escribirse de forma que va acotando tipos en tiempo de ejecución. Design by Contract probablemente también influyó en spec de Clojure, un lenguaje dinámico.
    En el fondo, es un tema de supuestos y garantías. Si se puede comprobar un supuesto para crear una garantía, otras partes del programa ya no necesitan volver a comprobar el mismo supuesto.
    Al leer código, lo que más confunde es ver que en otro lugar se vuelve a verificar una propiedad que ya estaba garantizada. Hace que razonar y mejorar el código sea más difícil.

    • Esa “propiedad ya garantizada” puede desaparecer en algún momento. Más exactamente, el procedimiento que implementa y ejecuta esa garantía puede, por cualquier motivo, dejar de cumplir su función.
      Estadísticamente eso termina ocurriendo, y cuando pasa, otros procesos, scripts o código que dependían del procedimiento de validación “original” quedan en una situación muy complicada.
    • En lenguajes con sistemas de tipos fuertes, a medida que el programa crece y se vuelve más complejo, esto termina siendo una de esas ventajas prácticas que te dan libertad.
      Pero hay que usarlo de verdad. Por ejemplo, tener clases UncheckedEmail, ValidEmail y VerifiedEmail, y obligar a pasar por el proceso de verificación de email para cambiar de una etapa a la siguiente.
      Así no hace falta adivinar si una dirección de email está sin revisar, si es formalmente válida o si ya fue verificada, y tampoco se necesita un booleano como is_email_verified que uno puede olvidarse de actualizar o revisar. Si usas un valor incorrecto en el lugar incorrecto, el verificador de tipos te grita, y las personas pueden concentrarse en lo importante.
    • Al repasar los comentarios de publicaciones anteriores, me parece que uno de los mayores problemas de este artículo es el título. El título actúa como un ancla, y mucha gente termina refutando cosas que no están en el cuerpo del texto y que solo el título sugiere sin contexto.
      Por eso hay quienes lo interpretan como si la autora propusiera no validar nada y solo parsear, pero el artículo en realidad trata sobre dónde validar los datos y qué hacer con el resultado. No es un artículo que proponga eliminar toda validación.
  • Es un artículo de 2019, pero sigue siendo un consejo bastante bueno. Este patrón encaja muy bien también en C# moderno, y además ahorra espacio porque se puede omitir la declaración explícita de variables.
    if(!Whatever.TryParse(input, out var output)) output = some-sane-default;
    O
    if(!Whatever.TryParse(input, out var output)) throw new ApplicationException($"Not a valid Thingy: {input}");
    Tip de experto: no hagan lo segundo en drivers en modo kernel.

    • Tip de experto: no hagan ninguno de los dos. En especial, nunca hagan el primero.
      El manejo explícito siempre es mejor que un valor por defecto implícito que se usa en lugar de un valor que se pensaba correcto pero resultó estar mal.
      Lo que hay que hacer es levantar la mano temprano y tratarlo como una falla de parseo, y luego definir con mucha claridad el proceso y el protocolo para manejar un archivo que no se puede cargar. Eso te obliga a hacerte las preguntas difíciles que esas dos opciones no responden.
      El verdadero problema de que el reciente driver en modo kernel de CrowdStrike fallara al parsear algún archivo def/config fue que los desarrolladores, responsables de producto y analistas de negocio no se preguntaron: “¿qué pasa si se intenta cargar un archivo inválido?”.
    • ¿Por qué solo “bastante bueno”? ¿Y qué relación tiene con el año en que se publicó? ¿Quieres decir que si se hubiera publicado antes de 2019, el consejo del artículo tendría más autoridad?
    • Ojalá no usaran el primer enfoque. Hay que manejar los casos malos. Volver a un “valor por defecto razonable” debería ser extremadamente raro.
      Manejo explícito > manejo implícito
    • if(!Whatever.TryParse(input, out var output)) output = some-sane-default;
      Detesto mucho ese enfoque. Creo que los errores de entrada inválida deben manejarse fuera de la función de parseo. En F# eso es fácil.
      type Whatever =
      static member create input =
      match input with
      | ValidWhatever x -> Some x
      | _ -> None
      match Whatever.create input with
      | Some x -> // procesar datos parseados
      | None -> // manejar el caso en que no se parseó correctamente
      O también se puede armar una pipeline más cómoda para encadenar operaciones con Option.map/Option.bind.
      Así solo se pueden crear instancias a través del método create, que parsea la entrada.
      Aunque en la práctica probablemente quieras usar result en vez de option, pero eso es un tema secundario.
    • Casi no se me ocurre, quizás directamente no se me ocurre, una situación en la que quisiera ver código como if(!Whatever.TryParse(input, out var output)) output = some-sane-default;.
      Si la entrada no se proporcionó en absoluto, es decir, si el parámetro es opcional, tiene sentido usar un valor por defecto razonable.
      Pero si se proporcionó una entrada incorrecta, no quiero que el programa finja que no pasa nada.
      Si alguien entra a una florería y pide café, la respuesta correcta no es entregarle una rosa. Si intenta bebérsela, se va a destrozar la boca.
      Para ese conjunto de entradas, el método, módulo o programa no tiene una salida definida. Hay que dejarlo claro, en vez de hacer algo silenciosamente incorrecto o ambiguo y volver rápidamente imposible razonar sobre el programa. Mejor hacer que el problema ocurra de forma explícita y deje un stack trace que apunte directo al punto del problema, en lugar de dejar que meses después aparezca como un bug de comportamiento extraño.
  • Es un consejo para aprovechar un sistema de tipos fuerte y hacer que los estados erróneos sean imposibles de representar. Es muy útil para reducir bugs en todo el software.
    Pensar más a fondo el problema y diseñar así toma más tiempo, pero en muchos casos ese tiempo vale totalmente la pena.

    • Me atrevería a decir que, en lenguajes que soportan tipos de datos algebraicos, este enfoque no toma más tiempo. Simplemente ocurre de forma natural.
      Claro que, en lenguajes donde modelar datos requiere mucho procedimiento consciente, como C++, Java, C#, Python, Go o JavaScript, sí toma más tiempo.
  • “Ahora tengo un lema corto y contundente para lo que significa para mí el diseño guiado por tipos, y lo mejor es que son solo tres palabras: Parse, don’t validate.”
    Mi lema, en cambio, se acerca más a valida siempre solo en un único constructor. No importa si es una función constructora.
    Así, un objeto inválido no puede existir desde el principio y siempre hay una única fuente de verdad. Si quieres modificar un objeto, puedes implementarlo llamando de nuevo al mismo constructor para crear un nuevo estado.

    • No es lo mismo.
      El punto central es que, si solo validas, esa información luego desaparece.
      Por ejemplo, validar que cierto int sea positivo tiene beneficios limitados. Si no parseas ese valor como un entero positivo, esa información no queda luego a nivel de tipos. Lo mismo ocurre con arrays o listas no vacías: el consumidor posterior podría tener que volver a comprobar si esa lista realmente no está vacía.
      Este tipo de información no siempre puede codificarse en un objeto o un constructor.
  • Material relacionado: Making Impossible States Impossible, de Richard Feldman
    https://www.youtube.com/watch?v=IcgmSRJHu_8

  • Ya hubo buenas discusiones antes
    https://news.ycombinator.com/item?id=35053118
    https://news.ycombinator.com/item?id=21476261

  • Cada vez que aparece este tema, me viene a la mente la sección 5 de https://cr.yp.to/qmail/guarantee.html. Ahí hay frases como “no parsees” y “hay dos tipos de interfaces de comandos en el mundo de la computación: las buenas interfaces y las interfaces de usuario”.
    Si diera una clase que enseñara programación a escala mediana, no a pequeña ni a gran escala, me gustaría dejarles a los estudiantes como tarea un ensayo en el que comparen y contrasten estas propuestas. Hay algo que aprender de cada una, y quizá no sean tan contradictorias como parecen a primera vista.

  • Me recuerda un comentario que vi durante la moda de XML a mediados de los 2000. Decía que muchas organizaciones implementaron lenguajes específicos de dominio, incluidos lenguajes de configuración, en XML porque XML les daba un parser y la mayoría de las organizaciones no quería escribir su propio parser.
    No sé por qué la gente no quería escribir parsers. Escribir parsers no es tan difícil y es bastante divertido.

  • Es uno de mis textos favoritos entre los que he leído en mi carrera. He visto que muchas personas leen solo el título y asumen que el parsing y la validación son somehow mutuamente excluyentes, pero en realidad no es así. El parsing a menudo incluye validación.
    Esto se trata en la sección “Use abstract datatypes to make validators ‘look like’ parsers” del artículo.
    Está en la misma línea que evitar la obsesión por los tipos primitivos.