Programación declarativa: ¿es algo real?
Publicado: 2022-03-11La programación declarativa es, actualmente, el paradigma dominante de un conjunto extenso y diverso de dominios, como bases de datos, plantillas y gestión de configuración.
En pocas palabras, la programación declarativa consiste en instruir a un programa sobre lo que debe hacer, en lugar de decirle cómo hacerlo. En la práctica, este enfoque implica proporcionar un lenguaje específico de dominio (DSL) para expresar lo que el usuario quiere y protegerlo de las construcciones de bajo nivel (bucles, condicionales, asignaciones) que materializan el estado final deseado.
Si bien este paradigma es una mejora notable sobre el enfoque imperativo que reemplazó, sostengo que la programación declarativa tiene limitaciones significativas, limitaciones que exploro en este artículo. Además, propongo un enfoque dual que captura los beneficios de la programación declarativa mientras supera sus limitaciones.
ADVERTENCIA : este artículo surgió como resultado de una lucha personal de varios años con las herramientas declarativas. Muchas de las afirmaciones que presento aquí no están completamente probadas, y algunas incluso se presentan al pie de la letra. Una crítica adecuada de la programación declarativa requeriría un tiempo y un esfuerzo considerables, y tendría que volver atrás y utilizar muchas de estas herramientas; mi corazón no está en tal empresa. El propósito de este artículo es compartir algunos pensamientos con usted, sin andarse con rodeos y mostrar lo que funcionó para mí. Si ha tenido problemas con las herramientas de programación declarativa, es posible que encuentre un respiro y alternativas. Y si disfruta del paradigma y sus herramientas, no me tome demasiado en serio.
Si la programación declarativa funciona bien para usted, no estoy en condiciones de decirle lo contrario .
Los méritos de la programación declarativa
Antes de explorar los límites de la programación declarativa, es necesario comprender sus méritos.
Podría decirse que la herramienta de programación declarativa más exitosa es la base de datos relacional (RDB). Incluso podría ser la primera herramienta declarativa. En cualquier caso, los RDB exhiben las dos propiedades que considero arquetípicas de la programación declarativa:
- Un lenguaje específico de dominio (DSL) : la interfaz universal para las bases de datos relacionales es un DSL llamado Lenguaje de consulta estructurado, más comúnmente conocido como SQL.
- El DSL oculta la capa de nivel inferior del usuario : desde el artículo original de Edgar F. Codd sobre RDB, está claro que el poder de este modelo es disociar las consultas deseadas de los bucles, índices y rutas de acceso subyacentes que los implementan.
Antes de las RDB, se accedía a la mayoría de los sistemas de bases de datos a través de un código imperativo, que depende en gran medida de detalles de bajo nivel, como el orden de los registros, los índices y las rutas físicas a los datos en sí. Debido a que estos elementos cambian con el tiempo, el código a menudo deja de funcionar debido a algún cambio subyacente en la estructura de los datos. El código resultante es difícil de escribir, difícil de depurar, difícil de leer y difícil de mantener. Haré todo lo posible y diré que la mayor parte de este código era, con toda probabilidad, largo, lleno de nidos de ratas proverbiales de condicionales, repeticiones y errores sutiles dependientes del estado.
Frente a esto, los RDB proporcionaron un tremendo salto de productividad para los desarrolladores de sistemas. Ahora, en lugar de miles de líneas de código imperativo, tenía un esquema de datos claramente definido, además de cientos (o incluso decenas) de consultas. Como resultado, las aplicaciones solo tenían que lidiar con una representación de datos abstracta, significativa y duradera, e interconectarla a través de un lenguaje de consulta poderoso pero simple. El RDB probablemente elevó la productividad de los programadores y de las empresas que los emplearon en un orden de magnitud.
¿Cuáles son las ventajas comúnmente enumeradas de la programación declarativa?
- Legibilidad/usabilidad : un DSL suele estar más cerca de un lenguaje natural (como el inglés) que de un pseudocódigo, por lo tanto, es más legible y también más fácil de aprender para quienes no son programadores.
- Concisión : gran parte del texto modelo es abstraído por el DSL, dejando menos líneas para hacer el mismo trabajo.
- Reutilizar : es más fácil crear código que pueda usarse para diferentes propósitos; algo que es notoriamente difícil cuando se usan construcciones imperativas.
- Idempotencia : puede trabajar con estados finales y dejar que el programa lo resuelva por usted. Por ejemplo, a través de una operación upsert, puede insertar una fila si no está allí o modificarla si ya está allí, en lugar de escribir código para tratar ambos casos.
- Recuperación de errores : es fácil especificar una construcción que se detenga en el primer error en lugar de tener que agregar detectores de errores para cada posible error. (Si alguna vez ha escrito tres devoluciones de llamada anidadas en node.js, sabe a lo que me refiero).
- Transparencia referencial : aunque esta ventaja se asocia comúnmente con la programación funcional, en realidad es válida para cualquier enfoque que minimice el manejo manual del estado y se base en efectos secundarios.
- Conmutatividad : la posibilidad de expresar un estado final sin tener que especificar el orden real en el que se implementará.
Si bien las anteriores son todas las ventajas comúnmente citadas de la programación declarativa, me gustaría condensarlas en dos cualidades, que servirán como principios rectores cuando propongo un enfoque alternativo.
- Una capa de alto nivel adaptada a un dominio específico : la programación declarativa crea una capa de alto nivel utilizando la información del dominio al que se aplica. Está claro que si estamos tratando con bases de datos, queremos un conjunto de operaciones para tratar con datos. La mayoría de las siete ventajas anteriores se derivan de la creación de una capa de alto nivel que se adapta con precisión a un dominio de problema específico.
- Poka-yoke (a prueba de tontos) : una capa de alto nivel adaptada al dominio oculta los detalles imperativos de la implementación. Esto significa que comete muchos menos errores porque los detalles de bajo nivel del sistema simplemente no son accesibles. Esta limitación elimina muchas clases de errores de su código.
Dos problemas con la programación declarativa
En las siguientes dos secciones, presentaré los dos problemas principales de la programación declarativa: separación y falta de desarrollo . Cada crítica necesita su coco, así que usaré los sistemas de plantillas HTML como un ejemplo concreto de las deficiencias de la programación declarativa.
El problema con los DSL: Separación
Imagine que necesita escribir una aplicación web con un número no trivial de vistas. Codificar estas vistas en un conjunto de archivos HTML no es una opción porque muchos componentes de estas páginas cambian.
La solución más sencilla, que es generar HTML mediante la concatenación de cadenas, parece tan horrible que rápidamente buscará una alternativa. La solución estándar es utilizar un sistema de plantillas. Aunque existen diferentes tipos de sistemas de plantilla, dejaremos de lado sus diferencias a los efectos de este análisis. Podemos considerar que todos ellos son similares en el sentido de que la misión principal de los sistemas de plantillas es proporcionar una alternativa al código que concatena cadenas HTML mediante condicionales y bucles, al igual que los RDB surgieron como una alternativa al código que recorría registros de datos.
Supongamos que vamos con un sistema de plantillas estándar; encontrará tres fuentes de fricción, que enumeraré en orden ascendente de importancia. La primera es que la plantilla reside necesariamente en un archivo separado de su código. Debido a que el sistema de plantillas usa un DSL, la sintaxis es diferente, por lo que no puede estar en el mismo archivo. En proyectos simples, donde el número de archivos es bajo, la necesidad de mantener archivos de plantilla separados puede duplicar o triplicar la cantidad de archivos.
Abro una excepción para las plantillas de Ruby integradas (ERB), porque están integradas en el código fuente de Ruby. Este no es el caso de las herramientas inspiradas en ERB escritas en otros idiomas, ya que esas plantillas también deben almacenarse como archivos diferentes.
La segunda fuente de fricción es que el DSL tiene su propia sintaxis, diferente a la de su lenguaje de programación. Por lo tanto, modificar el DSL (y mucho menos escribir uno propio) es considerablemente más difícil. Para ir bajo el capó y cambiar la herramienta, debe aprender sobre la tokenización y el análisis, lo cual es interesante y desafiante, pero difícil. Veo esto como una desventaja.
Puede preguntar: “¿Por qué demonios querría modificar su herramienta? Si está haciendo un proyecto estándar, una herramienta estándar bien escrita debería cumplir con los requisitos”. Tal vez sí tal vez no.
Un DSL nunca tiene todo el poder de un lenguaje de programación. Si lo hiciera, ya no sería un DSL, sino un lenguaje de programación completo.
¿Pero no es ese el objetivo de un DSL? ¿ No tener disponible todo el poder de un lenguaje de programación, para que podamos lograr la abstracción y eliminar la mayoría de las fuentes de errores? Tal vez sí. Sin embargo, la mayoría de los DSL comienzan de manera simple y luego incorporan gradualmente un número creciente de las funciones de un lenguaje de programación hasta que, de hecho, se convierte en uno. Los sistemas de plantillas son un ejemplo perfecto. Veamos las características estándar de los sistemas de plantillas y cómo se correlacionan con las funciones del lenguaje de programación:
- Reemplazar texto dentro de una plantilla : sustitución de variables.
- Repetición de una plantilla : bucles.
- Evite imprimir una plantilla si no se cumple una condición: condicionales.
- Parciales : subrutinas.
- Ayudantes : subrutinas (la única diferencia con los parciales es que los ayudantes pueden acceder al lenguaje de programación subyacente y dejarlo fuera de la camisa de fuerza de DSL).
Este argumento, que un DSL es limitado porque simultáneamente codicia y rechaza el poder de un lenguaje de programación, es directamente proporcional en la medida en que las características del DSL son directamente asignables a las características de un lenguaje de programación . En el caso de SQL, el argumento es débil porque la mayoría de las cosas que ofrece SQL no se parecen en nada a lo que se encuentra en un lenguaje de programación normal. En el otro extremo del espectro, encontramos sistemas de plantilla en los que prácticamente todas las características hacen que el DSL converja hacia BASIC.
Ahora demos un paso atrás y contemplemos estas tres fuentes de fricción por excelencia, resumidas en el concepto de separatividad . Debido a que está separado, un DSL debe ubicarse en un archivo separado; es más difícil de modificar (e incluso más difícil de escribir uno propio) y (a menudo, pero no siempre) necesita que agregue, una por una, las características que extraña de un lenguaje de programación real.
La separación es un problema inherente de cualquier DSL, sin importar qué tan bien diseñado esté.
Pasamos ahora a un segundo problema de las herramientas declarativas, que está muy extendido pero no es inherente.
Otro problema: la falta de desarrollo conduce a la complejidad
Si hubiera escrito este artículo hace unos meses, esta sección se habría llamado ¡La mayoría de las herramientas declarativas son #@!$#@! Complejo pero no sé por qué . En el proceso de escribir este artículo, encontré una mejor manera de expresarlo: la mayoría de las herramientas declarativas son mucho más complejas de lo que deberían ser . Pasaré el resto de esta sección explicando por qué. Para analizar la complejidad de una herramienta, propongo una medida llamada brecha de complejidad . La brecha de complejidad es la diferencia entre resolver un problema determinado con una herramienta versus resolverlo en el nivel inferior (presumiblemente, código imperativo simple) que la herramienta intenta reemplazar. Cuando la primera solución es más compleja que la segunda, estamos en presencia de la brecha de complejidad. Por más complejo , me refiero a más líneas de código, código que es más difícil de leer, más difícil de modificar y más difícil de mantener, pero no necesariamente todo esto al mismo tiempo.
Tenga en cuenta que no estamos comparando la solución de nivel inferior con la mejor herramienta posible, sino con ninguna herramienta. Esto hace eco del principio médico de “Primero, no hagas daño” .
Los signos de una herramienta con una gran brecha de complejidad son:
- Algo que toma unos minutos para describir con gran detalle en términos imperativos tomará horas codificar usando la herramienta, incluso cuando sepa cómo usarla.
- Siente que está trabajando constantemente alrededor de la herramienta en lugar de con la herramienta.
- Está luchando por resolver un problema sencillo que pertenece directamente al dominio de la herramienta que está utilizando, pero la mejor respuesta de desbordamiento de pila que encuentra describe una solución alternativa .
- Cuando este problema tan sencillo podría resolverse con una característica determinada (que no existe en la herramienta) y ve un problema de Github en la biblioteca que presenta una discusión larga de dicha característica con +1 s intercalados.
- Un anhelo crónico, con picazón, de deshacerse de la herramienta y hacerlo todo usted mismo dentro de un _for-loop_.
Podría haber sido presa de la emoción aquí, ya que los sistemas de plantillas no son tan complejos, pero esta brecha de complejidad comparativamente pequeña no es un mérito de su diseño, sino más bien porque el dominio de aplicabilidad es bastante simple (recuerde, aquí solo estamos generando HTML ). Cada vez que se utiliza el mismo enfoque para un dominio más complejo (como la gestión de la configuración), la brecha de complejidad puede convertir rápidamente su proyecto en un atolladero.
Dicho esto, no es necesariamente inaceptable que una herramienta sea un poco más compleja que el nivel inferior al que pretende reemplazar; si la herramienta produce un código más legible, conciso y correcto, puede valer la pena. Es un problema cuando la herramienta es varias veces más compleja que el problema al que reemplaza; esto es rotundamente inaceptable. Brian Kernighan dijo que “ controlar la complejidad es la esencia de la programación de computadoras. ” Si una herramienta agrega una complejidad significativa a su proyecto, ¿por qué siquiera usarla?
La pregunta es, ¿por qué algunas herramientas declarativas son mucho más complejas de lo necesario? Creo que sería un error echarle la culpa a un mal diseño. Una explicación tan general, un ataque general ad hominem a los autores de estas herramientas, no es justa. Tiene que haber una explicación más precisa y esclarecedora.
Mi argumento es que cualquier herramienta que ofrezca una interfaz de alto nivel para abstraer un nivel inferior debe desplegar este nivel superior desde el inferior. El concepto de despliegue proviene de la obra magna de Christopher Alexander, La naturaleza del orden, en particular el Volumen II. Está (desesperadamente) más allá del alcance de este artículo (sin mencionar mi comprensión) resumir las implicaciones de este trabajo monumental para el diseño de software; Creo que su impacto será enorme en los próximos años. También está más allá de este artículo proporcionar una definición rigurosa de los procesos de desarrollo. Usaré aquí el concepto de manera heurística.
Un proceso de despliegue es aquel que, paso a paso, crea una estructura adicional sin negar la existente. En cada paso, cada cambio (o diferenciación, para usar el término de Alexander) permanece en armonía con cualquier estructura previa, cuando la estructura previa es, simplemente, una secuencia cristalizada de cambios pasados.
Curiosamente, Unix es un gran ejemplo del desarrollo de un nivel superior a partir de uno inferior. En Unix, dos características complejas del sistema operativo, los trabajos por lotes y las corrutinas (tuberías), son simplemente extensiones de comandos básicos. Debido a ciertas decisiones de diseño fundamentales, como hacer que todo sea un flujo de bytes, el shell es un programa de usuario y archivos de E/S estándar, Unix puede proporcionar estas funciones sofisticadas con una complejidad mínima.
Para subrayar por qué estos son excelentes ejemplos de desarrollo, me gustaría citar algunos extractos de un artículo de 1979 de Dennis Ritchie, uno de los autores de Unix:
En trabajos por lotes :
… el nuevo esquema de control de procesos instantáneamente hizo que algunas características muy valiosas fueran triviales de implementar; por ejemplo, procesos separados (con
&
) y uso recursivo del shell como comando. La mayoría de los sistemas tienen que proporcionar algún tipo de función especial debatch job submission
y un intérprete de comandos especial para archivos distintos del que se usa de forma interactiva.
En corrutinas :
La genialidad de la canalización de Unix es precisamente que está construida a partir de los mismos comandos que se usan constantemente en modo simplex.
Esta elegancia y simplicidad, sostengo, proviene de un proceso de desarrollo . Los trabajos por lotes y las rutinas se desarrollan a partir de estructuras anteriores (los comandos se ejecutan en un shell de usuario). Creo que por la filosofía minimalista y los recursos limitados del equipo que creó Unix, el sistema evolucionó paso a paso, y como tal, fue capaz de incorporar características avanzadas sin dar la espalda a las básicas porque no había suficientes recursos para haz lo contrario
En ausencia de un proceso de desarrollo, el alto nivel será considerablemente más complejo de lo necesario. En otras palabras, la complejidad de la mayoría de las herramientas declarativas radica en el hecho de que su alto nivel no se despliega del bajo nivel que pretenden reemplazar.
Esta falta de despliegue , si se perdona el neologismo, se justifica habitualmente por la necesidad de proteger al usuario del nivel inferior. Este énfasis en poka-yoke (proteger al usuario de errores de bajo nivel) se produce a expensas de una gran brecha de complejidad que es contraproducente porque la complejidad adicional generará nuevas clases de errores. Para colmo de males, estas clases de errores no tienen nada que ver con el dominio del problema, sino con la herramienta en sí. No iríamos demasiado lejos si calificamos estos errores como iatrogénicos.
Las herramientas de creación de plantillas declarativas, al menos cuando se aplican a la tarea de generar vistas HTML, son un caso arquetípico de un nivel alto que da la espalda al nivel bajo que intenta reemplazar. ¿Cómo es eso? Porque generar cualquier vista no trivial requiere lógica , y los sistemas de plantillas, especialmente los que no tienen lógica, eliminan la lógica a través de la puerta principal y luego pasan de contrabando parte de ella por la puerta del gato.
Nota: una justificación aún más débil para una gran brecha de complejidad es cuando una herramienta se comercializa como mágica , o algo que simplemente funciona , se supone que la opacidad del bajo nivel es una ventaja porque se supone que una herramienta mágica siempre funciona sin que lo entiendas. por qué o cómo. En mi experiencia, cuanto más mágica pretende ser una herramienta, más rápido transmuta mi entusiasmo en frustración.
Pero, ¿qué pasa con la separación de preocupaciones? ¿No deberían la vista y la lógica permanecer separadas? El error central, aquí, es poner la lógica comercial y la lógica de presentación en la misma bolsa. La lógica empresarial ciertamente no tiene lugar en una plantilla, pero la lógica de presentación existe de todos modos. La exclusión de la lógica de las plantillas empuja la lógica de presentación al servidor, donde se acomoda de manera incómoda. La clara formulación de este punto se la debo a Alexei Boronine, quien presenta un excelente caso en este artículo.
Mi sensación es que aproximadamente dos tercios del trabajo de una plantilla residen en su lógica de presentación, mientras que el otro tercio se ocupa de cuestiones genéricas como la concatenación de cadenas, el cierre de etiquetas, el escape de caracteres especiales, etc. Esta es la naturaleza de bajo nivel de dos caras de generar vistas HTML. Los sistemas de plantillas se ocupan adecuadamente de la segunda mitad, pero no les va bien con la primera. Las plantillas sin lógica le dan la espalda a este problema, lo que te obliga a resolverlo de manera incómoda. Otros sistemas de plantillas sufren porque realmente necesitan proporcionar un lenguaje de programación no trivial para que sus usuarios puedan escribir lógica de presentación.
Para resumir; Las herramientas de plantillas declarativas sufren porque:
- Si tuvieran que desarrollarse desde el dominio de su problema, tendrían que proporcionar formas de generar patrones lógicos;
- Un DSL que proporciona lógica no es realmente un DSL, sino un lenguaje de programación. Tenga en cuenta que otros dominios, como la gestión de la configuración, también sufren de falta de "despliegue".
Me gustaría cerrar la crítica con un argumento que lógicamente está desconectado del hilo de este artículo, pero que resuena profundamente en su núcleo emocional: tenemos un tiempo limitado para aprender. La vida es corta y encima hay que trabajar. Frente a nuestras limitaciones, debemos dedicar nuestro tiempo a aprender cosas que serán útiles y resistirán el tiempo, incluso frente a la rápida evolución de la tecnología. Es por eso que lo exhorto a usar herramientas que no solo brinden una solución, sino que arrojen una luz brillante sobre el dominio de su propia aplicabilidad. Los RDB te enseñan sobre datos y Unix te enseña sobre conceptos de SO, pero con herramientas insatisfactorias que no se desarrollan, siempre sentí que estaba aprendiendo las complejidades de una solución subóptima mientras permanecía en la oscuridad sobre la naturaleza del problema. pretende resolver.

La heurística que le sugiero que considere es, valore las herramientas que iluminan el dominio de su problema, en lugar de las herramientas que oscurecen el dominio de su problema detrás de las características supuestas .
El enfoque gemelo
Para superar los dos problemas de la programación declarativa, que he presentado aquí, propongo un enfoque doble:
- Utilice un lenguaje específico de dominio de estructura de datos (dsDSL) para superar la separación.
- Cree un nivel alto que se desarrolle desde el nivel inferior, para superar la brecha de complejidad.
dsDSL
Una estructura de datos DSL (dsDSL) es una DSL que se construye con las estructuras de datos de un lenguaje de programación . La idea central es utilizar las estructuras de datos básicas que tiene disponibles, como cadenas, números, matrices, objetos y funciones, y combinarlas para crear abstracciones para tratar con un dominio específico.
Queremos mantener el poder de declarar estructuras o acciones (nivel alto) sin tener que especificar los patrones que implementan estas construcciones (nivel bajo). Queremos superar la separación entre el DSL y nuestro lenguaje de programación para que podamos usar todo el poder de un lenguaje de programación siempre que lo necesitemos. Esto no solo es posible sino sencillo a través de dsDSL.
Si me hubieras preguntado hace un año, habría pensado que el concepto de dsDSL era novedoso, pero un día me di cuenta de que JSON en sí mismo era un ejemplo perfecto de este enfoque. Un objeto JSON analizado consta de estructuras de datos que representan de forma declarativa las entradas de datos para obtener las ventajas de DSL y al mismo tiempo facilitar el análisis y el manejo desde un lenguaje de programación. (Puede haber otros dsDSL por ahí, pero hasta ahora no he encontrado ninguno. Si conoce uno, le agradecería mucho que lo mencionara en la sección de comentarios).
Al igual que JSON, un dsDSL tiene los siguientes atributos:
- Consiste en un conjunto muy pequeño de funciones: JSON tiene dos funciones principales,
parse
ystringify
. - Sus funciones suelen recibir argumentos complejos y recursivos: un JSON analizado es una matriz u objeto, que normalmente contiene más matrices y objetos en su interior.
- Las entradas de estas funciones se ajustan a formas muy específicas: JSON tiene un esquema de validación explícito y estrictamente aplicado para diferenciar las estructuras válidas de las no válidas.
- Tanto las entradas como las salidas de estas funciones pueden ser contenidas y generadas por un lenguaje de programación sin una sintaxis separada.
Pero los dsDSL van más allá de JSON de muchas maneras. Vamos a crear un dsDSL para generar HTML usando Javascript. Más adelante tocaré el tema de si este enfoque se puede extender a otros lenguajes (spoiler: definitivamente se puede hacer en Ruby y Python, pero probablemente no en C).
HTML es un lenguaje de marcado compuesto por tags
delimitadas por corchetes angulares ( <
y >
). Estas etiquetas pueden tener atributos y contenidos opcionales. Los atributos son simplemente una lista de atributos clave/valor, y el contenido puede ser texto u otras etiquetas. Tanto los atributos como los contenidos son opcionales para cualquier etiqueta dada. Estoy simplificando un poco, pero es exacto.
Una forma sencilla de representar una etiqueta HTML en un dsDSL es mediante una matriz con tres elementos: - Etiqueta: una cadena. - Atributos: un objeto (del tipo simple, clave/valor) o undefined
(si no se necesitan atributos). - Contenido: una cadena (texto), una matriz (otra etiqueta) o undefined
(si no hay contenido).
Por ejemplo, <a href="views">Index</a>
se puede escribir como ['a', {href: 'views'}, 'Index']
.
Si queremos incrustar este elemento ancla en un div
con links
de clase, podemos escribir: ['div', {class: 'links'}, ['a', {href: 'views'}, 'Index']]
.
Para enumerar varias etiquetas html en el mismo nivel, podemos envolverlas en una matriz:
[ ['h1', 'Hello!'], ['a', {href: 'views'}, 'Index'] ]
El mismo principio se puede aplicar a la creación de varias etiquetas dentro de una etiqueta:
['body', [ ['h1', 'Hello!'], ['a', {href: 'views'}, 'Index'] ]]
Por supuesto, este dsDSL no nos llevará lejos si no generamos HTML a partir de él. Necesitamos una función de generate
que tome nuestro dsDSL y produzca una cadena con HTML. Entonces, si ejecutamos generate (['a', {href: 'views'}, 'Index'])
, obtendremos la cadena <a href="views">Index</a>
.
La idea detrás de cualquier DSL es especificar algunas construcciones con una estructura específica que luego se pasa a una función. En este caso, la estructura que conforma el dsDSL es este arreglo, que tiene de uno a tres elementos; estas matrices tienen una estructura específica. Si generate
valida completamente su entrada (y es fácil e importante validar completamente la entrada, ya que estas reglas de validación son el análogo exacto de la sintaxis de un DSL), le dirá exactamente dónde se equivocó con su entrada. Después de un tiempo, comenzará a reconocer lo que distingue a una estructura válida en un dsDSL, y esta estructura será muy sugerente de lo que genera subyacente.
Ahora, ¿cuáles son los méritos de un dsDSL en contraposición a un DSL?
- Un dsDSL es una parte integral de su código. Conduce a un menor número de líneas, de archivos y a una reducción general de los gastos generales.
- Los dsDSL son fáciles de analizar (por lo tanto, más fáciles de implementar y modificar). El análisis es simplemente iterar a través de los elementos de una matriz u objeto. Del mismo modo, los dsDSL son comparativamente fáciles de diseñar porque en lugar de crear una nueva sintaxis (que todo el mundo odiará) puedes seguir con la sintaxis de tu lenguaje de programación (que todo el mundo odia pero al menos ya la conocen).
- Un dsDSL tiene todo el poder de un lenguaje de programación. Esto significa que un dsDSL, cuando se emplea correctamente, tiene la ventaja de ser una herramienta de alto y bajo nivel.
Ahora, la última afirmación es fuerte, así que voy a pasar el resto de esta sección apoyándola. ¿Qué quiero decir con empleo adecuado ? Para ver esto en acción, consideremos un ejemplo en el que queremos construir una tabla para mostrar la información de una matriz llamada DATA
.
var DATA = [ {id: 1, description: 'Product 1', price: 20, onSale: true, categories: ['a']}, {id: 2, description: 'Product 2', price: 60, onSale: false, categories: ['b']}, {id: 3, description: 'Product 3', price: 120, onSale: false, categories: ['a', 'c']}, {id: 4, description: 'Product 4', price: 45, onSale: true, categories: ['a', 'b']} ]
En una aplicación real, los DATA
se generarán dinámicamente a partir de una consulta de base de datos.
Además, tenemos una variable FILTER
que, cuando se inicialice, será una matriz con las categorías que queremos mostrar.
Queremos que nuestra mesa:
- Mostrar encabezados de tablas.
- Para cada producto, muestra los campos: descripción, precio y categorías.
- No imprima el campo de
id
, pero agréguelo como un atributo deid
para cada fila. VERSIÓN ALTERNATIVA: Agregue un atributoid
a cada elementotr
. - Coloque una clase en
onSale
si el producto está en oferta. - Ordenar los productos por precio descendente.
- Filtra ciertos productos por categoría. Si
FILTER
es una matriz vacía, mostraremos todos los productos. De lo contrario, solo mostraremos los productos en los que la categoría del producto se encuentre dentro deFILTER
.
Podemos crear la lógica de presentación que coincida con este requisito en ~20 líneas de código:
function drawTable (DATA, FILTER) { var printableFields = ['description', 'price', 'categories']; DATA.sort (function (a, b) {return a.price - b.price}); return ['table', [ ['tr', dale.do (printableFields, function (field) { return ['th', field]; })], dale.do (DATA, function (product) { var matches = (! FILTER || FILTER.length === 0) || dale.stop (product.categories, true, function (category) { return FILTER.indexOf (category) !== -1; }); return matches === false ? [] : ['tr', { id: product.id, class: product.onSale ? 'onsale' : undefined }, dale.do (printableFields, function (field) { return ['td', product [field]]; })]; }) ]]; }
Admito que este no es un ejemplo sencillo, sin embargo, representa una vista bastante simple de las cuatro funciones básicas del almacenamiento persistente, también conocido como CRUD. Cualquier aplicación web no trivial tendrá vistas más complejas que esta.
Veamos ahora qué está haciendo este código. Primero, define una función, drawTable
, para contener la lógica de presentación de dibujar la tabla de productos. Esta función recibe DATA
y FILTER
como parámetros, por lo que puede usarse para diferentes conjuntos de datos y filtros. drawTable
cumple el doble papel de parcial y ayudante.
var drawTable = function (DATA, FILTER) {
La variable interna, printableFields
, es el único lugar donde debe especificar qué campos son imprimibles, evitando repeticiones e inconsistencias frente a requisitos cambiantes.
var printableFields = ['description', 'price', 'categories'];
Luego clasificamos los DATA
según el precio de sus productos. Tenga en cuenta que los criterios de clasificación diferentes y más complejos serían sencillos de implementar, ya que tenemos todo el lenguaje de programación a nuestra disposición.
DATA.sort (function (a, b) {return a.price - b.price});
Aquí devolvemos un objeto literal; una matriz que contiene table
como primer elemento y su contenido como segundo. Esta es la representación dsDSL de la <table>
que queremos crear.
return ['table', [
Ahora creamos una fila con los encabezados de la tabla. Para crear su contenido, usamos dale.do que es una función como Array.map, pero que también funciona para objetos. Iteramos printableFields
y generamos encabezados de tabla para cada uno de ellos:
['tr', dale.do (printableFields, function (field) { return ['th', field]; })],
Tenga en cuenta que acabamos de implementar la iteración, el caballo de batalla de la generación de HTML, y no necesitamos ninguna construcción DSL; solo necesitábamos una función para iterar una estructura de datos y devolver dsDSL. Una función similar nativa o implementada por el usuario también habría funcionado.
Ahora itere a través de los productos contenidos en DATA
.
dale.do (DATA, function (product) {
Comprobamos si este producto está excluido por FILTER
. Si el FILTER
está vacío, imprimiremos el producto. Si FILTER
no está vacío, iteraremos a través de las categorías del producto hasta que encontremos uno que esté contenido dentro de FILTER
. Hacemos esto usando dale.stop.
var matches = (! FILTER || FILTER.length === 0) || dale.stop (product.categories, true, function (category) { return FILTER.indexOf (category) !== -1; });
Note la complejidad del condicional; se adapta con precisión a nuestros requisitos y tenemos total libertad para expresarlo porque estamos en un lenguaje de programación en lugar de un DSL.
Si las matches
son false
, devolvemos una matriz vacía (por lo que no imprimimos este producto). De lo contrario, devolvemos un <tr>
con su id y clase adecuados e iteramos a través de printableFields
para, bueno, imprimir los campos.
return matches === false ? [] : ['tr', { id: product.id, class: product.onSale ? 'onsale' : undefined }, dale.do (printableFields, function (field) { return ['td', product [field]];
Por supuesto cerramos todo lo que abrimos. ¿No es divertida la sintaxis?
})]; }) ]]; }
Ahora, ¿cómo incorporamos esta tabla en un contexto más amplio? Escribimos una función llamada drawAll
que invocará todas las funciones que generan las vistas. Además de drawTable
, también podríamos tener drawHeader
, drawFooter
y otras funciones comparables, todas las cuales devolverán dsDSL .
var drawAll = function () { return generate ([ drawHeader (), drawTable (DATA, FILTER), drawFooter () ]); }
Si no te gusta cómo se ve el código anterior, nada de lo que diga te convencerá. Este es un dsDSL en su mejor momento . You might as well stop reading the article (and drop a mean comment too because you've earned the right to do so if you've made it this far!). But seriously, if the code above doesn't strike you as elegant, nothing else in this article will.
For those who are still with me, I would like to go back to the main claim of this section, which is that a dsDSL has the advantages of both the high and the low level :
- The advantage of the low level resides in writing code whenever we want, getting out of the straightjacket of the DSL.
- The advantage of the high level resides in using literals that represent what we want to declare and letting the functions of the tool convert that into the desired end state (in this case, a string with HTML).
But how is this truly different from purely imperative code? I think ultimately the elegance of the dsDSL approach boils down to the fact that code written in this way mostly consists of expressions, instead of statements. More precisely, code that uses a dsDSL is almost entirely composed of:
- Literals that map to lower level structures.
- Function invocations or lambdas within those literal structures that return structures of the same kind.
Code that consists mostly of expressions and which encapsulate most statements within functions is extremely succinct because all patterns of repetition can be easily abstracted. You can write arbitrary code as long as that code returns a literal that conforms to a very specific, non-arbitrary form.
A further characteristic of dsDSLs (which we don't have time to explore here) is the possibility of using types to increase the richness and succinctness of the literal structures. I will expound on this issue on a future article.
Might it be possible to create dsDSLs beyond Javascript, the One True Language? I think that it is, indeed, possible, as long as the language supports:
- Literals for: arrays, objects (associative arrays), function invocations, and lambdas.
- Runtime type detection
- Polymorphism and dynamic return types
I think this means that dsDSLs are tenable in any modern dynamic language (ie: Ruby, Python, Perl, PHP), but probably not in C or Java.
Walk, Then Slide: How To Unfold The High From The Low
In this section I will attempt to show a way for unfolding a high level tool from its domain. In a nutshell, the approach consists of the following steps
- Take two to four problems that are representative instances of a problem domain. These problems should be real. Unfolding the high level from the low one is a problem of induction, so you need real data to come up with representative solutions.
- Solve the problems with no tool in the most straightforward way possible.
- Stand back, take a good look at your solutions, and notice the common patterns among them.
- Find the patterns of representation (high level).
- Find the patterns of generation (low level).
- Solve the same problems with your high level layer and verify that the solutions are indeed correct.
- If you feel that you can easily represent all the problems with your patterns of representation, and the generation patterns for each of these instances produce correct implementations, you're done. Otherwise, go back to the drawing board.
- If new problems appear, solve them with the tool and modify it accordingly.
- The tool should converge asymptotically to a finished state, no matter how many problems it solves. In other words, the complexity of the tool should remain constant, rather than growing with the amount of problems it solves.
Now, what the hell are patterns of representation and patterns of generation ? I'm glad you asked. The patterns of representation are the patterns in which you should be able to express a problem that belongs to the domain that concerns your tool. It is an alphabet of structures that allows you to write any pattern you might wish to express within its domain of applicability. In a DSL, these would be the production rules. Let's go back to our dsDSL for generating HTML.
The patterns of representation for HTML are the following:
- A single tag:
['TAG']
- A single tag with attributes:
['TAG', {attribute1: value1, attribute2: value2, ...}]
- A single tag with contents:
['TAG', 'CONTENTS']
- A single tag with both attributes and contents:
['TAG', {attribute1: value1, ...}, 'CONTENTS']
- A single tag with another tag inside:
['TAG1', ['TAG2', ...]]
- A group of tags (standalone or inside another tag):
[['TAG1', ...], ['TAG2', ...]]
- Depending on a condition, place a tag or no tag:
condition ? ['TAG', ...] : []
/ Depending on a condition, place an attribute or no attribute:['TAG', {class: condition ? 'someClass': undefined}, ...]
These instances can be represented with the dsDSL notation we determined in the previous section. And this is all you need to represent any HTML you might need. More sophisticated patterns, such as conditional iteration through an object to generate a table, may be implemented with functions that return the patterns of representation above, and these patterns map directly to HTML tags.
If the patterns of representation are the structures you use to express what you want, the patterns of generation are the structures your tool will use to convert patterns of representation into the lower level structures. For HTML, these are the following:
- Validate the input (this is actually is an universal pattern of generation).
- Open and close tags (but not the void tags, like
<input>
, which are self-closing). - Place attributes and contents, escaping special characters (but not the contents of the
<style>
and<script>
tags).
Believe it or not, these are the patterns you need to create an unfolding dsDSL layer that generates HTML. Similar patterns can be found for generating CSS. In fact, lith does both, in ~250 lines of code.
One last question remains to be answered: What do I mean by walk, then slide ? When we deal with a problem domain, we want to use a tool that delivers us from the nasty details of that domain. In other words, we want to sweep the low level under the rug, the faster the better. The walk, then slide approach proposes exactly the opposite: spend some time on the low level. Embrace its quirks, and understand which are essential and which can be avoided in the face of a set of real, varied, and useful problems.
After walking in the low level for some time and solving useful problems, you will have a sufficiently deep understanding of their domain. The patterns of representation and generation will then arise naturally; they are wholly derived from the nature of the problem they intend to solve. You can then write code that employs them. If they work, you will be able to slide through problems where you recently had to walk through them. Sliding means many things; it implies speed, precision and lack of friction. Maybe more importantly, this quality can be felt; when solving problems with this tool, do you feel like you're walking through the problem, or do you feel that you're sliding through it?
Maybe the most important thing about an unfolded tool is not the fact that it frees us from having to deal with the low level. Rather, by capturing the empiric patterns of repetition in the low level, a good high level tool allows us to understand fully the domain of applicability.
An unfolded tool will not just solve a problem - it will enlighten you about the problem's structure.
So, don't run away from a worthy problem. First walk around it, then slide through it.