Criando sua própria DSL utilizando expressões Lambda em Kotlin

Bernardo do Amaral Teodosio
Movile Tech
Published in
9 min readJun 27, 2019

Kotlin tem se tornado uma linguagem cada vez mais popular nos últimos anos. Por todas as suas funcionalidades e seus benefícios, optamos por torná-la nossa linguagem de desenvolvimento oficial para os apps Android da Wavy ainda em 2016.

Se você ainda não conhece a linguagem, sugiro que leia este artigo que escrevi no início do ano passado, comparando algumas funcionalidades do Kotlin com o tão famoso Java.

Por algumas de suas diversas features, que veremos adiante, Kotlin é uma ótima linguagem para a criação de DSLs.

Mas… O que é uma DSL?

DSL é uma sigla para Domain Specific Language (Linguagem de Domínio Específico), em tradução literal. Nada mais é que uma linguagem criada para resolver um problema específico, ou um conjunto de problemas que fazem parte de um mesmo domínio. Diferentemente das linguagens de propósito geral (como o próprio Kotlin, Java, C, C++, etc.), as DSLs são criadas para resolver problemas de um determinado domínio, ou às vezes para resolver um único problema.

Um exemplo claro de DSL é a linguagem utilizada para os scripts de build do Gradle. Quando você utiliza o Gradle, precisa escrever um script para configurar a sua build. Esse script é escrito em Groovy, mas se você já teve a oportunidade de trabalhar com Groovy antes, percebeu que a linguagem utilizada nas builds do Gradle é um pouco diferente do Groovy “comum”. Isso acontece porque, na verdade, esta linguagem é uma DSL, a Gradle Build Language.

A linguagem utilizada nos scripts de build do Gradle é uma linguagem específica de um domínio, que tem por objetivo resolver o problema de “configurar builds através de scripts”. Portanto, pode ser considerada uma DSL.

Alguns autores consideram ainda outras linguagens mais famosas e abrangentes como DSLs — como o SQL e o CSS. O SQL pode ser considerado uma DSL por resolver os problemas de consulta e gerenciamento de dados em um banco. CSS pode também ser considerado uma DSL por resolver um problema específico: configurar o layout de uma página HTML. Martin Fowler, um dos mais famosos autores sobre o assunto, considera ambas as linguagens como DSLs. A partir destes exemplos, podemos observar um fato interessante: DSLs não são necessariamente linguagens de programação.

As DSLs podem, ainda, ser classificadas em dois tipos: internas e externas. As DSLs internas são aquelas que usam uma linguagem já existente como base, alterando a sintaxe dessa linguagem de forma a criar uma nova linguagem. As DSLs externas, por outro lado, possuem necessariamente uma sintaxe própria, e em geral é necessária a criação de um parser para o processamento das mesmas. No exemplo que veremos a seguir, criaremos uma DSL interna — o que significa que o código escrito da DSL é um código Kotlin.

Legal, mas porque eu deveria usar ou criar uma DSL?

Em geral, as DSLs são criadas para facilitar a resolução de um problema específico e/ou de problemas de um domínio específico. Então, o primeiro passo para você usar uma DSL é ter um problema bem definido. Enquanto desenvolvedores, temos diversos problemas para resolver no dia a dia. Você deve ser capaz de olhar cada um dos seus problemas, pensar se uma DSL te ajudaria a resolvê-lo ou não, e verificar se o tempo de realizar a criação da mesma vale a pena.

DSLs em Kotlin

Abaixo, algumas das funcionalidades que facilitam a criação de DSLs em Kotlin:

Higher Order Functions

A primeira dessas funcionalidades é a habilidade de escrever Higher Order Functions (Funções de Ordem Superior, em tradução literal). Estas funções nada mais são do que aquelas que suportam que pelo menos um de seus argumentos seja uma outra função ou que seu retorno seja uma função.

Tais funções são úteis principalmente quando estamos trabalhando com programação funcional, pois são a base deste paradigma. Como veremos mais adiante, elas também são extremamente úteis para a criação de DSLs.

Extension Functions

Se você já está familiarizado com Kotlin, com certeza tem conhecimento sobre essa maravilha de funcionalidade que são as extension functions. Se você ainda não as conhece, sugiro que leia meu artigo no qual explico como as extension functions funcionam e porque elas são úteis.

Para agora, basta sabermos que as extension functions tem ainda mais uma funcionalidade: nos ajudar a criar DSLs.

Infix Functions

Talvez você ainda não as conheça, mas as funções infixas são uma outra funcionalidade menos famosa do Kotlin, mas que também é útil no dia a dia.

O modificador infix na assinatura de uma função permite que a mesma seja invocada em um objeto sem a necessidade de parênteses e também sem a necessidade de inserir um ponto antes do nome da função. Abaixo, um exemplo de como isso funciona:

A enumeração LogLevel possui uma função includes, que foi declarada como infixa. Isso permite que instâncias da enumeração possam chamar esta função com uma notação diferente do padrão — sem a necessidade de parênteses ou um ponto no momento da invocação (o que pode ser visto na linha 2). Isso facilita a leitura do código em alguns casos, e pode também ser uma ferramenta útil para a criação das DSLs.

Um exemplo prático — passo a passo

Na Wavy, temos um projeto interno chamado Block Builder, que é um conjunto de bibliotecas Android que tem como objetivo agilizar o desenvolvimento de novos aplicativos. O Block Builder é composto por diferentes bibliotecas — os “blocos” — e a ideia essencial do projeto é que juntando os diferentes blocos disponíveis conseguimos agilizar o desenvolvimento dos aplicativos.

Os diferentes blocos disponíveis são independentes, mas uma das premissas do projeto é que os mesmos sejam capazes de comunicar-se entre si quando necessário. Para isso, criamos um bloco base denominado “core”, que tem como objetivo controlar e gerenciar a comunicação entre os outros blocos.

O Problema

O bloco “core” depende de um processo de inicialização externo, que deve ser iniciado pelo aplicativo que o está usando. Precisávamos de uma forma simples e concisa de inicializá-lo, que fosse intuitiva e de fácil utilização para quem estivesse usando o Block Builder.

Nossa primeira tentativa foi algo assim:

Essencialmente, o core do Block Builder necessita de uma instância de um objeto do tipo “Context”, de alguns parâmetros de configuração (um arquivo local e um arquivo remoto), de uma configuração de log e também de saber quais blocos do Block Builder serão utilizados (no exemplo acima, esses blocos são especificados pelas chamadas de “addModule()”).

Nesta implementação, podemos notar diversos problemas. De cara, podemos ver que ela é grande — o que é ruim. Queríamos que o processo de inicialização fosse simples e conciso, mas esta implementação não atendia a esses requisitos. Além disso, não era nem um pouco intuitiva.

Também era muito ruim ter que especificar quais blocos seriam utilizados — pois além de tornar grande o código de inicialização, era muito fácil começar a usar algum módulo e esquecer de avisar ao “core” que o mesmo estava sendo utilizado — e isto era um problema da arquitetura do projeto.

Melhorando a arquitetura

Tentamos inicialmente resolver o problema com a arquitetura, removendo a necessidade de especificar os blocos utilizados durante o processo de inicialização. Chegamos então à seguinte solução parcial:

Simplificamos bastante o código e tivemos uma grande melhoria em relação à primeira tentativa, mas isso ainda não era o que queríamos. O processo de inicialização estava, de fato, mais simples, mas ainda não era intuitivo. Acessar a propriedade “instance” da classe BlockBuilder também era algo estranho, e que gostaríamos de evitar.

Criando a DSL

Vendo essas dificuldades, começamos então a cogitar a ideia da criação de uma DSL. Acreditamos que, com uma linguagem específica para resolver o nosso problema, conseguiríamos obter uma forma de inicialização mais simples, concisa e intuitiva, com uma sintaxe apropriada e de fácil utilização para todos aqueles que fossem utilizar o projeto em seus aplicativos. Nossa ideia era obter um código de inicialização que se parecesse com algo assim:

Usando a propriedade das Higher Order Functions em Kotlin, que permitem que quando o último argumento de função é uma outra função, os parênteses podem ser obtidos na hora de realizar a chamada, percebemos que “blockBuilder” deveria ser uma função que recebesse um único argumento — uma segunda função. A assinatura da função “blockBuilder”, então, foi desenhada:

Esta segunda função — a função de inicialização — foi denominada como “initializeAction”. Apesar de já sabermos como deveria ser a assinatura da função “blockBuilder”, não sabíamos qual deveria ser sua implementação — e nem como usá-la para fazer a inicialização que havíamos feito anteriormente, sem o uso de DSLs. Gostaríamos que fosse possível chamar a função “blockBuilder” da seguinte forma:

Para isso, necessitávamos que, de alguma forma, fôssemos capazes de escolher um valor para a propriedade “remoteFileUrl” dentro da chamada dessa função. Além disso, precisávamos definir esta propriedade em algum lugar.

Uma extension function para a função de inicialização

Para sermos capazes de usar a notação proposta, precisávamos que a função de inicialização fosse uma extension function de alguma classe, que deveria conter a propriedade “remoteFileUrl” e as outras propriedades configuráveis da implementação.

Neste exemplo, podemos notar que a assinatura da função blockBuilder foi alterada, de forma a não receber mais um parâmetro do tipo () -> Unit, mas receber agora um parâmetro do tipo BBInitializer.() -> Unit. Isso indica que a função initializeAction() tornou-se uma extension function da classe BBInitializer, definida também neste exemplo.

Na prática, isso significa que quem invocar a função blockBuilder terá acesso às propriedades e métodos públicos da classe BBInitializer, enquanto estiver definindo a função initializeAction(). Isto permite que usemos a notação remoteJsonUrl = "<value>" na hora que invocamos a função blockBuilder. O que acontece, nesse caso, é que a propriedade remoteJsonUrl pertence à uma instância da classe BBInitializer.

A implementação da função blockBuilder

Até agora, fizemos alterações na assinatura da função blockBuilder, mas resta uma pergunta fundamental: como deve ser sua implementação?

Em sua assinatura, a função blockBuilder recebe uma extension function da classe BBInitializer, mas é importante que notemos alguns pontos:

  • Em nenhum momento foi criada uma instância dessa classe
  • Em nenhum momento foi invocada a função initializeAction
  • Em nenhum momento executamos o código de inicialização do Block Builder.

Em suma: até agora, definimos como gostaríamos que fosse a notação da nossa DSL, mas não criamos um comportamento para a mesma.

Esperamos que a única coisa que o usuário faça seja invocar a função blockBuilder, passando as configurações de inicialização necessária. Com isso, a função deve ser capaz de inicializar o SDK e configurá-lo como necessário. Abaixo, podemos ver como deve ser a implementação da função:

Na linha 2, criamos a instância da classe BBInitializer, que ainda não havia sido criada. Em seguida, chamamos a função initializeAction nesta instância. Esta invocação faz com que os valores de propriedades definidos por quem chamou a função blockBuilder passem a ser os valores das propriedades da instância recém-criada.

Nas linhas 5, 8 e 11, obtemos os valores dessas propriedades, e na linha 14 fazemos a inicialização do SDK. O objeto BlockBuilder foi transformado num singleton, e por isso não é mais necessário que ele possua uma propriedade “instance”.

Apesar da implementação da função ter ficado um pouco complexa e verbosa, a principal vantagem da criação da DSL é refletida para o usuário da mesma. A função blockBuilder pode ser invocada de forma simples:

Adicionando a configuração do log

Para fazer a configuração do log, precisamos alterar a classe BBInitializer da seguinte forma:

Adicionamos uma nova classe LogInitializer — similar à classe BBInitializer — que tem por objetivo fazer a inicialização do log. Adicionamos também uma função “log”, dentro da classe BBInitializer, que recebe como parâmetro uma extension function da classe LogInitializer. Essa função é similiar à função blockBuilder.

Precisamos também alterar a função blockBuilder, para que passe agora a configurar o log do Core durante a sua inicialização, utilizando os parâmetros fornecidos pelo usuário durante a invocação da função.

Completa, a inicialização do SDK ficou da seguinte forma:

Finalizando

As principais vantagens das DSLs são para quem as utiliza — que obtém em geral uma forma simples e concisa de realizar alguma ação. A criação de uma DSL pode ser um pouco verbosa e demorada (dependendo da complexidade da mesma), e portanto, é necessário que antes de optar pela criação de uma, você analise o problema que quer resolver e verifique se isso vai lhe ajudar ou não.

Aqui você pode encontrar a palestra completa que fiz no AndroidFest do ano passado, evento organizado pelo GDG Campinas focado em desenvolvimento Android, no qual falei sobre DSLs e Kotlin. Os slides da palestra estão disponíveis aqui (inserir link)

Neste outro link, você pode encontrar também os slides de uma outra palestra sobre o mesmo assunto que fiz no Kotlin Summit 18, evento organizado pelo iMasters que reuniu desenvolvedores para discutir sobre Kotlin.

Se você quer conhecer um pouco mais sobre Kotlin e outras funcionalidades incríveis que a linguagem possui, deixo a sugestão para este artigo sobre coroutines e programação assíncrona em Kotlin, escrito pela minha amiga Lucy Narita.

Referências

--

--

Bernardo do Amaral Teodosio
Movile Tech

Tech Lead & Mobile Developer @ Sandbox Group/PlayKids | BSc CS (UNICAMP) | MSc Candidate @UNICAMP