Программисты постоянно спорят о том, какой язык лучший. Однажды мы сравнивали Си и Паскаль, но время шло. Битвы / и /C# уже позади. У каждого языка есть свои плюсы и минусы, поэтому мы их сравниваем. В идеале мы хотели бы расширить количество языков в соответствии с нашими потребностями. У программистов была такая возможность уже очень давно. Мы знаем разные способы метапрограммирования, то есть создания программ для создания программ. Даже тривиальные макросы на языке C позволяют генерировать большие фрагменты кода из небольших описаний. Однако эти макросы ненадежны, ограничены и маловыразительны. Современные языки обладают гораздо более выразительными средствами расширения. Одним из таких языков является Котлин. Python Ruby Java Определение предметно-ориентированного языка — это язык, разработанный специально для конкретной предметной области, в отличие от языков общего назначения, таких как Java, C#, C++ и других. Это означает, что описывать задачи предметной области проще, удобнее и выразительнее, но в то же время он неудобен и непрактичен для решения повседневных задач, т.е. не является универсальным языком. DSL, можно взять язык регулярных выражений. Предметной областью регулярных выражений является формат строк. Предметно-ориентированный язык (DSL) Чтобы проверить строку на соответствие формату, достаточно просто использовать библиотеку, реализующую поддержку регулярных выражений: private boolean isIdentifierOrInteger(String s) { return s.matches("^\\s*(\\w+\\d*|\\d+)$"); } Если вы проверите строку на соответствие заданному формату на универсальном языке, например Java, вы получите следующий код: 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(); } Приведенный выше код труднее читать, чем регулярные выражения, в нем легче допускать ошибки и сложнее вносить изменения. Другими распространенными примерами DSL являются HTML, CSS, SQL, UML и BPMN (последние два используют графическую нотацию). DSL используются не только разработчиками, но также тестировщиками и специалистами, не связанными с ИТ. Типы DSL DSL делятся на два типа: внешние и внутренние. имеют собственный синтаксис и не зависят от универсального языка программирования, в котором реализована их поддержка. Внешние языки DSL Плюсы и минусы внешних DSL: 🟢 Генерация кода на разных языках/готовые библиотеки 🟢 Дополнительные возможности настройки синтаксиса 🔴 Использование специализированных инструментов: ANTLR, yacc, lex. 🔴 Иногда сложно описать грамматику 🔴 Нет поддержки IDE, нужно писать свои плагины основаны на определенном универсальном языке программирования (хост-языке). То есть с помощью стандартных средств основного языка создаются библиотеки, позволяющие писать более компактно. В качестве примера рассмотрим подход Fluent API. Внутренние DSL Плюсы и минусы внутренних DSL: 🟢 Использует в качестве основы выражения принимающего языка 🟢 Легко встроить DSL в код на хост-языках и наоборот 🟢 Не требует генерации кода 🟢 Может быть отлажена как подпрограмма на основном языке. 🔴 Ограниченные возможности настройки синтаксиса Реальный пример Недавно мы в компании столкнулись с необходимостью создания своего DSL. В нашем продукте реализован функционал приема покупок. Этот модуль представляет собой мини-движок BPM (Управление бизнес-процессами). Бизнес-процессы часто представляются графически. Например, в нотации BPMN ниже показан процесс, который состоит из выполнения задачи 1, а затем параллельного выполнения задач 2 и задачи 3. Нам было важно иметь возможность программно создавать бизнес-процессы, в том числе динамически строить маршрут, задавать исполнителей для этапов согласования, задавать сроки выполнения этапов и т. д. Для этого мы сначала попытались решить эту задачу с помощью Fluent API. подход. Тогда мы пришли к выводу, что настройка маршрутов приема с помощью Fluent API по-прежнему оказывается громоздкой и наша команда рассмотрела вариант создания собственного DSL. Мы исследовали, как будет выглядеть маршрут принятия на внешнем DSL и внутреннем DSL на основе Kotlin (поскольку код нашего продукта написан на Java и ). Kotlin Внешний DSL: 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: 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 } } За исключением фигурных скобок, оба варианта практически одинаковы. Поэтому было решено не тратить время и силы на разработку внешнего DSL, а создать внутренний DSL. Реализация базовой структуры DSL Приступим к разработке объектной модели 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 } } Лямбды Сначала давайте посмотрим на класс . Он предназначен для хранения набора элементов маршрута и используется для представления всей диаграммы, а также блоков. AcceptanceContext parallel Методы и принимают лямбду с получателем в качестве параметра. addStep parallel Лямбда-выражение с получателем — это способ определить лямбда-выражение, имеющее доступ к определенному объекту-получателю. Внутри тела функционального литерала объект-получатель, переданный в вызов, становится неявным , так что вы можете получить доступ к членам этого объекта-получателя без каких-либо дополнительных квалификаторов или получить доступ к объекту-получателю с помощью выражения . this this Кроме того, если последним аргументом вызова метода является лямбда, то лямбда может быть помещена вне круглых скобок. Вот почему в нашем DSL мы можем написать такой код: parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } } Это эквивалентно коду без синтаксического сахара: parallel({ this.addStep({ this.executor = FINANCE_DEPARTMENT ... }) this.addStep({ this.executor = CTO ... }) }) Лямбды с получателями и лямбда вне круглых скобок — это функции Kotlin, которые особенно полезны при работе с DSL. Объявление объекта Теперь давайте посмотрим на сущности. — это объект. В Котлине объявление объекта — это способ определить синглтон — класс только с одним экземпляром. Таким образом, объявление объекта одновременно определяет и класс, и его единственный экземпляр. acceptance acceptance перегрузка оператора «вызова» Кроме того, для объекта перегружен оператор . Оператор — это специальная функция, которую вы можете определить в своих классах. Когда вы вызываете экземпляр класса, как если бы это была функция, операторная функция вызова. Это позволяет вам обращаться с объектами как с функциями и вызывать их как функции. accreditation invoke invoke invoke Обратите внимание, что параметр метода также является лямбдой с получателем. Теперь мы можем определить маршрут приемки… invoke val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT ... } parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } } addStep { executor = SECRETARY ... } } … и пройти через него val headOfDepartmentStep = acceptanceRoute.elements[0] as StepContext val parallelBlock = acceptanceRoute.elements[1] as AcceptanceContext val ctoStep = parallelBlock.elements[1] as StepContext Добавление деталей Инфиксные функции Взгляните на этот код addStep { executor = FINANCE_DEPARTMENT or CTO or CEO ... } Мы можем реализовать это следующим образом: 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) Класс позволяет нам задать несколько возможных исполнителей задач. В мы определяем инфиксную функцию . Инфиксная функция — это особый вид функции, который позволяет вызывать ее, используя более естественную инфиксную запись. ExecutorCondition ExecutorCondition or Без использования этой особенности языка нам пришлось бы писать так: addStep { executor = FINANCE_DEPARTMENT.or(CTO).or(CEO) ... } Инфиксные функции также используются для установки необходимого состояния протокола и времени с часовым поясом. 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) } Функции расширения — это функция расширения. В Kotlin функции расширения позволяют добавлять новые функции к существующим классам без изменения их исходного кода. Эта функция особенно полезна, когда вы хотите расширить функциональность классов, над которыми у вас нет контроля, например классов из стандартных или внешних библиотек. String.timezone Использование в DSL: addStep { ... protocol shouldBe formed dueDate = "2022-12-08 08:00" timezone PST ... } Здесь — это объект-получатель, для которого вызывается функции расширения, а — параметр. Доступ к объекту-получателю осуществляется с помощью ключевого слова. "2022-12-08 08:00" timezone PST this Перегрузка оператора Следующая функция Kotlin, которую мы используем в нашем DSL, — это перегрузка операторов. Мы уже рассмотрели перегрузку оператора . В Котлине можно перегружать другие операторы, в том числе арифметические. invoke addStep { ... +canChange } Здесь унарный оператор перегружен. Ниже приведен код, который это реализует: + class StepContext : AcceptanceElement { ... var canChange = ChangePermission() } data class ChangePermission( var canChange: Boolean = true, ) { operator fun unaryPlus() { canChange = true } operator fun unaryMinus() { canChange = false } } Завершающий штрих Теперь мы можем описать маршруты приема на нашем DSL. Однако пользователи DSL должны быть защищены от возможных ошибок. Например, в текущей версии допустим следующий код: val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe signed addStep { executor = FINANCE_DEPARTMENT } } } внутри выглядит странно, не правда ли? Давайте разберемся, почему этот код успешно компилируется без ошибок. Как упоминалось выше, методы и принимают лямбда-выражение с получателем в качестве параметра, а объект-получатель доступен по ключевому слову . Итак, мы можем переписать предыдущий код следующим образом: addStep addStep acceptance#invoke AcceptanceContext#addStep this val acceptanceRoute = acceptance { this@acceptance.addStep { this@addStep.executor = HEAD_OF_DEPARTMENT this@addStep.duration = days(7) this@addStep.protocol shouldBe signed this@acceptance.addStep { executor = FINANCE_DEPARTMENT } } } Теперь вы можете видеть, что вызывается оба раза. Специально для таких случаев в Котлине есть аннотация . Вы можете использовать для определения пользовательских аннотаций. К получателям, отмеченным одной и той же такой аннотацией, нельзя получить доступ друг в друге. this@acceptance.addStep DslMarker @DslMarker @DslMarker annotation class AcceptanceDslMarker @AcceptanceDslMarker class AcceptanceContext : AcceptanceElement { ... } @AcceptanceDslMarker class StepContext : AcceptanceElement { ... } Теперь код val acceptanceRoute = acceptance { addStep { ... addStep { ... } } } не будет компилироваться из-за ошибки 'fun addStep(init: StepContext.() -> Unit): Unit' can't be called in this context by implicit receiver. Use the explicit one if necessary Ссылки Ниже приведены ссылки на официальную документацию Kotlin по возможностям языка, рассмотренным в этой статье: Функциональные литералы с получателем https://kotlinlang.org/docs/lambdas.html#function-literals-with-receiver Передача конечных лямбда-выражений https://kotlinlang.org/docs/lambdas.html#passing-trailing-lambdas Объявления объектов https://kotlinlang.org/docs/object-declarations.html#object-declarations-overview Перегрузка оператора https://kotlinlang.org/docs/operator-overloading.html Инфиксная нотация https://kotlinlang.org/docs/functions.html#infix-notation Функции расширения https://kotlinlang.org/docs/extensions.html#extension-functions Контроль области: @DslMarker https://kotlinlang.org/docs/type-safe-builders.html#scope-control-dslmarker Заключение Языки, ориентированные на предметную область, предлагают мощные средства повышения производительности, уменьшения количества ошибок и улучшения совместной работы, предоставляя специализированный и выразительный способ моделирования и решения проблем в конкретной области. Kotlin — это современный язык программирования со множеством функций и синтаксическим сахаром, поэтому он отлично подходит в качестве основного языка для внутреннего DSL.