Programmation déclarative : est-ce une vraie chose ?

Publié: 2022-03-11

La programmation déclarative est actuellement le paradigme dominant d'un ensemble étendu et diversifié de domaines tels que les bases de données, les modèles et la gestion de la configuration.

En un mot, la programmation déclarative consiste à indiquer à un programme ce qui doit être fait, au lieu de lui dire comment le faire. En pratique, cette approche consiste à fournir un langage spécifique au domaine (DSL) pour exprimer ce que l'utilisateur veut et le protéger des constructions de bas niveau (boucles, conditions, affectations) qui matérialisent l'état final souhaité.

Bien que ce paradigme soit une amélioration remarquable par rapport à l'approche impérative qu'il a remplacée, je soutiens que la programmation déclarative a des limitations importantes, limitations que j'explore dans cet article. De plus, je propose une double approche qui capture les avantages de la programmation déclarative tout en dépassant ses limites.

CAVEAT : Cet article est né d'un combat personnel de plusieurs années avec les outils déclaratifs. Bon nombre des affirmations que je présente ici ne sont pas complètement prouvées, et certaines sont même présentées à leur valeur nominale. Une bonne critique de la programmation déclarative prendrait beaucoup de temps, d'efforts, et je devrais revenir en arrière et utiliser bon nombre de ces outils ; mon cœur n'est pas dans une telle entreprise. Le but de cet article est de partager quelques réflexions avec vous, sans prendre de risques et de montrer ce qui a fonctionné pour moi. Si vous avez eu des difficultés avec les outils de programmation déclarative, vous pourriez trouver un répit et des alternatives. Et si vous aimez le paradigme et ses outils, ne me prenez pas trop au sérieux.

Si la programmation déclarative fonctionne bien pour vous, je ne suis pas en mesure de vous dire le contraire .

Vous pouvez aimer ou détester la programmation déclarative, mais vous ne pouvez pas vous permettre de l'ignorer.
Tweeter

Les mérites de la programmation déclarative

Avant d'explorer les limites de la programmation déclarative, il est nécessaire de comprendre ses mérites.

L'outil de programmation déclarative le plus réussi est sans doute la base de données relationnelle (RDB). Ce pourrait même être le premier outil déclaratif. Dans tous les cas, les RDB présentent les deux propriétés que je considère comme archétypales de la programmation déclarative :

  • Un langage spécifique au domaine (DSL) : l'interface universelle pour les bases de données relationnelles est un DSL nommé Structured Query Language, plus communément appelé SQL.
  • Le DSL cache la couche de niveau inférieur à l'utilisateur : depuis l'article original d'Edgar F. Codd sur les RDB, il est clair que la puissance de ce modèle est de dissocier les requêtes souhaitées des boucles, index et chemins d'accès sous-jacents qui les implémentent.

Avant les RDB, la plupart des systèmes de bases de données étaient accessibles via un code impératif, qui dépend fortement de détails de bas niveau tels que l'ordre des enregistrements, les index et les chemins physiques vers les données elles-mêmes. Étant donné que ces éléments changent avec le temps, le code cesse souvent de fonctionner en raison d'un changement sous-jacent dans la structure des données. Le code résultant est difficile à écrire, difficile à déboguer, difficile à lire et difficile à maintenir. Je vais sortir un membre et dire que la majeure partie de ce code était, selon toute vraisemblance, longue, pleine de nids de rats proverbiaux de conditionnels, de répétitions et de bogues subtils dépendant de l'état.

Face à cela, les RDB ont fourni un énorme bond de productivité aux développeurs de systèmes. Désormais, au lieu de milliers de lignes de code impératif, vous disposiez d'un schéma de données clairement défini, ainsi que de centaines (voire de dizaines) de requêtes. En conséquence, les applications n'avaient qu'à gérer une représentation abstraite, significative et durable des données, et à les interfacer via un langage de requête puissant mais simple. Le RDB a probablement augmenté la productivité des programmeurs et des entreprises qui les employaient, d'un ordre de grandeur.

Quels sont les avantages couramment cités de la programmation déclarative ?

Les avantages de la programmation déclarative sont listés ci-dessous, mais chacun avec une icône représentative.

Les partisans de la programmation déclarative n'hésitent pas à en souligner les avantages. Cependant, même eux admettent que cela implique des compromis.
Tweeter
  1. Lisibilité/utilisabilité : un DSL est généralement plus proche d'un langage naturel (comme l'anglais) que d'un pseudocode, donc plus lisible et aussi plus facile à apprendre par des non-programmeurs.
  2. Concision : une grande partie du passe-partout est abstraite par le DSL, laissant moins de lignes pour faire le même travail.
  3. Réutilisation : il est plus facile de créer du code qui peut être utilisé à différentes fins ; quelque chose qui est notoirement difficile lors de l'utilisation de constructions impératives.
  4. Idempotence : vous pouvez travailler avec des états finaux et laisser le programme le résoudre pour vous. Par exemple, via une opération upsert, vous pouvez soit insérer une ligne si elle n'y est pas, soit la modifier si elle y est déjà, au lieu d'écrire du code pour traiter les deux cas.
  5. Récupération d'erreur : il est facile de spécifier une construction qui s'arrêtera à la première erreur au lieu d'avoir à ajouter des écouteurs d'erreur pour chaque erreur possible. (Si vous avez déjà écrit trois rappels imbriqués dans node.js, vous savez ce que je veux dire.)
  6. Transparence référentielle : bien que cet avantage soit couramment associé à la programmation fonctionnelle, il est en fait valable pour toute approche minimisant la manipulation manuelle des états et s'appuyant sur les effets de bord.
  7. Commutativité : la possibilité d'exprimer un état final sans avoir à préciser l'ordre réel dans lequel il sera mis en œuvre.

Bien que les avantages ci-dessus soient tous couramment cités pour la programmation déclarative, je voudrais les condenser en deux qualités, qui serviront de principes directeurs lorsque je proposerai une approche alternative.

  1. Une couche de haut niveau adaptée à un domaine spécifique : la programmation déclarative crée une couche de haut niveau en utilisant les informations du domaine auquel elle s'applique. Il est clair que si nous avons affaire à des bases de données, nous voulons un ensemble d'opérations pour traiter les données. La plupart des sept avantages ci-dessus proviennent de la création d'une couche de haut niveau qui est précisément adaptée à un domaine de problème spécifique.
  2. Poka-yoke (infaillible) : une couche de haut niveau adaptée au domaine cache les détails impératifs de la mise en œuvre. Cela signifie que vous commettez beaucoup moins d'erreurs car les détails de bas niveau du système ne sont tout simplement pas accessibles. Cette limitation élimine de nombreuses classes d'erreurs de votre code.

Deux problèmes avec la programmation déclarative

Dans les deux sections suivantes, je présenterai les deux principaux problèmes de la programmation déclarative : la séparation et l' absence de déploiement . Chaque critique a besoin de son croque-mitaine, j'utiliserai donc les systèmes de modèles HTML comme exemple concret des lacunes de la programmation déclarative.

Le problème avec les DSL : la séparation

Imaginez que vous ayez besoin d'écrire une application Web avec un nombre non négligeable de vues. Le codage en dur de ces vues dans un ensemble de fichiers HTML n'est pas une option car de nombreux composants de ces pages changent.

La solution la plus simple, qui consiste à générer du HTML en concaténant des chaînes, semble si horrible que vous chercherez rapidement une alternative. La solution standard consiste à utiliser un système de modèles. Bien qu'il existe différents types de systèmes de modèles, nous éviterons leurs différences aux fins de cette analyse. Nous pouvons considérer qu'ils sont tous similaires dans la mesure où la mission principale des systèmes de modèles est de fournir une alternative au code qui concatène les chaînes HTML à l'aide de conditions et de boucles, tout comme les RDB sont apparus comme une alternative au code qui parcourait les enregistrements de données.

Supposons que nous utilisions un système de modèles standard ; vous rencontrerez trois sources de friction, que je vais énumérer par ordre croissant d'importance. La première est que le modèle réside nécessairement dans un fichier distinct de votre code. Étant donné que le système de modèles utilise un DSL, la syntaxe est différente et ne peut donc pas se trouver dans le même fichier. Dans les projets simples, où le nombre de fichiers est faible, la nécessité de conserver des fichiers modèles séparés peut dupliquer ou tripler le nombre de fichiers.

J'ouvre une exception pour les modèles Embedded Ruby (ERB), car ceux -ci sont intégrés au code source Ruby. Ce n'est pas le cas pour les outils inspirés de l'ERB écrits dans d'autres langages, car ces modèles doivent également être stockés dans des fichiers différents.

La deuxième source de friction est que le DSL a sa propre syntaxe, différente de celle de votre langage de programmation. Par conséquent, modifier le DSL (sans parler d'écrire le vôtre) est considérablement plus difficile. Pour passer sous le capot et changer d'outil, vous devez vous familiariser avec la tokenisation et l'analyse, ce qui est intéressant et stimulant, mais difficile. Il se trouve que je vois cela comme un inconvénient.

Vous pouvez demander : « Pourquoi diable voudriez-vous modifier votre outil ? Si vous faites un projet standard, un outil standard bien écrit devrait faire l'affaire. Peut-être que oui, peut-être que non.

Un DSL n'a jamais la pleine puissance d'un langage de programmation. Si c'était le cas, ce ne serait plus un DSL, mais plutôt un langage de programmation complet.

Mais n'est-ce pas là tout l'intérêt d'un DSL ? Ne pas disposer de toute la puissance d'un langage de programmation pour pouvoir réaliser l'abstraction et éliminer la plupart des sources de bugs ? Peut-être oui. Cependant, la plupart des DSL commencent simplement, puis intègrent progressivement un nombre croissant de fonctionnalités d'un langage de programmation jusqu'à ce qu'il en devienne un. Les systèmes de modèles en sont un parfait exemple. Voyons les fonctionnalités standard des systèmes de modèles et leur corrélation avec les fonctionnalités du langage de programmation :

  • Remplacer du texte dans un modèle : substitution de variable.
  • Répétition d'un modèle : boucles.
  • Évitez d'imprimer un modèle si une condition n'est pas remplie : les conditionnels.
  • Partiels : sous-programmes.
  • Helpers : sous-programmes (la seule différence avec les partiels est que les helpers peuvent accéder au langage de programmation sous-jacent et vous faire sortir du carcan DSL).

Cet argument, selon lequel un DSL est limité parce qu'il convoite et rejette simultanément la puissance d'un langage de programmation, est directement proportionnel à la mesure dans laquelle les fonctionnalités du DSL sont directement mappables aux fonctionnalités d'un langage de programmation . Dans le cas de SQL, l'argument est faible car la plupart des choses qu'offre SQL ne ressemblent en rien à ce que vous trouvez dans un langage de programmation normal. À l'autre extrémité du spectre, nous trouvons des modèles de systèmes où pratiquement toutes les fonctionnalités font converger le DSL vers le BASIC.

Prenons maintenant du recul et contemplons ces trois sources de friction quintessentielles, résumées par le concept de séparation . Parce qu'il est séparé, un DSL doit être situé sur un fichier séparé ; il est plus difficile à modifier (et encore plus difficile d'écrire le vôtre) et nécessite (souvent, mais pas toujours) que vous ajoutiez, une par une, les fonctionnalités qui vous manquent dans un vrai langage de programmation.

La séparation est un problème inhérent à tout DSL, aussi bien conçu soit-il.

Nous nous tournons maintenant vers un deuxième problème des outils déclaratifs, qui est répandu mais pas inhérent.

Un autre problème : le manque de déploiement conduit à la complexité

Si j'avais écrit cet article il y a quelques mois, cette section aurait été nommée Les outils les plus déclaratifs sont #@!$#@! Complexe mais je ne sais pas pourquoi . En écrivant cet article, j'ai trouvé une meilleure façon de le dire : la plupart des outils déclaratifs sont bien plus complexes qu'ils ne devraient l'être . Je passerai le reste de cette section à expliquer pourquoi. Pour analyser la complexité d'un outil, je propose une mesure appelée l' écart de complexité . L'écart de complexité est la différence entre la résolution d'un problème donné avec un outil et sa résolution au niveau inférieur (vraisemblablement, du code impératif simple) que l'outil a l'intention de remplacer. Lorsque la première solution est plus complexe que la seconde, on est en présence de l'écart de complexité. Par plus complexe , je veux dire plus de lignes de code, du code plus difficile à lire, plus difficile à modifier et plus difficile à maintenir, mais pas nécessairement tout cela en même temps.

Veuillez noter que nous ne comparons pas la solution de niveau inférieur au meilleur outil possible, mais plutôt à aucun outil. Cela fait écho au principe médical du « d'abord, ne pas nuire » .

Les signes d'un outil avec un grand écart de complexité sont :

  • Quelque chose qui prend quelques minutes à décrire en détail en termes impératifs prendra des heures à coder à l'aide de l'outil, même lorsque vous savez comment utiliser l'outil.
  • Vous sentez que vous travaillez constamment autour de l'outil plutôt qu'avec l'outil.
  • Vous avez du mal à résoudre un problème simple qui appartient carrément au domaine de l'outil que vous utilisez, mais la meilleure réponse Stack Overflow que vous trouvez décrit une solution de contournement .
  • Lorsque ce problème très simple pourrait être résolu par une certaine fonctionnalité (qui n'existe pas dans l'outil) et que vous voyez un problème Github dans la bibliothèque qui présente une longue discussion sur ladite fonctionnalité avec +1 entrecoupés.
  • Une envie chronique, démangeaisons, d'abandonner l'outil et de tout faire soi-même à l'intérieur d'une _boucle for_.

J'ai peut-être été la proie de l'émotion ici puisque les systèmes de modèles ne sont pas si complexes, mais cet écart de complexité relativement faible n'est pas un mérite de leur conception, mais plutôt parce que le domaine d'applicabilité est assez simple (rappelez-vous, nous ne faisons que générer du HTML ici ). Chaque fois que la même approche est utilisée pour un domaine plus complexe (tel que la gestion de la configuration), l'écart de complexité peut rapidement transformer votre projet en bourbier.

Cela dit, il n'est pas forcément inacceptable qu'un outil soit un peu plus complexe que le niveau inférieur qu'il entend remplacer ; si l'outil produit un code plus lisible, concis et correct, cela peut en valoir la peine. C'est un problème lorsque l'outil est plusieurs fois plus complexe que le problème qu'il remplace ; c'est carrément inacceptable. Brian Kernighan a déclaré que « le contrôle de la complexité est l'essence même de la programmation informatique. « Si un outil ajoute une complexité importante à votre projet, pourquoi même l'utiliser ?

La question est la suivante : pourquoi certains outils déclaratifs sont-ils tellement plus complexes qu'ils ne devraient l'être ? Je pense que ce serait une erreur de blâmer une mauvaise conception. Une explication aussi générale, une attaque généralisée ad-hominem contre les auteurs de ces outils, n'est pas juste. Il doit y avoir une explication plus précise et plus éclairante.

Mon argument est que tout outil qui offre une interface de haut niveau pour abstraire un niveau inférieur doit déplier ce niveau supérieur à partir du niveau inférieur. Le concept de déploiement vient du magnum opus de Christopher Alexander, The Nature of Order - en particulier le volume II. Il est (désespérément) au-delà de la portée de cet article (sans parler de ma compréhension) de résumer les implications de ce travail monumental pour la conception de logiciels ; Je pense que son impact sera énorme dans les années à venir. Il est également au-delà de cet article de fournir une définition rigoureuse des processus de déploiement. J'utiliserai ici le concept de manière heuristique.

Un processus de déploiement est un processus qui, par étapes, crée une structure supplémentaire sans nier celle qui existe déjà. A chaque étape, chaque changement (ou différenciation, pour reprendre le terme d'Alexandre) reste en harmonie avec toute structure précédente, alors que la structure précédente est simplement une séquence cristallisée de changements passés.

Chose intéressante, Unix est un excellent exemple du déploiement d'un niveau supérieur à partir d'un niveau inférieur. Sous Unix, deux fonctionnalités complexes du système d'exploitation, les travaux par lots et les coroutines (tubes), sont simplement des extensions des commandes de base. En raison de certaines décisions de conception fondamentales, comme faire de tout un flux d'octets, le shell étant un programme utilisateur et des fichiers d'E / S standard, Unix est en mesure de fournir ces fonctionnalités sophistiquées avec une complexité minimale.

Pour souligner pourquoi ce sont d'excellents exemples de déploiement, je voudrais citer quelques extraits d'un article de 1979 de Dennis Ritchie, l'un des auteurs d'Unix :

Sur les jobs batch :

… le nouveau schéma de contrôle de processus a instantanément rendu certaines fonctionnalités très précieuses faciles à mettre en œuvre ; par exemple processus détachés (avec & ) et utilisation récursive du shell comme commande. La plupart des systèmes doivent fournir une sorte de batch job submission et un interpréteur de commandes spécial pour les fichiers distincts de celui utilisé de manière interactive.

Sur les coroutines :

Le génie du pipeline Unix est précisément qu'il est construit à partir des mêmes commandes utilisées constamment en mode simplex.

Cette élégance et cette simplicité, selon moi, proviennent d'un processus de déploiement . Les travaux par lots et les coroutines sont dépliés à partir des structures précédentes (commandes exécutées dans un shell userland). Je crois qu'en raison de la philosophie minimaliste et des ressources limitées de l'équipe qui a créé Unix, le système a évolué par étapes et, en tant que tel, a pu incorporer des fonctionnalités avancées sans tourner le dos aux fonctionnalités de base car il n'y avait pas assez de ressources pour faire autrement.

En l'absence d'un processus de déploiement, le haut niveau sera considérablement plus complexe que nécessaire. En d'autres termes, la complexité de la plupart des outils déclaratifs vient du fait que leur niveau haut ne se déploie pas à partir du niveau bas qu'ils entendent remplacer.

Cette absence de déploiement , si l'on pardonne le néologisme, est systématiquement justifiée par la nécessité de protéger l'utilisateur du niveau inférieur. Cet accent mis sur le poka-yoke (protéger l'utilisateur contre les erreurs de bas niveau) se fait au détriment d'un grand écart de complexité qui est autodestructeur car la complexité supplémentaire générera de nouvelles classes d'erreurs. Pour ajouter l'insulte à l'injure, ces classes d'erreurs n'ont rien à voir avec le domaine du problème mais plutôt avec l'outil lui-même. On n'irait pas trop loin si on qualifiait ces erreurs d'iatrogènes.

Les outils de templates déclaratifs, du moins lorsqu'ils sont appliqués à la tâche de génération de vues HTML, sont un cas archétypique d'un haut niveau qui tourne le dos au bas niveau qu'il entend remplacer. Comment? Parce que générer une vue non triviale nécessite une logique et des systèmes de modèles, en particulier ceux sans logique, bannissez la logique par la porte principale, puis en réintroduisez une partie par la porte du chat.

Remarque : Une justification encore plus faible pour un écart de complexité important est lorsqu'un outil est commercialisé comme magique , ou quelque chose qui fonctionne , l'opacité du bas niveau est censée être un atout car un outil magique est toujours censé fonctionner sans que vous le compreniez pourquoi ou comment. D'après mon expérience, plus un outil prétend être magique, plus il transforme rapidement mon enthousiasme en frustration.

Mais qu'en est-il de la séparation des préoccupations ? La vue et la logique ne devraient-elles pas rester séparées ? L'erreur principale, ici, est de mettre la logique métier et la logique de présentation dans le même sac. La logique métier n'a certes pas sa place dans un template, mais la logique de présentation existe néanmoins. L'exclusion de la logique des modèles pousse la logique de présentation dans le serveur où elle est maladroitement hébergée. Je dois la formulation claire de ce point à Alexei Boronine, qui en fait un excellent cas dans cet article.

Mon sentiment est qu'environ les deux tiers du travail d'un modèle résident dans sa logique de présentation, tandis que l'autre tiers traite de problèmes génériques tels que la concaténation de chaînes, la fermeture de balises, l'échappement de caractères spéciaux, etc. C'est la nature à deux faces de bas niveau de la génération de vues HTML. Les systèmes de modèles traitent de manière appropriée la seconde moitié, mais ils ne fonctionnent pas bien avec la première. Les modèles sans logique tournent carrément le dos à ce problème, vous obligeant à le résoudre maladroitement. D'autres systèmes de modèles souffrent car ils ont vraiment besoin de fournir un langage de programmation non trivial pour que leurs utilisateurs puissent réellement écrire une logique de présentation.

Pour résumer; les outils de modèles déclaratifs souffrent parce que :

  • S'ils devaient se dérouler à partir de leur domaine problématique, ils devraient fournir des moyens de générer des modèles logiques ;
  • Un DSL qui fournit une logique n'est pas vraiment un DSL, mais un langage de programmation. Notez que d'autres domaines, comme la gestion de la configuration, souffrent également d'un manque de "dépliage".

Je voudrais clore la critique par un argument logiquement déconnecté du fil de cet article, mais qui résonne profondément avec son noyau émotionnel : Nous avons peu de temps pour apprendre. La vie est courte et en plus il faut travailler. Face à nos limites, nous devons passer notre temps à apprendre des choses qui seront utiles et qui résisteront au temps, même face à l'évolution rapide de la technologie. C'est pourquoi je vous exhorte à utiliser des outils qui ne se contentent pas d'apporter une solution, mais qui éclairent réellement le domaine de sa propre applicabilité. Les RDB vous enseignent les données et Unix vous enseigne les concepts du système d'exploitation, mais avec des outils insatisfaisants qui ne se déroulent pas, j'ai toujours eu l'impression d'apprendre les subtilités d'une solution sous-optimale tout en restant dans l'ignorance de la nature du problème il entend résoudre.

L'heuristique que je vous suggère de prendre en compte est de valoriser les outils qui éclairent leur domaine problématique, au lieu d'outils qui obscurcissent leur domaine problématique derrière de prétendues fonctionnalités .

L'approche jumelle

Pour pallier les deux problèmes de la programmation déclarative, que j'ai présentés ici, je propose une double approche :

  • Utilisez un langage spécifique au domaine de structure de données (dsDSL) pour surmonter la séparation.
  • Créez un niveau supérieur qui se déroule à partir du niveau inférieur, pour surmonter l'écart de complexité.

dsDSL

Un DSL à structure de données (dsDSL) est un DSL construit avec les structures de données d'un langage de programmation . L'idée de base est d'utiliser les structures de données de base dont vous disposez, telles que les chaînes, les nombres, les tableaux, les objets et les fonctions, et de les combiner pour créer des abstractions pour traiter un domaine spécifique.

Nous voulons garder le pouvoir de déclarer des structures ou des actions (haut niveau) sans avoir à spécifier les modèles qui implémentent ces constructions (bas niveau). Nous voulons surmonter la séparation entre le DSL et notre langage de programmation afin que nous soyons libres d'utiliser toute la puissance d'un langage de programmation chaque fois que nous en avons besoin. Ce n'est pas seulement possible mais simple grâce aux dsDSL.

Si vous me l'aviez demandé il y a un an, j'aurais pensé que le concept de dsDSL était nouveau, puis un jour, j'ai réalisé que JSON lui-même était un parfait exemple de cette approche ! Un objet JSON analysé se compose de structures de données qui représentent de manière déclarative des entrées de données afin d'obtenir les avantages du DSL tout en facilitant l'analyse et la gestion à partir d'un langage de programmation. (Il existe peut-être d'autres dsDSL, mais jusqu'à présent, je n'en ai rencontré aucun. Si vous en connaissez un, j'apprécierais vraiment que vous le mentionniez dans la section des commentaires.)

Comme JSON, un dsDSL possède les attributs suivants :

  1. Il se compose d'un très petit ensemble de fonctions : JSON a deux fonctions principales, parse et stringify .
  2. Ses fonctions reçoivent le plus souvent des arguments complexes et récursifs : un JSON analysé est un tableau, ou un objet, qui contient généralement d'autres tableaux et objets à l'intérieur.
  3. Les entrées de ces fonctions se conforment à des formes très spécifiques : JSON a un schéma de validation explicite et strictement appliqué pour distinguer les structures valides des structures non valides.
  4. Les entrées et les sorties de ces fonctions peuvent être contenues et générées par un langage de programmation sans syntaxe distincte.

Mais les dsDSL vont au-delà de JSON à bien des égards. Créons un dsDSL pour générer du HTML en utilisant Javascript. Plus tard, j'aborderai la question de savoir si cette approche peut être étendue à d'autres langages (spoiler : cela peut certainement être fait en Ruby et Python, mais probablement pas en C).

HTML est un langage de balisage composé de tags délimitées par des chevrons ( < et > ). Ces balises peuvent avoir des attributs et des contenus facultatifs. Les attributs sont simplement une liste d'attributs clé/valeur, et le contenu peut être du texte ou d'autres balises. Les attributs et le contenu sont facultatifs pour une balise donnée. Je simplifie un peu, mais c'est juste.

Une façon simple de représenter une balise HTML dans un dsDSL consiste à utiliser un tableau à trois éléments : - Balise : une chaîne. - Attributs : un objet (de type brut, clé/valeur) ou undefined (si aucun attribut n'est nécessaire). - Contenu : une chaîne (texte), un tableau (une autre balise) ou undefined (s'il n'y a pas de contenu).

Par exemple, <a href="views">Index</a> peut être écrit comme ['a', {href: 'views'}, 'Index'] .

Si nous voulons intégrer cet élément d'ancrage dans un div avec des links de classe, nous pouvons écrire : ['div', {class: 'links'}, ['a', {href: 'views'}, 'Index']] .

Pour lister plusieurs balises html au même niveau, on peut les regrouper dans un tableau :

 [ ['h1', 'Hello!'], ['a', {href: 'views'}, 'Index'] ]

Le même principe peut être appliqué à la création de plusieurs balises dans une balise :

 ['body', [ ['h1', 'Hello!'], ['a', {href: 'views'}, 'Index'] ]]

Bien sûr, ce dsDSL ne nous mènera pas loin si nous ne générons pas de code HTML à partir de celui-ci. Nous avons besoin d'une fonction de generate qui prendra notre dsDSL et produira une chaîne avec HTML. Donc, si nous exécutons generate (['a', {href: 'views'}, 'Index']) , nous obtiendrons la chaîne <a href="views">Index</a> .

L'idée derrière tout DSL est de spécifier quelques constructions avec une structure spécifique qui est ensuite transmise à une fonction. Dans ce cas, la structure qui compose le dsDSL est ce tableau, qui comporte un à trois éléments ; ces tableaux ont une structure spécifique. Si generate valide complètement son entrée (et il est à la fois facile et important de valider complètement l'entrée, puisque ces règles de validation sont l'analogue précis de la syntaxe d'un DSL), il vous dira exactement où vous vous êtes trompé avec votre entrée. Après un certain temps, vous commencerez à reconnaître ce qui distingue une structure valide dans un dsDSL, et cette structure sera très suggestive de la chose sous-jacente qu'elle génère.

Maintenant, quels sont les mérites d'un dsDSL par rapport à un DSL ?

  • Un dsDSL fait partie intégrante de votre code. Cela entraîne une diminution du nombre de lignes, du nombre de fichiers et une réduction globale des frais généraux.
  • Les dsDSL sont faciles à analyser (donc plus faciles à mettre en œuvre et à modifier). L'analyse consiste simplement à parcourir les éléments d'un tableau ou d'un objet. De même, les dsDSL sont relativement faciles à concevoir car au lieu de créer une nouvelle syntaxe (que tout le monde détestera), vous pouvez vous en tenir à la syntaxe de votre langage de programmation (que tout le monde déteste mais au moins ils la connaissent déjà).
  • Un dsDSL a toute la puissance d'un langage de programmation. Cela signifie qu'un dsDSL, lorsqu'il est correctement utilisé, a l'avantage d'être à la fois un outil de haut niveau et un outil de bas niveau.

Maintenant, la dernière affirmation est forte, donc je vais passer le reste de cette section à la soutenir. Qu'est-ce que j'entends par bien employé ? Pour voir cela en action, considérons un exemple dans lequel nous voulons construire une table pour afficher les informations d'un tableau nommé 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']} ]

Dans une application réelle, les DATA seront générées dynamiquement à partir d'une requête de base de données.

De plus, nous avons une variable FILTER qui, une fois initialisée, sera un tableau avec les catégories que nous voulons afficher.

Nous voulons que notre table :

  • Afficher les en-têtes de tableau.
  • Pour chaque produit, affichez les champs : description, prix et catégories.
  • N'imprimez pas le champ id , mais ajoutez-le en tant qu'attribut id pour chaque ligne. VERSION ALTERNATIVE : Ajoutez un attribut id à chaque élément tr .
  • Placez une classe onSale si le produit est en solde.
  • Trier les produits par prix décroissant.
  • Filtrez certains produits par catégorie. Si FILTER est un tableau vide, nous afficherons tous les produits. Sinon, nous n'afficherons que les produits dont la catégorie de produit est contenue dans FILTER .

Nous pouvons créer la logique de présentation qui correspond à cette exigence dans environ 20 lignes de code :

 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]]; })]; }) ]]; }

Je reconnais que ce n'est pas un exemple simple, cependant, il représente une vue assez simple des quatre fonctions de base du stockage persistant, également connu sous le nom de CRUD. Toute application Web non triviale aura des vues plus complexes que cela.

Voyons maintenant ce que fait ce code. Tout d'abord, il définit une fonction, drawTable , pour contenir la logique de présentation du dessin de la table des produits. Cette fonction reçoit DATA et FILTER comme paramètres, elle peut donc être utilisée pour différents ensembles de données et filtres. drawTable remplit le double rôle de partial et d'assistant.

 var drawTable = function (DATA, FILTER) {

La variable interne, printableFields , est le seul endroit où vous devez spécifier quels champs sont imprimables, évitant les répétitions et les incohérences face à l'évolution des exigences.

 var printableFields = ['description', 'price', 'categories'];

Nous trions ensuite les DATA en fonction du prix de ses produits. Notez que des critères de tri différents et plus complexes seraient simples à mettre en œuvre puisque nous avons tout le langage de programmation à notre disposition.

 DATA.sort (function (a, b) {return a.price - b.price});

Ici, nous renvoyons un littéral d'objet ; un tableau qui contient table comme premier élément et son contenu comme second. C'est la représentation dsDSL de la <table> que nous voulons créer.

 return ['table', [

Nous créons maintenant une ligne avec les en-têtes de tableau. Pour créer son contenu, nous utilisons dale.do qui est une fonction comme Array.map, mais qui fonctionne aussi pour les objets. Nous allons itérer printableFields et générer des en-têtes de table pour chacun d'eux :

 ['tr', dale.do (printableFields, function (field) { return ['th', field]; })],

Notez que nous venons d'implémenter l'itération, le cheval de bataille de la génération HTML, et nous n'avons pas eu besoin de constructions DSL ; nous n'avions besoin que d'une fonction pour itérer une structure de données et renvoyer des dsDSL. Une fonction similaire native, ou implémentée par l'utilisateur, aurait également fait l'affaire.

Parcourez maintenant les produits contenus dans DATA .

 dale.do (DATA, function (product) {

Nous vérifions si ce produit est omis par FILTER . Si FILTER est vide, nous imprimerons le produit. Si FILTER n'est pas vide, nous parcourrons les catégories du produit jusqu'à ce que nous en trouvions une qui soit contenue dans FILTER . Nous faisons cela en utilisant dale.stop.

 var matches = (! FILTER || FILTER.length === 0) || dale.stop (product.categories, true, function (category) { return FILTER.indexOf (category) !== -1; });

Remarquez la complexité du conditionnel ; il est précisément adapté à notre besoin et nous avons une liberté totale pour l'exprimer car nous sommes dans un langage de programmation plutôt que dans un DSL.

Si matches est false , nous renvoyons un tableau vide (donc nous n'affichons pas ce produit). Sinon, nous renvoyons un <tr> avec son identifiant et sa classe appropriés et nous parcourons printableFields pour, eh bien, imprimer les champs.

 return matches === false ? [] : ['tr', { id: product.id, class: product.onSale ? 'onsale' : undefined }, dale.do (printableFields, function (field) { return ['td', product [field]];

Bien sûr, nous fermons tout ce que nous avons ouvert. La syntaxe n'est-elle pas amusante ?

 })]; }) ]]; }

Maintenant, comment intégrons-nous ce tableau dans un contexte plus large ? Nous écrivons une fonction nommée drawAll qui invoquera toutes les fonctions qui génèrent les vues. Outre drawTable , nous pourrions également avoir drawHeader , drawFooter et d'autres fonctions comparables, qui renverront toutes dsDSLs .

 var drawAll = function () { return generate ([ drawHeader (), drawTable (DATA, FILTER), drawFooter () ]); }

Si vous n'aimez pas l'apparence du code ci-dessus, rien de ce que je dirai ne vous convaincra. C'est un dsDSL à son meilleur . 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

  1. 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.
  2. Solve the problems with no tool in the most straightforward way possible.
  3. Stand back, take a good look at your solutions, and notice the common patterns among them.
  4. Find the patterns of representation (high level).
  5. Find the patterns of generation (low level).
  6. Solve the same problems with your high level layer and verify that the solutions are indeed correct.
  7. 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.
  8. If new problems appear, solve them with the tool and modify it accordingly.
  9. 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.

Breaking down an HTML snippet. The line

The humble HTML tag is a good example of patterns of representation. Let's take a closer look at these basic patterns.
Tweeter

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.

Related: Introduction To Concurrent Programming: A Beginner's Guide