Os programadores estão constantemente discutindo sobre qual linguagem é a melhor. Uma vez comparamos C e Pascal, mas o tempo passou. As batalhas de Python / Ruby e Java /C# já ficaram para trás. Cada idioma tem seus prós e contras, por isso os comparamos. Idealmente, gostaríamos de expandir os idiomas para atender às nossas próprias necessidades. Os programadores já têm essa oportunidade há muito tempo. Conhecemos diferentes formas de metaprogramação, ou seja, criar programas para criar programas. Mesmo macros triviais em C permitem gerar grandes pedaços de código a partir de pequenas descrições. Entretanto, essas macros não são confiáveis, são limitadas e pouco expressivas. As línguas modernas têm meios de extensão muito mais expressivos. Uma dessas linguagens é Kotlin.
Uma linguagem específica de domínio (DSL) é uma linguagem desenvolvida especificamente para uma área de assunto específica, ao contrário de linguagens de uso geral, como Java, C#, C++ e outras. Isso significa que é mais fácil, conveniente e expressivo descrever as tarefas da área disciplinar, mas ao mesmo tempo é inconveniente e impraticável para resolver tarefas cotidianas, ou seja, não é uma linguagem universal. DSL, você pode usar a linguagem de expressão regular. A área de assunto das expressões regulares é o formato das strings.
Para verificar a conformidade da string com o formato, basta usar uma biblioteca que implemente suporte para expressões regulares:
private boolean isIdentifierOrInteger(String s) { return s.matches("^\\s*(\\w+\\d*|\\d+)$"); }
Se você verificar a conformidade da string com o formato especificado em uma linguagem universal, por exemplo, Java, obterá o seguinte código:
private boolean isIdentifierOrInteger(String s) { int index = 0; while (index < s.length() && isSpaceChar(s.charAt(index))) { index++; } if (index == s.length()) { return false; } if (isLetter(s.charAt(index))) { index++; while (index < s.length() && isLetter(s.charAt(index))) index++; while (index < s.length() && isDigit(s.charAt(index))) index++; } else if (Character.isDigit(s.charAt(index))) { while (index < s.length() && isDigit(s.charAt(index))) index++; } return index == s.length(); }
O código acima é mais difícil de ler do que expressões regulares, é mais fácil cometer erros e mais complicado fazer alterações.
Outros exemplos comuns de DSL são HTML, CSS, SQL, UML e BPMN (os dois últimos usam notação gráfica). As DSLs são usadas não apenas por desenvolvedores, mas também por testadores e não especialistas em TI.
As DSLs são divididas em dois tipos: externas e internas. As linguagens DSL externas possuem sintaxe própria e não dependem da linguagem de programação universal na qual seu suporte é implementado.
Prós e contras de DSLs externas:
🟢 Geração de código em diferentes linguagens/bibliotecas prontas
🟢 Mais opções para definir sua sintaxe
🔴 Uso de ferramentas especializadas: ANTLR, yacc, lex
🔴 Às vezes é difícil descrever a gramática
🔴 Não há suporte IDE, você precisa escrever seus plugins
As DSLs internas são baseadas em uma linguagem de programação universal específica (linguagem host). Ou seja, com a ajuda de ferramentas padrão da linguagem hospedeira, são criadas bibliotecas que permitem escrever de forma mais compacta. Por exemplo, considere a abordagem Fluent API.
Prós e contras de DSLs internas:
🟢 Usa as expressões do idioma anfitrião como base
🟢 É fácil incorporar DSL no código nas linguagens host e vice-versa
🟢 Não requer geração de código
🟢 Pode ser depurado como uma sub-rotina no idioma host
🔴 Possibilidades limitadas na configuração da sintaxe
Recentemente, nós da empresa enfrentamos a necessidade de criar nossa DSL. Nosso produto implementou a funcionalidade de aceitação de compra. Este módulo é um minimotor de BPM (Business Process Management). Os processos de negócios são frequentemente representados graficamente. Por exemplo, a notação BPMN abaixo mostra um processo que consiste em executar a Tarefa 1 e, em seguida, executar a Tarefa 2 e a Tarefa 3 em paralelo.
Era importante para nós sermos capazes de criar processos de negócios de forma programática, incluindo a construção dinâmica de uma rota, definição de executores para etapas de aprovação, definição de prazo para execução de etapas, etc. Para fazer isso, primeiro tentamos resolver esse problema usando a API Fluent abordagem.
Concluímos então que definir rotas de aceitação usando a API Fluent ainda é complicado e nossa equipe considerou a opção de criar sua própria DSL. Investigamos como seria a rota de aceitação em uma DSL externa e em uma DSL interna baseada em Kotlin (porque nosso código de produto é escrito em Java e Kotlin ).
DSL externo:
acceptance addStep executor: HEAD_OF_DEPARTMENT duration: 7 days protocol should be formed parallel addStep executor: FINANCE_DEPARTMENT or CTO or CEO condition: ${!request.isInternal} duration: 7 work days after start date addStep executor: CTO dueDate: 2022-12-08 08:00 PST can change addStep executor: SECRETARY protocol should be signed
DSL interno:
acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe formed } parallel { addStep { executor = FINANCE_DEPARTMENT or CTO or CEO condition = !request.isInternal duration = startDate() + workDays(7) } addStep { executor = CTO dueDate = "2022-12-08 08:00" timezone PST +canChange } } addStep { executor = SECRETARY protocol shouldBe signed } }
Exceto pelas chaves, ambas as opções são quase iguais. Portanto, decidiu-se não perder tempo e esforço desenvolvendo uma DSL externa, mas sim criar uma DSL interna.
Vamos começar a desenvolver um modelo de objeto
interface AcceptanceElement class StepContext : AcceptanceElement { lateinit var executor: ExecutorCondition var duration: Duration? = null var dueDate: ZonedDateTime? = null val protocol = Protocol() var condition = true var canChange = ChangePermission() } class AcceptanceContext : AcceptanceElement { val elements = mutableListOf<AcceptanceElement>() fun addStep(init: StepContext.() -> Unit) { elements += StepContext().apply(init) } fun parallel(init: AcceptanceContext.() -> Unit) { elements += AcceptanceContext().apply(init) } } object acceptance { operator fun invoke(init: AcceptanceContext.() -> Unit): AcceptanceContext { val acceptanceContext = AcceptanceContext() acceptanceContext.init() return acceptanceContext } }
Primeiro, vamos dar uma olhada na classe AcceptanceContext
. Ele foi projetado para armazenar uma coleção de elementos de rota e é usado para representar todo o diagrama, bem como blocos parallel
.
Os métodos addStep
e parallel
usam um lambda com um receptor como parâmetro.
Um lambda com um receptor é uma forma de definir uma expressão lambda que tem acesso a um objeto receptor específico. Dentro do corpo da função literal, o objeto receptor passado para uma chamada torna-se um this
implícito, para que você possa acessar os membros desse objeto receptor sem quaisquer qualificadores adicionais ou acessar o objeto receptor usando uma expressão this
.
Além disso, se o último argumento de uma chamada de método for lambda, então o lambda poderá ser colocado fora dos parênteses. É por isso que em nossa DSL podemos escrever um código como o seguinte:
parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } }
Isso é equivalente a um código sem açúcar sintático:
parallel({ this.addStep({ this.executor = FINANCE_DEPARTMENT ... }) this.addStep({ this.executor = CTO ... }) })
Lambdas com receptores e Lambda fora dos parênteses são recursos do Kotlin particularmente úteis ao trabalhar com DSLs.
Agora vamos dar uma olhada na acceptance
da entidade. acceptance
é um objeto. Em Kotlin, uma declaração de objeto é uma forma de definir um singleton – uma classe com apenas uma instância. Portanto, a declaração do objeto define a classe e sua instância única ao mesmo tempo.
Além disso, o operador invoke
fica sobrecarregado para o objeto accreditation
. O operador invoke
é uma função especial que você pode definir em suas classes. Quando você invoca uma instância de uma classe como se fosse uma função, a função do operador invoke
é chamada. Isso permite tratar objetos como funções e chamá-los de maneira semelhante a uma função.
Observe que o parâmetro do método invoke
também é um lambda com um receptor. Agora podemos definir uma rota de aceitação…
val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT ... } parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } } addStep { executor = SECRETARY ... } }
…e percorra isso
val headOfDepartmentStep = acceptanceRoute.elements[0] as StepContext val parallelBlock = acceptanceRoute.elements[1] as AcceptanceContext val ctoStep = parallelBlock.elements[1] as StepContext
Dê uma olhada neste código
addStep { executor = FINANCE_DEPARTMENT or CTO or CEO ... }
Podemos implementar isso da seguinte maneira:
enum class ExecutorConditionType { EQUALS, OR } data class ExecutorCondition( private val name: String, private val values: Set<ExecutorCondition>, private val type: ExecutorConditionType, ) { infix fun or(another: ExecutorCondition) = ExecutorCondition("or", setOf(this, another), ExecutorConditionType.OR) } val HEAD_OF_DEPARTMENT = ExecutorCondition("HEAD_OF_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val FINANCE_DEPARTMENT = ExecutorCondition("FINANCE_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val CHIEF = ExecutorCondition("CHIEF", setOf(), ExecutorConditionType.EQUALS) val CTO = ExecutorCondition("CTO", setOf(), ExecutorConditionType.EQUALS) val SECRETARY = ExecutorCondition("SECRETARY", setOf(), ExecutorConditionType.EQUALS)
A classe ExecutorCondition
nos permite definir vários executores de tarefas possíveis. Em ExecutorCondition
definimos a função infix or
. Uma função infixa é um tipo especial de função que permite chamá-la usando uma notação infixa mais natural.
Sem usar esse recurso da linguagem, teríamos que escrever assim:
addStep { executor = FINANCE_DEPARTMENT.or(CTO).or(CEO) ... }
As funções Infix também são usadas para definir o estado necessário do protocolo e a hora com um fuso horário.
enum class ProtocolState { formed, signed } class Protocol { var state: ProtocolState? = null infix fun shouldBe(state: ProtocolState) { this.state = state } } enum class TimeZone { ... PST, ... } infix fun String.timezone(tz: TimeZone): ZonedDateTime { val format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z") return ZonedDateTime.parse("$this $tz", format) }
String.timezone
é uma função de extensão. No Kotlin, as funções de extensão permitem adicionar novas funções a classes existentes sem modificar seu código-fonte. Esse recurso é particularmente útil quando você deseja aumentar a funcionalidade de classes sobre as quais você não tem controle, como classes de bibliotecas padrão ou externas.
Uso no DSL:
addStep { ... protocol shouldBe formed dueDate = "2022-12-08 08:00" timezone PST ... }
Aqui "2022-12-08 08:00"
é um objeto receptor, no qual a função de extensão timezone
é chamada, e PST
é o parâmetro. O objeto receptor é acessado usando this
palavra-chave.
O próximo recurso Kotlin que usamos em nossa DSL é a sobrecarga do operador. Já consideramos a sobrecarga do operador invoke
. No Kotlin, você pode sobrecarregar outros operadores, inclusive os aritméticos.
addStep { ... +canChange }
Aqui o operador unário +
está sobrecarregado. Abaixo está o código que implementa isso:
class StepContext : AcceptanceElement { ... var canChange = ChangePermission() } data class ChangePermission( var canChange: Boolean = true, ) { operator fun unaryPlus() { canChange = true } operator fun unaryMinus() { canChange = false } }
Agora podemos descrever as rotas de aceitação em nossa DSL. No entanto, os usuários DSL devem ser protegidos de possíveis erros. Por exemplo, na versão atual, o seguinte código é aceitável:
val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe signed addStep { executor = FINANCE_DEPARTMENT } } }
addStep
dentro de addStep
parece estranho, não é? Vamos descobrir por que esse código é compilado com êxito e sem erros. Conforme mencionado acima, os métodos acceptance#invoke
e AcceptanceContext#addStep
usam um lambda com um receptor como parâmetro, e um objeto receptor é acessível por uma palavra-chave this
. Portanto, podemos reescrever o código anterior assim:
val acceptanceRoute = acceptance { [email protected] { [email protected] = HEAD_OF_DEPARTMENT [email protected] = days(7) [email protected] shouldBe signed [email protected] { executor = FINANCE_DEPARTMENT } } }
Agora você pode ver que [email protected]
é chamado nas duas vezes. Especialmente para esses casos, o Kotlin possui uma anotação DslMarker
. Você pode usar @DslMarker
para definir anotações personalizadas. Receptores marcados com a mesma anotação não podem ser acessados um dentro do outro.
@DslMarker annotation class AcceptanceDslMarker @AcceptanceDslMarker class AcceptanceContext : AcceptanceElement { ... } @AcceptanceDslMarker class StepContext : AcceptanceElement { ... }
Agora o código
val acceptanceRoute = acceptance { addStep { ... addStep { ... } } }
não será compilado devido a um erro 'fun addStep(init: StepContext.() -> Unit): Unit' can't be called in this context by implicit receiver. Use the explicit one if necessary
Abaixo estão links para a documentação oficial do Kotlin sobre os recursos da linguagem considerados neste artigo:
Linguagens específicas de domínio oferecem um meio poderoso para aumentar a produtividade, reduzir erros e melhorar a colaboração, fornecendo uma maneira especializada e expressiva de modelar e resolver problemas dentro de um domínio específico. Kotlin é uma linguagem de programação moderna com muitos recursos e açúcar sintático, por isso é excelente como linguagem host para DSL interno.