Programação declarativa: é uma coisa real?

Publicados: 2022-03-11

A programação declarativa é, atualmente, o paradigma dominante de um conjunto extenso e diversificado de domínios como bancos de dados, modelagem e gerenciamento de configuração.

Em poucas palavras, a programação declarativa consiste em instruir um programa sobre o que precisa ser feito, em vez de dizer como fazê-lo. Na prática, essa abordagem envolve fornecer uma linguagem específica de domínio (DSL) para expressar o que o usuário deseja e protegê-los das construções de baixo nível (loops, condicionais, atribuições) que materializam o estado final desejado.

Embora esse paradigma seja uma melhoria notável em relação à abordagem imperativa que ele substituiu, afirmo que a programação declarativa tem limitações significativas, limitações que exploro neste artigo. Além disso, proponho uma abordagem dupla que captura os benefícios da programação declarativa enquanto supera suas limitações.

CAVEAT : Este artigo surgiu como resultado de uma luta pessoal de vários anos com ferramentas declarativas. Muitas das alegações que apresento aqui não são totalmente comprovadas, e algumas até são apresentadas pelo valor de face. Uma crítica adequada da programação declarativa levaria tempo e esforço consideráveis, e eu teria que voltar e usar muitas dessas ferramentas; meu coração não está em tal empreendimento. O objetivo deste artigo é compartilhar alguns pensamentos com você, sem rodeios e mostrando o que funcionou para mim. Se você teve dificuldades com ferramentas de programação declarativa, poderá encontrar descanso e alternativas. E se você gosta do paradigma e de suas ferramentas, não me leve muito a sério.

Se a programação declarativa funcionar bem para você, não estou em posição de lhe dizer o contrário .

Você pode amar ou odiar a programação declarativa, mas não pode ignorá-la.
Tweet

Os méritos da programação declarativa

Antes de explorarmos os limites da programação declarativa, é necessário entender seus méritos.

Indiscutivelmente a ferramenta de programação declarativa mais bem-sucedida é o banco de dados relacional (RDB). Pode até ser a primeira ferramenta declarativa. De qualquer forma, os RDBs exibem as duas propriedades que considero arquetípicas da programação declarativa:

  • Uma linguagem específica de domínio (DSL) : a interface universal para bancos de dados relacionais é uma DSL chamada Structured Query Language, mais comumente conhecida como SQL.
  • A DSL esconde a camada de nível inferior do usuário : desde o artigo original de Edgar F. Codd sobre RDBs, está claro que o poder desse modelo é dissociar as consultas desejadas dos loops, índices e caminhos de acesso subjacentes que as implementam.

Antes dos RDBs, a maioria dos sistemas de banco de dados era acessada por meio de código imperativo, que depende muito de detalhes de baixo nível, como a ordem dos registros, índices e os caminhos físicos para os próprios dados. Como esses elementos mudam com o tempo, o código geralmente para de funcionar devido a alguma alteração subjacente na estrutura dos dados. O código resultante é difícil de escrever, difícil de depurar, difícil de ler e difícil de manter. Eu vou me arriscar e dizer que a maior parte desse código estava, muito provavelmente, longo, cheio de ninhos de ratos proverbiais de condicionais, repetição e bugs sutis dependentes do estado.

Diante disso, os RDBs proporcionaram um tremendo salto de produtividade para os desenvolvedores de sistemas. Agora, em vez de milhares de linhas de código imperativo, você tinha um esquema de dados claramente definido, além de centenas (ou mesmo dezenas) de consultas. Como resultado, os aplicativos tinham apenas que lidar com uma representação abstrata, significativa e duradoura dos dados, e interfaceá-los por meio de uma linguagem de consulta poderosa e simples. O RDB provavelmente aumentou a produtividade dos programadores e das empresas que os empregavam em uma ordem de grandeza.

Quais são as vantagens comumente listadas da programação declarativa?

As vantagens da programação declarativa conforme listado abaixo, mas cada uma com um ícone representativo.

Os defensores da programação declarativa são rápidos em apontar as vantagens. No entanto, mesmo eles admitem que vem com trade-offs.
Tweet
  1. Legibilidade/usabilidade : uma DSL geralmente está mais próxima de uma linguagem natural (como o inglês) do que de pseudocódigo, portanto, mais legível e também mais fácil de aprender por não programadores.
  2. Concisão : muito do clichê é abstraído pelo DSL, deixando menos linhas para fazer o mesmo trabalho.
  3. Reutilizar : é mais fácil criar código que pode ser usado para diferentes propósitos; algo que é notoriamente difícil ao usar construções imperativas.
  4. Idempotência : você pode trabalhar com estados finais e deixar o programa descobrir isso para você. Por exemplo, por meio de uma operação de upsert, você pode inserir uma linha se não estiver lá ou modificá-la se já estiver lá, em vez de escrever código para lidar com os dois casos.
  5. Recuperação de erros : é fácil especificar uma construção que irá parar no primeiro erro em vez de ter que adicionar ouvintes de erro para cada erro possível. (Se você já escreveu três retornos de chamada aninhados em node.js, você sabe o que quero dizer.)
  6. Transparência referencial : embora essa vantagem seja comumente associada à programação funcional, ela é realmente válida para qualquer abordagem que minimize a manipulação manual de estado e dependa de efeitos colaterais.
  7. Comutatividade : a possibilidade de expressar um estado final sem precisar especificar a ordem real em que será implementado.

Embora todas as vantagens citadas acima sejam da programação declarativa, gostaria de condensá-las em duas qualidades, que servirão como princípios orientadores quando eu propor uma abordagem alternativa.

  1. Uma camada de alto nível adaptada a um domínio específico : a programação declarativa cria uma camada de alto nível usando as informações do domínio ao qual se aplica. É claro que se estamos lidando com bancos de dados, queremos um conjunto de operações para lidar com dados. A maioria das sete vantagens acima derivam da criação de uma camada de alto nível que é precisamente adaptada a um domínio de problema específico.
  2. Poka-yoke (à prova de tolos) : uma camada de alto nível adaptada ao domínio oculta os detalhes imperativos da implementação. Isso significa que você comete muito menos erros porque os detalhes de baixo nível do sistema simplesmente não são acessíveis. Essa limitação elimina muitas classes de erros do seu código.

Dois problemas com programação declarativa

Nas duas seções seguintes, apresentarei os dois principais problemas da programação declarativa: separação e falta de desdobramento . Toda crítica precisa de seu bicho-papão, então usarei sistemas de modelagem HTML como um exemplo concreto das deficiências da programação declarativa.

O problema com DSLs: Separação

Imagine que você precisa escrever um aplicativo da Web com um número não trivial de visualizações. Codificar essas visualizações em um conjunto de arquivos HTML não é uma opção porque muitos componentes dessas páginas mudam.

A solução mais direta, que é gerar HTML concatenando strings, parece tão horrível que você rapidamente procurará uma alternativa. A solução padrão é usar um sistema de modelo. Embora existam diferentes tipos de sistemas de modelo, vamos evitar suas diferenças para fins desta análise. Podemos considerar todos eles semelhantes, pois a principal missão dos sistemas de modelo é fornecer uma alternativa ao código que concatena strings HTML usando condicionais e loops, assim como os RDBs surgiram como uma alternativa ao código que fazia loop por registros de dados.

Vamos supor que vamos com um sistema de modelagem padrão; você encontrará três fontes de atrito, que vou listar em ordem crescente de importância. A primeira é que o template reside necessariamente em um arquivo separado do seu código. Como o sistema de modelagem usa uma DSL, a sintaxe é diferente, portanto, não pode estar no mesmo arquivo. Em projetos simples, onde a contagem de arquivos é baixa, a necessidade de manter arquivos de modelo separados pode duplicar ou triplicar a quantidade de arquivos.

Eu abro uma exceção para templates Ruby incorporados (ERB), porque eles são integrados ao código fonte Ruby. Este não é o caso de ferramentas inspiradas em ERB escritas em outras linguagens, pois esses modelos também devem ser armazenados como arquivos diferentes.

A segunda fonte de atrito é que a DSL tem sua própria sintaxe, diferente da sua linguagem de programação. Portanto, modificar o DSL (e muito menos escrever o seu próprio) é consideravelmente mais difícil. Para entrar nos bastidores e mudar a ferramenta, você precisa aprender sobre tokenização e análise, o que é interessante e desafiador, mas difícil. Acontece que vejo isso como uma desvantagem.

Você pode perguntar: “Por que diabos você gostaria de modificar sua ferramenta? Se você está fazendo um projeto padrão, uma ferramenta padrão bem escrita deve se adequar ao projeto.” Talvez sim, talvez não.

Uma DSL nunca tem todo o poder de uma linguagem de programação. Se tivesse, não seria mais uma DSL, mas sim uma linguagem de programação completa.

Mas não é esse o objetivo de uma DSL? Não ter todo o poder de uma linguagem de programação disponível, para que possamos obter abstração e eliminar a maioria das fontes de bugs? Talvez sim. No entanto, a maioria das DSLs começa simples e depois gradualmente incorpora um número crescente de facilidades de uma linguagem de programação até que, de fato, se torne uma. Os sistemas de modelo são um exemplo perfeito. Vamos ver os recursos padrão dos sistemas de modelo e como eles se correlacionam com os recursos da linguagem de programação:

  • Substituir texto em um modelo : substituição de variável.
  • Repetição de um modelo : loops.
  • Evite imprimir um modelo se uma condição não for atendida : condicionais.
  • Parciais : sub-rotinas.
  • Helpers : sub-rotinas (a única diferença com os parciais é que os helpers podem acessar a linguagem de programação subjacente e deixá-lo fora da camisa de força DSL).

Esse argumento, de que uma DSL é limitada porque simultaneamente cobiça e rejeita o poder de uma linguagem de programação, é diretamente proporcional à medida em que os recursos da DSL são mapeáveis ​​diretamente aos recursos de uma linguagem de programação . No caso do SQL, o argumento é fraco porque a maioria das coisas que o SQL oferece não são nada parecidas com o que você encontra em uma linguagem de programação normal. No outro extremo do espectro, encontramos sistemas de modelo onde praticamente todos os recursos estão fazendo o DSL convergir para o BASIC.

Vamos agora dar um passo atrás e contemplar essas três fontes de atrito por excelência, resumidas pelo conceito de separação . Por ser separado, um DSL precisa estar localizado em um arquivo separado; é mais difícil de modificar (e ainda mais difícil de escrever o seu próprio) e (frequentemente, mas nem sempre) precisa que você adicione, um por um, os recursos que você perde de uma linguagem de programação real.

A separação é um problema inerente a qualquer DSL, não importa quão bem projetado.

Agora nos voltamos para um segundo problema de ferramentas declarativas, que é difundido, mas não inerente.

Outro problema: a falta de desdobramento leva à complexidade

Se eu tivesse escrito este artigo alguns meses atrás, esta seção teria sido nomeada A maioria das ferramentas declarativas são #@!$#@! Complexo, mas não sei por quê . No processo de escrever este artigo, encontrei uma maneira melhor de colocá-lo: A maioria das ferramentas declarativas são muito mais complexas do que precisam ser . Vou passar o resto desta seção explicando o porquê. Para analisar a complexidade de uma ferramenta, proponho uma medida chamada de gap de complexidade . A lacuna de complexidade é a diferença entre resolver um determinado problema com uma ferramenta versus resolvê-lo no nível inferior (presumivelmente, código imperativo simples) que a ferramenta pretende substituir. Quando a primeira solução é mais complexa que a segunda, estamos na presença da lacuna de complexidade. Por mais complexo , quero dizer mais linhas de código, código que é mais difícil de ler, mais difícil de modificar e mais difícil de manter, mas não necessariamente tudo isso ao mesmo tempo.

Observe que não estamos comparando a solução de nível inferior com a melhor ferramenta possível, mas sim com nenhuma ferramenta. Isso ecoa o princípio médico de “Primeiro, não faça mal” .

Sinais de uma ferramenta com uma grande lacuna de complexidade são:

  • Algo que leva alguns minutos para ser descrito em detalhes ricos em termos imperativos levará horas para codificar usando a ferramenta, mesmo quando você sabe como usar a ferramenta.
  • Você sente que está constantemente trabalhando em torno da ferramenta e não com a ferramenta.
  • Você está lutando para resolver um problema direto que pertence diretamente ao domínio da ferramenta que está usando, mas a melhor resposta do Stack Overflow que você encontra descreve uma solução alternativa .
  • Quando este problema muito simples pode ser resolvido por um determinado recurso (que não existe na ferramenta) e você vê um problema do Github na biblioteca que apresenta uma longa discussão sobre esse recurso com +1 s intercalados.
  • Um desejo crônico, com coceira, de abandonar a ferramenta e fazer tudo sozinho dentro de um _for-loop_.

Eu posso ter sido vítima da emoção aqui já que os sistemas de templates não são tão complexos, mas essa lacuna de complexidade comparativamente pequena não é um mérito de seu design, mas sim porque o domínio de aplicabilidade é bastante simples (lembre-se, estamos apenas gerando HTML aqui ). Sempre que a mesma abordagem é usada para um domínio mais complexo (como gerenciamento de configuração), a lacuna de complexidade pode rapidamente transformar seu projeto em um atoleiro.

Dito isso, não é necessariamente inaceitável que uma ferramenta seja um pouco mais complexa do que o nível inferior que ela pretende substituir; se a ferramenta produzir um código mais legível, conciso e correto, pode valer a pena t. É um problema quando a ferramenta é várias vezes mais complexa do que o problema que ela substitui; isso é totalmente inaceitável. Brian Kernighan afirmou que “ Controlar a complexidade é a essência da programação de computadores. ” Se uma ferramenta adiciona complexidade significativa ao seu projeto, por que usá-la?

A questão é: por que algumas ferramentas declarativas são muito mais complexas do que precisam ser? Eu acho que seria um erro culpar o design ruim. Tal explicação geral, um ataque ad hominem generalizado aos autores dessas ferramentas, não é justo. Tem que haver uma explicação mais precisa e esclarecedora.

Minha opinião é que qualquer ferramenta que ofereça uma interface de alto nível para abstrair um nível inferior deve desdobrar esse nível superior do inferior. O conceito de desdobramento vem da magnum opus de Christopher Alexander, The Nature of Order - em particular o Volume II. Está (desesperadamente) além do escopo deste artigo (para não mencionar meu entendimento) resumir as implicações desse trabalho monumental para o design de software; Acredito que seu impacto será enorme nos próximos anos. Também está além deste artigo fornecer uma definição rigorosa de processos de desdobramento. Usarei aqui o conceito de forma heurística.

Um processo de desdobramento é aquele que, passo a passo, cria mais estrutura sem negar a existente. A cada passo, cada mudança (ou diferenciação, para usar o termo de Alexander) permanece em harmonia com qualquer estrutura anterior, quando a estrutura anterior é, simplesmente, uma sequência cristalizada de mudanças passadas.

Curiosamente, o Unix é um ótimo exemplo do desdobramento de um nível superior de um inferior. No Unix, duas características complexas do sistema operacional, batch jobs e corrotinas (pipes), são simplesmente extensões de comandos básicos. Por causa de certas decisões fundamentais de projeto, como fazer de tudo um fluxo de bytes, o shell sendo um programa userland e arquivos de E/S padrão, o Unix é capaz de fornecer esses recursos sofisticados com complexidade mínima.

Para sublinhar por que esses são excelentes exemplos de desdobramento, gostaria de citar alguns trechos de um artigo de 1979 de Dennis Ritchie, um dos autores do Unix:

Em trabalhos em lote :

… o novo esquema de controle de processo instantaneamente tornou trivial a implementação de alguns recursos muito valiosos; por exemplo, processos desanexados (com & ) e uso recursivo do shell como um comando. A maioria dos sistemas precisa fornecer algum tipo de recurso especial de batch job submission e um interpretador de comandos especial para arquivos distintos daquele usado interativamente.

Em corrotinas :

A genialidade do pipeline Unix é precisamente que ele é construído a partir dos mesmos comandos usados ​​constantemente no modo simplex.

Essa elegância e simplicidade, eu argumento, vem de um processo de desdobramento . Trabalhos em lote e corrotinas são desdobrados de estruturas anteriores (comandos executados em um shell de usuário). Acredito que por causa da filosofia minimalista e recursos limitados da equipe que criou o Unix, o sistema evoluiu passo a passo, e como tal, foi capaz de incorporar recursos avançados sem virar as costas para os básicos porque não havia recursos suficientes para faça o contrário.

Na ausência de um processo de desdobramento, o alto nível será consideravelmente mais complexo do que o necessário. Em outras palavras, a complexidade da maioria das ferramentas declarativas decorre do fato de que seu alto nível não se desdobra do baixo nível que pretendem substituir.

Essa falta de desdobramento , se você perdoar o neologismo, é rotineiramente justificada pela necessidade de blindar o usuário do nível inferior. Essa ênfase no poka-yoke (proteger o usuário de erros de baixo nível) vem à custa de uma grande lacuna de complexidade que é autodestrutiva porque a complexidade extra gerará novas classes de erros. Para adicionar insulto à injúria, essas classes de erros não têm nada a ver com o domínio do problema, mas com a própria ferramenta. Não iríamos longe demais se descrevêssemos esses erros como iatrogênicos.

As ferramentas de modelagem declarativa, pelo menos quando aplicadas à tarefa de gerar visualizações HTML, são um caso arquetípico de um nível alto que dá as costas ao nível baixo que pretende substituir. Como assim? Como gerar qualquer visão não trivial requer lógica e sistemas de modelagem, especialmente os sem lógica, banem a lógica pela porta principal e, em seguida, contrabandeiam parte dela de volta pela porta do gato.

Nota: Uma justificativa ainda mais fraca para uma grande lacuna de complexidade é quando uma ferramenta é comercializada como mágica , ou algo que simplesmente funciona , a opacidade do nível baixo deve ser um ativo porque uma ferramenta mágica sempre deve funcionar sem você entender por que ou como. Na minha experiência, quanto mais mágica uma ferramenta parece ser, mais rápido ela transforma meu entusiasmo em frustração.

Mas e a separação de interesses? A visão e a lógica não deveriam permanecer separadas? O principal erro, aqui, é colocar a lógica de negócios e a lógica de apresentação no mesmo saco. A lógica de negócios certamente não tem lugar em um modelo, mas a lógica de apresentação existe. A exclusão da lógica dos modelos empurra a lógica da apresentação para o servidor onde ela é acomodada de maneira desajeitada. Devo a formulação clara desse ponto a Alexei Boronine, que apresenta um excelente argumento neste artigo.

Minha impressão é que cerca de dois terços do trabalho de um modelo reside em sua lógica de apresentação, enquanto o outro terço lida com questões genéricas, como concatenar strings, fechar tags, escapar caracteres especiais e assim por diante. Essa é a natureza de baixo nível de duas faces da geração de visualizações HTML. Os sistemas de modelagem lidam adequadamente com a segunda metade, mas não se saem bem com a primeira. Os modelos sem lógica dão as costas a esse problema, forçando você a resolvê-lo de maneira desajeitada. Outros sistemas de modelo sofrem porque realmente precisam fornecer uma linguagem de programação não trivial para que seus usuários possam realmente escrever lógica de apresentação.

Resumindo; ferramentas de modelagem declarativa sofrem porque:

  • Se eles se desdobrassem a partir de seu domínio de problema, eles teriam que fornecer maneiras de gerar padrões lógicos;
  • Uma DSL que fornece lógica não é realmente uma DSL, mas uma linguagem de programação. Observe que outros domínios, como gerenciamento de configuração, também sofrem com a falta de “desdobramento”.

Gostaria de encerrar a crítica com um argumento logicamente desconectado do fio condutor deste artigo, mas que ressoa profundamente em seu núcleo emocional: temos tempo limitado para aprender. A vida é curta e, além disso, precisamos trabalhar. Diante de nossas limitações, precisamos gastar nosso tempo aprendendo coisas que serão úteis e resistirão ao tempo, mesmo diante da tecnologia em rápida mudança. É por isso que eu exorto você a usar ferramentas que não apenas fornecem uma solução, mas realmente lançam uma luz brilhante sobre o domínio de sua própria aplicabilidade. RDBs ensinam sobre dados e Unix ensina sobre conceitos de SO, mas com ferramentas insatisfatórias que não se desdobram, sempre senti que estava aprendendo os meandros de uma solução sub-ótima enquanto permanecia no escuro sobre a natureza do problema pretende resolver.

A heurística que sugiro que você considere é valorizar as ferramentas que iluminam o domínio do problema, em vez de ferramentas que obscurecem o domínio do problema por trás dos recursos pretendidos .

A Abordagem Gêmea

Para superar os dois problemas da programação declarativa, que apresentei aqui, proponho uma abordagem dupla:

  • Use uma linguagem específica de domínio de estrutura de dados (dsDSL), para superar a separação.
  • Crie um nível alto que se desdobre do nível inferior, para superar a lacuna de complexidade.

dsDSL

Uma estrutura de dados DSL (dsDSL) é uma DSL construída com as estruturas de dados de uma linguagem de programação . A ideia central é usar as estruturas de dados básicas que você tem disponíveis, como strings, números, arrays, objetos e funções, e combiná-los para criar abstrações para lidar com um domínio específico.

Queremos manter o poder de declarar estruturas ou ações (nível alto) sem precisar especificar os padrões que implementam essas construções (nível baixo). Queremos superar a separação entre a DSL e nossa linguagem de programação para que possamos usar todo o poder de uma linguagem de programação sempre que precisarmos. Isso não é apenas possível, mas direto por meio de dsDSLs.

Se você me perguntasse há um ano, eu teria pensado que o conceito de dsDSL era novo, então um dia, percebi que o próprio JSON era um exemplo perfeito dessa abordagem! Um objeto JSON analisado consiste em estruturas de dados que representam entradas de dados declarativamente para obter as vantagens da DSL, além de facilitar a análise e o manuseio de uma linguagem de programação. (Pode haver outros dsDSLs por aí, mas até agora não encontrei nenhum. Se você souber de um, eu realmente apreciaria se você o mencionasse na seção de comentários.)

Assim como o JSON, um dsDSL tem os seguintes atributos:

  1. Ele consiste em um conjunto muito pequeno de funções: JSON tem duas funções principais, parse e stringify .
  2. Suas funções geralmente recebem argumentos complexos e recursivos: um JSON analisado é uma matriz, ou objeto, que geralmente contém outras matrizes e objetos dentro.
  3. As entradas para essas funções estão em conformidade com formulários muito específicos: JSON tem um esquema de validação explícito e estritamente aplicado para diferenciar estruturas válidas de inválidas.
  4. Tanto as entradas quanto as saídas dessas funções podem ser contidas e geradas por uma linguagem de programação sem uma sintaxe separada.

Mas os dsDSLs vão além do JSON de várias maneiras. Vamos criar um dsDSL para gerar HTML usando Javascript. Mais tarde, abordarei a questão de saber se essa abordagem pode ser estendida a outras linguagens (spoiler: definitivamente pode ser feito em Ruby e Python, mas provavelmente não em C).

HTML é uma linguagem de marcação composta por tags delimitadas por colchetes angulares ( < e > ). Essas tags podem ter atributos e conteúdos opcionais. Os atributos são simplesmente uma lista de atributos de chave/valor, e o conteúdo pode ser texto ou outras tags. Tanto os atributos quanto os conteúdos são opcionais para qualquer tag. Estou simplificando um pouco, mas está correto.

Uma maneira simples de representar uma tag HTML em um dsDSL é usando um array com três elementos: - Tag: uma string. - Atributos: um objeto (do tipo simples, chave/valor) ou undefined (se nenhum atributo for necessário). - Conteúdo: uma string (texto), um array (outra tag) ou undefined (se não houver conteúdo).

Por exemplo, <a href="views">Index</a> pode ser escrito como ['a', {href: 'views'}, 'Index'] .

Se quisermos incorporar este elemento âncora em um div com links de classe, podemos escrever: ['div', {class: 'links'}, ['a', {href: 'views'}, 'Index']] .

Para listar várias tags html no mesmo nível, podemos envolvê-las em um array:

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

O mesmo princípio pode ser aplicado à criação de várias tags dentro de uma tag:

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

É claro que este dsDSL não nos levará muito longe se não gerarmos HTML a partir dele. Precisamos de uma função de generate que pegue nosso dsDSL e produza uma string com HTML. Portanto, se executarmos generate (['a', {href: 'views'}, 'Index']) , obteremos a string <a href="views">Index</a> .

A ideia por trás de qualquer DSL é especificar algumas construções com uma estrutura específica que é então passada para uma função. Nesse caso, a estrutura que compõe o dsDSL é esse array, que possui de um a três elementos; essas matrizes têm uma estrutura específica. Se o generate validar completamente sua entrada (e é fácil e importante validar completamente a entrada, uma vez que essas regras de validação são o análogo preciso da sintaxe de uma DSL), ele lhe dirá exatamente onde você errou com sua entrada. Depois de um tempo, você começará a reconhecer o que distingue uma estrutura válida em um dsDSL, e essa estrutura será altamente sugestiva da coisa subjacente que ela gera.

Agora, quais são os méritos de um dsDSL em contraposição a um DSL?

  • Um dsDSL é parte integrante do seu código. Isso leva a contagens de linhas mais baixas, contagens de arquivos e uma redução geral da sobrecarga.
  • dsDSLs são fáceis de analisar (portanto, mais fáceis de implementar e modificar). A análise é meramente iterar pelos elementos de uma matriz ou objeto. Da mesma forma, dsDSLs são comparativamente fáceis de projetar porque ao invés de criar uma nova sintaxe (que todo mundo vai odiar) você pode ficar com a sintaxe da sua linguagem de programação (que todo mundo odeia, mas pelo menos eles já sabem).
  • Um dsDSL tem todo o poder de uma linguagem de programação. Isso significa que um dsDSL, quando bem empregado, tem a vantagem de ser uma ferramenta de alto e baixo nível.

Agora, a última afirmação é forte, então vou passar o resto desta seção apoiando-a. O que quero dizer com devidamente empregado ? Para ver isso em ação, vamos considerar um exemplo no qual queremos construir uma tabela para exibir as informações de um array chamado 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']} ]

Em uma aplicação real, os DATA serão gerados dinamicamente a partir de uma consulta ao banco de dados.

Além disso, temos uma variável FILTER que, quando inicializada, será um array com as categorias que queremos exibir.

Queremos que nossa tabela:

  • Exibir cabeçalhos de tabela.
  • Para cada produto, mostre os campos: descrição, preço e categorias.
  • Não imprima o campo id , mas adicione-o como um atributo id para cada linha. VERSÃO ALTERNATIVA: Adicione um atributo id a cada elemento tr .
  • Coloque uma classe onSale se o produto estiver à venda.
  • Classifique os produtos por preço decrescente.
  • Filtre alguns produtos por categoria. Se FILTER for um array vazio, exibiremos todos os produtos. Caso contrário, exibiremos apenas os produtos em que a categoria do produto estiver contida em FILTER .

Podemos criar a lógica de apresentação que corresponde a esse requisito em ~20 linhas 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 não é um exemplo direto, no entanto, representa uma visão bastante simples das quatro funções básicas do armazenamento persistente, também conhecido como CRUD. Qualquer aplicativo da Web não trivial terá visualizações mais complexas do que isso.

Vamos agora ver o que este código está fazendo. Primeiro, ele define uma função, drawTable , para conter a lógica de apresentação do desenho da tabela de produtos. Esta função recebe DATA e FILTER como parâmetros, podendo ser utilizada para diferentes conjuntos de dados e filtros. drawTable cumpre o duplo papel de parcial e auxiliar.

 var drawTable = function (DATA, FILTER) {

A variável interna, printableFields , é o único lugar onde você precisa especificar quais campos são imprimíveis, evitando repetições e inconsistências diante da mudança de requisitos.

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

Em seguida, classificamos os DATA de acordo com o preço de seus produtos. Observe que critérios de classificação diferentes e mais complexos seriam fáceis de implementar, pois temos toda a linguagem de programação à nossa disposição.

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

Aqui retornamos um literal de objeto; um array que contém table como seu primeiro elemento e seu conteúdo como segundo. Esta é a representação dsDSL da <table> que queremos criar.

 return ['table', [

Agora criamos uma linha com os cabeçalhos da tabela. Para criar seu conteúdo, usamos dale.do que é uma função como Array.map, mas que também funciona para objetos. Iremos iterar printableFields e gerar cabeçalhos de tabela para cada um deles:

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

Observe que acabamos de implementar a iteração, o cavalo de batalha da geração de HTML, e não precisamos de nenhuma construção DSL; precisávamos apenas de uma função para iterar uma estrutura de dados e retornar dsDSLs. Uma função nativa semelhante ou implementada pelo usuário também teria feito o truque.

Agora itere pelos produtos contidos em DATA .

 dale.do (DATA, function (product) {

Verificamos se este produto é deixado de fora pelo FILTER . Se FILTER estiver vazio, imprimiremos o produto. Se FILTER não estiver vazio, percorreremos as categorias do produto até encontrarmos uma que esteja contida em FILTER . Fazemos isso usando dale.stop.

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

Observe a complexidade da condicional; ele é precisamente adaptado às nossas necessidades e temos total liberdade para expressá-lo porque estamos em uma linguagem de programação e não em uma DSL.

Se matches for false , retornamos um array vazio (portanto, não imprimimos este produto). Caso contrário, retornamos um <tr> com seu id e classe apropriados e iteramos através de printableFields para, bem, imprimir os campos.

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

Claro que fechamos tudo o que abrimos. A sintaxe não é divertida?

 })]; }) ]]; }

Agora, como incorporamos essa tabela em um contexto mais amplo? Escrevemos uma função chamada drawAll que invocará todas as funções que geram as visualizações. Além de drawTable , também podemos ter drawHeader , drawFooter e outras funções comparáveis, todas as quais retornarão dsDSLs .

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

Se você não gostar da aparência do código acima, nada do que eu disser irá convencê-lo. Este é um dsDSL no seu melhor . 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.
Tweet

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