Programmazione dichiarativa: è una cosa reale?

Pubblicato: 2022-03-11

La programmazione dichiarativa è, attualmente, il paradigma dominante di un insieme ampio e diversificato di domini come database, modelli e gestione della configurazione.

In poche parole, la programmazione dichiarativa consiste nell'istruire un programma su cosa deve essere fatto, invece di dirgli come farlo. In pratica, questo approccio comporta la fornitura di un linguaggio specifico del dominio (DSL) per esprimere ciò che l'utente desidera e proteggerlo dai costrutti di basso livello (loop, condizionali, assegnazioni) che materializzano lo stato finale desiderato.

Sebbene questo paradigma rappresenti un notevole miglioramento rispetto all'approccio imperativo che ha sostituito, sostengo che la programmazione dichiarativa ha limitazioni significative, limitazioni che esploro in questo articolo. Inoltre, propongo un duplice approccio che coglie i vantaggi della programmazione dichiarativa superandone i limiti.

AVVERTENZA : questo articolo è emerso come il risultato di una lotta personale pluriennale con gli strumenti dichiarativi. Molte delle affermazioni che presento qui non sono completamente provate e alcune sono persino presentate al valore nominale. Una corretta critica della programmazione dichiarativa richiederebbe molto tempo, sforzi e dovrei tornare indietro e utilizzare molti di questi strumenti; il mio cuore non è in una simile impresa. Lo scopo di questo articolo è condividere alcuni pensieri con te, senza tirare pugni e mostrare cosa ha funzionato per me. Se hai lottato con gli strumenti di programmazione dichiarativa, potresti trovare tregua e alternative. E se ti piace il paradigma e i suoi strumenti, non prendermi troppo sul serio.

Se la programmazione dichiarativa funziona bene per te, non sono nella posizione di dirti diversamente .

Puoi amare o odiare la programmazione dichiarativa, ma non puoi permetterti di ignorarla.
Twitta

I meriti della programmazione dichiarativa

Prima di esplorare i limiti della programmazione dichiarativa, è necessario comprenderne i meriti.

Probabilmente lo strumento di programmazione dichiarativa di maggior successo è il database relazionale (RDB). Potrebbe anche essere il primo strumento dichiarativo. In ogni caso, gli RDB mostrano le due proprietà che considero archetipiche della programmazione dichiarativa:

  • Un linguaggio specifico del dominio (DSL) : l'interfaccia universale per i database relazionali è un DSL chiamato Structured Query Language, più comunemente noto come SQL.
  • Il DSL nasconde all'utente lo strato di livello inferiore : sin dall'articolo originale di Edgar F. Codd sugli RDB, è chiaro che il potere di questo modello è di dissociare le query desiderate dai loop, dagli indici e dai percorsi di accesso sottostanti che li implementano.

Prima degli RDB, alla maggior parte dei sistemi di database si accedeva tramite codice imperativo, che dipende fortemente da dettagli di basso livello come l'ordine dei record, gli indici e i percorsi fisici dei dati stessi. Poiché questi elementi cambiano nel tempo, il codice spesso smette di funzionare a causa di alcune modifiche sottostanti nella struttura dei dati. Il codice risultante è difficile da scrivere, difficile da eseguire il debug, difficile da leggere e difficile da mantenere. Mi limiterò a dire che la maggior parte di questo codice era, con ogni probabilità, lungo, pieno di proverbiali nidi di topi di condizionali, ripetizioni e bug sottili e dipendenti dallo stato.

Di fronte a ciò, gli RDB hanno fornito un enorme salto di produttività per gli sviluppatori di sistemi. Ora, invece di migliaia di righe di codice imperativo, avevi uno schema di dati chiaramente definito, oltre a centinaia (o anche solo decine) di query. Di conseguenza, le applicazioni dovevano solo gestire una rappresentazione astratta, significativa e duratura dei dati e interfacciarla tramite un linguaggio di query potente ma semplice. L'RDB probabilmente ha aumentato di un ordine di grandezza la produttività dei programmatori e delle aziende che li hanno impiegati.

Quali sono i vantaggi comunemente elencati della programmazione dichiarativa?

I vantaggi della programmazione dichiarativa sono elencati di seguito, ma ciascuno con un'icona rappresentativa.

I fautori della programmazione dichiarativa si affrettano a sottolineare i vantaggi. Tuttavia, anche loro ammettono che si tratta di compromessi.
Twitta
  1. Leggibilità/usabilità : un DSL è solitamente più vicino a una lingua naturale (come l'inglese) che a uno pseudocodice, quindi più leggibile e anche più facile da imparare dai non programmatori.
  2. Succintezza : gran parte del boilerplate viene astratto dalla DSL, lasciando meno linee per fare lo stesso lavoro.
  3. Riutilizzo : è più semplice creare codice che può essere utilizzato per diversi scopi; qualcosa che è notoriamente difficile quando si usano costrutti imperativi.
  4. Idempotenza : puoi lavorare con gli stati finali e lasciare che il programma lo capisca per te. Ad esempio, tramite un'operazione di upsert, puoi inserire una riga se non c'è, o modificarla se è già presente, invece di scrivere codice per gestire entrambi i casi.
  5. Recupero degli errori : è facile specificare un costrutto che si fermerà al primo errore invece di dover aggiungere listener di errori per ogni possibile errore. (Se hai mai scritto tre callback nidificati in node.js, sai cosa intendo.)
  6. Trasparenza referenziale : sebbene questo vantaggio sia comunemente associato alla programmazione funzionale, è in realtà valido per qualsiasi approccio che riduca al minimo la gestione manuale dello stato e faccia affidamento su effetti collaterali.
  7. Commutatività : la possibilità di esprimere uno stato finale senza dover specificare l'ordine effettivo in cui verrà implementato.

Sebbene quanto sopra siano tutti vantaggi comunemente citati della programmazione dichiarativa, vorrei condensarli in due qualità, che serviranno da principi guida quando proporrò un approccio alternativo.

  1. Un livello di alto livello su misura per un dominio specifico : la programmazione dichiarativa crea un livello di alto livello utilizzando le informazioni del dominio a cui si applica. È chiaro che se abbiamo a che fare con database, vogliamo un insieme di operazioni per gestire i dati. La maggior parte dei sette vantaggi di cui sopra derivano dalla creazione di uno strato di alto livello che è esattamente su misura per un dominio specifico del problema.
  2. Poka-yoke (a prova di stupido) : uno strato di alto livello su misura per il dominio nasconde i dettagli imperativi dell'implementazione. Ciò significa che commetti molti meno errori perché i dettagli di basso livello del sistema semplicemente non sono accessibili. Questa limitazione elimina molte classi di errori dal codice.

Due problemi con la programmazione dichiarativa

Nelle due sezioni seguenti presenterò i due problemi principali della programmazione dichiarativa: separatezza e mancanza di dispiegamento . Ogni critica ha bisogno del suo spauracchio, quindi userò i sistemi di modelli HTML come esempio concreto delle carenze della programmazione dichiarativa.

Il problema con gli DSL: la separazione

Immagina di dover scrivere un'applicazione web con un numero di visualizzazioni non banale. L'hardcoding di queste viste in un insieme di file HTML non è un'opzione perché molti componenti di queste pagine cambiano.

La soluzione più semplice, che è generare HTML concatenando stringhe, sembra così orribile che cercherai rapidamente un'alternativa. La soluzione standard consiste nell'utilizzare un sistema di modelli. Sebbene esistano diversi tipi di sistemi di modelli, eviteremo le loro differenze ai fini di questa analisi. Possiamo considerarli tutti simili in quanto la missione principale dei sistemi di modelli è fornire un'alternativa al codice che concatena le stringhe HTML utilizzando condizionali e loop, proprio come gli RDB sono emersi come un'alternativa al codice che scorre attraverso i record di dati.

Supponiamo di utilizzare un sistema di modelli standard; incontrerete tre fonti di attrito, che elencherò in ordine crescente di importanza. Il primo è che il modello risiede necessariamente in un file separato dal tuo codice. Poiché il sistema di modelli utilizza un DSL, la sintassi è diversa, quindi non può trovarsi nello stesso file. Nei progetti semplici, in cui il numero di file è basso, la necessità di mantenere file modello separati può duplicare o triplicare la quantità di file.

Apro un'eccezione per i modelli Embedded Ruby (ERB), perché sono integrati nel codice sorgente di Ruby. Questo non è il caso degli strumenti ispirati a ERB scritti in altre lingue poiché anche questi modelli devono essere archiviati come file diversi.

La seconda fonte di attrito è che il DSL ha una sua sintassi, una diversa da quella del tuo linguaggio di programmazione. Quindi, modificare il DSL (per non parlare di scriverne uno tuo) è considerevolmente più difficile. Per andare sotto il cofano e cambiare lo strumento, devi imparare a tokenizzare e analizzare, che è interessante e stimolante, ma difficile. Mi capita di vedere questo come uno svantaggio.

Potresti chiedere: "Perché diavolo vorresti modificare il tuo strumento? Se stai realizzando un progetto standard, uno strumento standard ben scritto dovrebbe adattarsi al progetto. Forse sì forse no.

Una DSL non ha mai la piena potenza di un linguaggio di programmazione. Se lo facesse, non sarebbe più un DSL, ma piuttosto un linguaggio di programmazione completo.

Ma non è questo il punto di una DSL? Non avere a disposizione tutta la potenza di un linguaggio di programmazione, in modo da poter ottenere l'astrazione ed eliminare la maggior parte delle fonti di bug? Forse si. Tuttavia, la maggior parte dei DSL inizia in modo semplice e poi incorpora gradualmente un numero crescente di funzionalità di un linguaggio di programmazione fino a quando, di fatto, diventa uno. I sistemi di modelli sono un esempio perfetto. Vediamo le caratteristiche standard dei sistemi di modelli e come sono correlati alle strutture del linguaggio di programmazione:

  • Sostituisci il testo all'interno di un modello : sostituzione variabile.
  • Ripetizione di un modello : loop.
  • Evitare di stampare un modello se una condizione non è soddisfatta : condizionali.
  • Parziali : subroutine.
  • Helpers : subroutine (l'unica differenza con i partial è che gli helper possono accedere al linguaggio di programmazione sottostante e farti uscire dalla camicia di forza DSL).

Questa argomentazione, secondo cui una DSL è limitata perché brama e allo stesso tempo rifiuta la potenza di un linguaggio di programmazione, è direttamente proporzionale alla misura in cui le caratteristiche della DSL sono direttamente mappabili alle caratteristiche di un linguaggio di programmazione . Nel caso di SQL, l'argomento è debole perché la maggior parte delle cose offerte da SQL non assomigliano a quelle che trovi in ​​un normale linguaggio di programmazione. All'altra estremità dello spettro, troviamo sistemi modello in cui praticamente ogni caratteristica sta facendo convergere la DSL verso il BASIC.

Facciamo ora un passo indietro e contempliamo queste tre fonti di attrito per eccellenza, riassunte dal concetto di separatezza . Poiché è separato, un DSL deve trovarsi in un file separato; è più difficile da modificare (e ancora più difficile da scrivere da soli) e (spesso, ma non sempre) richiede che tu aggiunga, una per una, le funzionalità che ti mancano da un vero linguaggio di programmazione.

La separazione è un problema inerente a qualsiasi DSL, non importa quanto ben progettata.

Passiamo ora ad un secondo problema degli strumenti dichiarativi, che è diffuso ma non inerente.

Un altro problema: la mancanza di dispiegamento porta alla complessità

Se avessi scritto questo articolo qualche mese fa, questa sezione sarebbe stata denominata Gli strumenti più dichiarativi sono #@!$#@! Complesso ma non so perché . Durante la stesura di questo articolo ho trovato un modo migliore per dirlo: la maggior parte degli strumenti dichiarativi sono molto più complessi di quanto dovrebbero essere . Passerò il resto di questa sezione a spiegare perché. Per analizzare la complessità di uno strumento, propongo una misura chiamata divario di complessità . Il divario di complessità è la differenza tra risolvere un determinato problema con uno strumento e risolverlo al livello inferiore (presumibilmente, semplice codice imperativo) che lo strumento intende sostituire. Quando la prima soluzione è più complessa della seconda, siamo in presenza del gap di complessità. Per più complesso , intendo più righe di codice, codice più difficile da leggere, più difficile da modificare e più difficile da mantenere, ma non necessariamente tutte contemporaneamente.

Tieni presente che non stiamo confrontando la soluzione di livello inferiore con il miglior strumento possibile, ma piuttosto con nessuno strumento. Ciò fa eco al principio medico del "Primo, non nuocere" .

I segni di uno strumento con un grande divario di complessità sono:

  • Qualcosa che richiede alcuni minuti per essere descritto in modo dettagliato in termini imperativi richiederà ore per codificare utilizzando lo strumento, anche quando si sa come utilizzare lo strumento.
  • Senti di lavorare costantemente attorno allo strumento piuttosto che con lo strumento.
  • Stai lottando per risolvere un problema semplice che appartiene esattamente al dominio dello strumento che stai utilizzando, ma la migliore risposta a Stack Overflow che trovi descrive una soluzione alternativa .
  • Quando questo problema molto semplice potrebbe essere risolto da una certa funzionalità (che non esiste nello strumento) e vedi un problema di Github nella libreria che presenta una lunga discussione di detta funzionalità con +1 s intervallati.
  • Un desiderio cronico, pruriginoso, di abbandonare lo strumento e fare tutto da soli in un _ for-loop_.

Potrei essere caduto preda dell'emozione qui poiché i sistemi di modelli non sono così complessi, ma questo divario di complessità relativamente piccolo non è un merito del loro design, ma piuttosto perché il dominio di applicabilità è abbastanza semplice (ricorda, stiamo solo generando HTML qui ). Ogni volta che lo stesso approccio viene utilizzato per un dominio più complesso (come la gestione della configurazione), il divario di complessità può trasformare rapidamente il tuo progetto in un pantano.

Detto questo, non è necessariamente inaccettabile che uno strumento sia un po' più complesso del livello inferiore che intende sostituire; se lo strumento produce codice più leggibile, conciso e corretto, può valerne la pena t. È un problema quando lo strumento è molte volte più complesso del problema che sostituisce; questo è assolutamente inaccettabile. Brian Kernighan ha affermato che “ il controllo della complessità è l'essenza della programmazione per computer. ” Se uno strumento aggiunge complessità significativa al tuo progetto, perché utilizzarlo?

La domanda è: perché alcuni strumenti dichiarativi sono molto più complessi del necessario? Penso che sarebbe un errore dare la colpa a un design scadente. Una spiegazione così generale, un attacco ad hominem generalizzato agli autori di questi strumenti, non è giusta. Ci deve essere una spiegazione più accurata e illuminante.

La mia tesi è che qualsiasi strumento che offra un'interfaccia di alto livello per astrarre un livello inferiore deve dispiegare questo livello superiore da quello inferiore. Il concetto di dispiegamento deriva dall'opera magnum di Cristoforo Alexander, La natura dell'ordine, in particolare il volume II. È (senza speranza) oltre lo scopo di questo articolo (per non parlare della mia comprensione) riassumere le implicazioni di questo lavoro monumentale per la progettazione del software; Credo che il suo impatto sarà enorme negli anni a venire. È anche al di là di questo articolo fornire una definizione rigorosa dei processi di sviluppo. Userò qui il concetto in modo euristico.

Un processo di dispiegamento è quello che, in modo graduale, crea ulteriore struttura senza negare quella esistente. Ad ogni passo, ogni cambiamento (o differenziazione, per usare il termine di Alexander) rimane in armonia con qualsiasi struttura precedente, quando la struttura precedente è, semplicemente, una sequenza cristallizzata di cambiamenti passati.

È interessante notare che Unix è un ottimo esempio dello sviluppo di un livello superiore da uno inferiore. In Unix, due caratteristiche complesse del sistema operativo, i lavori batch e le coroutine (pipe), sono semplicemente estensioni dei comandi di base. A causa di alcune decisioni di progettazione fondamentali, come rendere tutto un flusso di byte, essendo la shell un programma userland e file di I/O standard, Unix è in grado di fornire queste sofisticate funzionalità con una complessità minima.

Per sottolineare perché questi sono ottimi esempi di dispiegamento, vorrei citare alcuni estratti di un articolo del 1979 di Dennis Ritchie, uno degli autori di Unix:

Sui lavori batch :

… il nuovo schema di controllo del processo ha reso istantaneamente banali da implementare alcune funzionalità molto preziose; ad esempio processi distaccati (con & ) e uso ricorsivo della shell come comando. La maggior parte dei sistemi deve fornire una sorta di speciale funzione di batch job submission e uno speciale interprete dei comandi per i file distinto da quello utilizzato in modo interattivo.

Sulle coroutine :

Il genio della pipeline Unix sta proprio nel fatto che è costruito dagli stessi comandi usati costantemente in modo simplex.

Questa eleganza e semplicità, sostengo, deriva da un processo di dispiegamento . I lavori batch e le coroutine vengono spiegati dalle strutture precedenti (i comandi vengono eseguiti in una shell utente). Credo che a causa della filosofia minimalista e delle risorse limitate del team che ha creato Unix, il sistema si sia evoluto gradualmente e, come tale, sia stato in grado di incorporare funzionalità avanzate senza voltare le spalle a quelle di base perché non c'erano abbastanza risorse per fare diversamente.

In assenza di un processo di sviluppo, il livello alto sarà notevolmente più complesso del necessario. In altre parole, la complessità della maggior parte degli strumenti dichiarativi deriva dal fatto che il loro livello elevato non si dispiega dal livello basso che intendono sostituire.

Questa mancanza di dispiegamento , se si perdona il neologismo, è abitualmente giustificata dalla necessità di schermare l'utente dal livello inferiore. Questa enfasi sul poka-yoke (protezione dell'utente da errori di basso livello) va a scapito di un ampio divario di complessità che è controproducente perché la complessità aggiuntiva genererà nuove classi di errori. Per aggiungere la beffa al danno, queste classi di errori non hanno nulla a che fare con il dominio del problema, ma piuttosto con lo strumento stesso. Non andremmo troppo lontano se descriviamo questi errori come iatrogeni.

Gli strumenti di creazione di modelli dichiarativi, almeno quando applicati al compito di generare visualizzazioni HTML, sono un caso archetipico di un livello alto che volta le spalle al livello basso che intende sostituire. Come mai? Perché generare una visione non banale richiede logica e sistemi di modelli, in particolare quelli privi di logica, bandiscono la logica attraverso la porta principale e poi ne riportano parte di nascosto attraverso la porta del gatto.

Nota: una giustificazione ancora più debole per un grande divario di complessità è quando uno strumento viene commercializzato come magico o qualcosa che funziona e basta , l'opacità del livello basso dovrebbe essere una risorsa perché uno strumento magico dovrebbe sempre funzionare senza che tu lo capisca perché o come. Secondo la mia esperienza, più uno strumento pretende di essere magico, più velocemente trasmuta il mio entusiasmo in frustrazione.

Ma che dire della separazione delle preoccupazioni? Vista e logica non dovrebbero restare separate? L'errore principale, qui, è mettere la logica aziendale e la logica di presentazione nella stessa borsa. La logica aziendale non ha certamente posto in un modello, ma la logica di presentazione esiste comunque. L'esclusione della logica dai modelli spinge la logica di presentazione nel server in cui è alloggiata in modo scomodo. Devo la chiara formulazione di questo punto ad Alexei Boronine, che ne fa un ottimo argomento in questo articolo.

La mia sensazione è che circa due terzi del lavoro di un modello risiedano nella sua logica di presentazione, mentre l'altro terzo si occupa di questioni generiche come la concatenazione di stringhe, la chiusura di tag, l'escape di caratteri speciali e così via. Questa è la doppia natura di basso livello della generazione di visualizzazioni HTML. I sistemi di templating gestiscono in modo appropriato la seconda metà, ma non se la cavano bene con la prima. I modelli privi di logica voltano le spalle a questo problema, costringendoti a risolverlo goffamente. Altri sistemi di modelli soffrono perché hanno davvero bisogno di fornire un linguaggio di programmazione non banale in modo che i loro utenti possano effettivamente scrivere la logica di presentazione.

Per riassumere; gli strumenti di creazione di modelli dichiarativi soffrono perché:

  • Se dovessero emergere dal loro dominio problematico, dovrebbero fornire modi per generare schemi logici;
  • Un DSL che fornisce la logica non è in realtà un DSL, ma un linguaggio di programmazione. Tieni presente che anche altri domini, come la gestione della configurazione, soffrono della mancanza di "spiegazione".

Vorrei chiudere la critica con un argomento che è logicamente disconnesso dal filo di questo articolo, ma che risuona profondamente con il suo nucleo emotivo: abbiamo poco tempo per imparare. La vita è breve e, soprattutto, dobbiamo lavorare. Di fronte ai nostri limiti, dobbiamo dedicare il nostro tempo a imparare cose che saranno utili e resisteranno al tempo, anche di fronte alla tecnologia in rapida evoluzione. Questo è il motivo per cui vi esorto a utilizzare strumenti che non forniscano solo una soluzione, ma in realtà gettino una luce brillante sul dominio della sua stessa applicabilità. Gli RDB ti insegnano i dati e Unix ti insegna i concetti del sistema operativo, ma con strumenti insoddisfacenti che non si dispiegano, ho sempre pensato che stavo imparando le complessità di una soluzione non ottimale pur rimanendo all'oscuro sulla natura del problema intende risolvere.

L'euristica che ti suggerisco di considerare è, valutare gli strumenti che illuminano il loro dominio problematico, invece di strumenti che oscurano il loro dominio problematico dietro presunte funzionalità .

L'approccio gemellare

Per superare i due problemi della programmazione dichiarativa, che ho qui presentato, propongo un duplice approccio:

  • Utilizzare un linguaggio specifico del dominio della struttura dati (dsDSL) per superare la separazione.
  • Crea un livello alto che si dispiega dal livello inferiore, per superare il divario di complessità.

dsDSL

Una struttura dati DSL (dsDSL) è una DSL costruita con le strutture dati di un linguaggio di programmazione . L'idea di base è utilizzare le strutture di dati di base disponibili, come stringhe, numeri, array, oggetti e funzioni, e combinarle per creare astrazioni per gestire un dominio specifico.

Vogliamo mantenere il potere di dichiarare strutture o azioni (livello alto) senza dover specificare i modelli che implementano questi costrutti (livello basso). Vogliamo superare la separazione tra la DSL e il nostro linguaggio di programmazione in modo da essere liberi di utilizzare tutta la potenza di un linguaggio di programmazione ogni volta che ne abbiamo bisogno. Questo non è solo possibile, ma semplice tramite dsDSL.

Se me lo avessi chiesto un anno fa, avrei pensato che il concetto di dsDSL fosse nuovo, poi un giorno mi sono reso conto che lo stesso JSON era un perfetto esempio di questo approccio! Un oggetto JSON analizzato è costituito da strutture di dati che rappresentano in modo dichiarativo voci di dati al fine di ottenere i vantaggi del DSL semplificando al contempo l'analisi e la gestione dall'interno di un linguaggio di programmazione. (Potrebbero esserci altri dsDSL là fuori, ma finora non ne ho incontrati nessuno. Se ne conosci uno, apprezzerei davvero che tu lo menzionassi nella sezione commenti.)

Come JSON, un dsDSL ha i seguenti attributi:

  1. Consiste in un insieme molto piccolo di funzioni: JSON ha due funzioni principali, parse e stringify .
  2. Le sue funzioni ricevono più comunemente argomenti complessi e ricorsivi: un JSON analizzato è un array, o oggetto, che di solito contiene ulteriori array e oggetti all'interno.
  3. Gli input di queste funzioni sono conformi a moduli molto specifici: JSON ha uno schema di convalida esplicito e rigorosamente applicato per distinguere le strutture valide da quelle non valide.
  4. Sia gli ingressi che le uscite di queste funzioni possono essere contenuti e generati da un linguaggio di programmazione senza una sintassi separata.

Ma i dsDSL vanno oltre JSON in molti modi. Creiamo un dsDSL per generare HTML usando Javascript. Più avanti toccherò la questione se questo approccio possa essere esteso ad altri linguaggi (spoiler: può sicuramente essere fatto in Ruby e Python, ma probabilmente non in C).

L'HTML è un linguaggio di markup composto da tags delimitati da parentesi angolari ( < e > ). Questi tag possono avere attributi e contenuti opzionali. Gli attributi sono semplicemente un elenco di attributi chiave/valore e i contenuti possono essere testo o altri tag. Sia gli attributi che i contenuti sono opzionali per ogni dato tag. Sto semplificando un po', ma è accurato.

Un modo semplice per rappresentare un tag HTML in un dsDSL consiste nell'usare un array con tre elementi: - Tag: una stringa. - Attributi: un oggetto (del tipo semplice, chiave/valore) o undefined (se non sono necessari attributi). - Contenuto: una stringa (testo), un array (un altro tag) o undefined (se non ci sono contenuti).

Ad esempio, <a href="views">Index</a> può essere scritto come ['a', {href: 'views'}, 'Index'] .

Se vogliamo incorporare questo elemento anchor in un div con links di classe , possiamo scrivere: ['div', {class: 'links'}, ['a', {href: 'views'}, 'Index']] .

Per elencare più tag html allo stesso livello, possiamo racchiuderli in un array:

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

Lo stesso principio può essere applicato alla creazione di più tag all'interno di un tag:

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

Naturalmente, questo dsDSL non ci porterà lontano se non generiamo HTML da esso. Abbiamo bisogno di una funzione di generate che prenda il nostro dsDSL e produca una stringa con HTML. Quindi, se eseguiamo generate (['a', {href: 'views'}, 'Index']) , otterremo la stringa <a href="views">Index</a> .

L'idea alla base di qualsiasi DSL è di specificare alcuni costrutti con una struttura specifica che viene poi passata a una funzione. In questo caso, la struttura che compone il dsDSL è questo array, che ha da uno a tre elementi; questi array hanno una struttura specifica. Se generate convalida completamente il suo input (ed è sia facile che importante convalidare completamente l'input, poiché queste regole di convalida sono l'analogo preciso della sintassi di un DSL), ti dirà esattamente dove hai sbagliato con il tuo input. Dopo un po', inizierai a riconoscere ciò che distingue una struttura valida in un dsDSL, e questa struttura sarà altamente suggestiva della cosa sottostante che genera.

Ora, quali sono i pregi di una dsDSL in contrapposizione a una DSL?

  • Un dsDSL è parte integrante del tuo codice. Porta a conteggi di righe e file inferiori e a una riduzione complessiva del sovraccarico.
  • I dsDSL sono facili da analizzare (quindi più facili da implementare e modificare). L'analisi è semplicemente l'iterazione degli elementi di un array o di un oggetto. Allo stesso modo, i dsDSL sono relativamente facili da progettare perché invece di creare una nuova sintassi (che tutti odieranno) puoi attenerti alla sintassi del tuo linguaggio di programmazione (che tutti odiano ma almeno lo conoscono già).
  • Un dsDSL ha tutta la potenza di un linguaggio di programmazione. Ciò significa che una dsDSL, se utilizzata correttamente, ha il vantaggio di essere sia uno strumento di alto livello che uno di basso livello.

Ora, l'ultima affermazione è forte, quindi trascorrerò il resto di questa sezione a sostenerla. Cosa intendo per correttamente impiegato ? Per vederlo in azione, consideriamo un esempio in cui vogliamo costruire una tabella per visualizzare le informazioni da un array chiamato 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']} ]

In un'applicazione reale, i DATA verranno generati dinamicamente da una query del database.

Inoltre, abbiamo una variabile FILTER che, una volta inizializzata, sarà un array con le categorie che vogliamo visualizzare.

Vogliamo che la nostra tavola:

  • Visualizza le intestazioni delle tabelle.
  • Per ogni prodotto, mostra i campi: descrizione, prezzo e categorie.
  • Non stampare il campo id , ma aggiungerlo come attributo id per ogni riga. VERSIONE ALTERNATIVA: aggiungi un attributo id a ogni elemento tr .
  • Inserisci una classe onSale se il prodotto è in vendita.
  • Ordina i prodotti per prezzo decrescente.
  • Filtra determinati prodotti per categoria. Se FILTER è un array vuoto, visualizzeremo tutti i prodotti. In caso contrario, verranno visualizzati solo i prodotti in cui la categoria del prodotto è contenuta all'interno di FILTER .

Possiamo creare la logica di presentazione che soddisfa questo requisito in circa 20 righe di codice:

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

Ammetto che questo non è un esempio semplice, tuttavia, rappresenta una visione abbastanza semplice delle quattro funzioni di base dell'archiviazione persistente, note anche come CRUD. Qualsiasi applicazione web non banale avrà viste più complesse di questa.

Vediamo ora cosa sta facendo questo codice. In primo luogo, definisce una funzione, drawTable , per contenere la logica di presentazione del disegno della tabella prodotto. Questa funzione riceve DATA e FILTER come parametri, quindi può essere utilizzata per diversi set di dati e filtri. drawTable svolge il doppio ruolo di parziale e di aiuto.

 var drawTable = function (DATA, FILTER) {

La variabile interna, printableFields , è l'unico posto in cui è necessario specificare quali campi sono stampabili, evitando ripetizioni e incoerenze di fronte al cambiamento dei requisiti.

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

Quindi ordiniamo i DATA in base al prezzo dei suoi prodotti. Si noti che criteri di ordinamento diversi e più complessi sarebbero semplici da implementare poiché abbiamo l'intero linguaggio di programmazione a nostra disposizione.

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

Qui restituiamo un oggetto letterale; un array che contiene table come primo elemento e il suo contenuto come secondo. Questa è la rappresentazione dsDSL della <table> che vogliamo creare.

 return ['table', [

Ora creiamo una riga con le intestazioni della tabella. Per crearne il contenuto, utilizziamo dale.do che è una funzione come Array.map, ma che funziona anche per gli oggetti. Itereremo printableFields e genereremo intestazioni di tabella per ciascuno di essi:

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

Si noti che abbiamo appena implementato l'iterazione, il cavallo di battaglia della generazione HTML, e non abbiamo bisogno di costrutti DSL; avevamo solo bisogno di una funzione per iterare una struttura dati e restituire dsDSL. Anche una funzione nativa simile, o implementata dall'utente, avrebbe fatto il trucco.

Ora scorrere i prodotti contenuti in DATA .

 dale.do (DATA, function (product) {

Controlliamo se questo prodotto è stato escluso da FILTER . Se FILTER è vuoto, stamperemo il prodotto. Se FILTER non è vuoto, itereremo attraverso le categorie del prodotto finché non ne troveremo uno contenuto all'interno di FILTER . Lo facciamo usando dale.stop.

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

Notare la complessità del condizionale; è perfettamente adattato alle nostre esigenze e abbiamo totale libertà di esprimerlo perché siamo in un linguaggio di programmazione piuttosto che in un DSL.

Se matches è false , restituiamo un array vuoto (quindi non stampiamo questo prodotto). Altrimenti, restituiamo un <tr> con il suo id e classe appropriati e ripetiamo printableFields per stampare i campi.

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

Ovviamente chiudiamo tutto ciò che abbiamo aperto. La sintassi non è divertente?

 })]; }) ]]; }

Ora, come incorporiamo questa tabella in un contesto più ampio? Scriviamo una funzione denominata drawAll che invocherà tutte le funzioni che generano le viste. Oltre a drawTable , potremmo anche avere drawHeader , drawFooter e altre funzioni simili, che restituiranno tutte dsDSLs .

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

Se non ti piace come appare il codice sopra, niente di quello che dico ti convincerà. Questo è un dsDSL al suo meglio . 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.
Twitta

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