Статьи

Котлин Д.С.Л .: от теории к практике

Я попытаюсь объяснить синтаксис языка как можно более простым, однако статья по-прежнему привлекательна для разработчиков, которые рассматривают Kotlin как язык для пользовательского построения DSL. В конце статьи я упомяну о недостатках Kotlin, которые стоит учитывать. Представленные фрагменты кода относятся к Kotlin версии 1.2.0 и доступны на GitHub.

Что такое DSL?

Все языки программирования можно разделить на языки общего назначения и области применения. SQL, регулярные выражения, build.gradle часто приводятся в качестве примеров DSL. Эти языки ограничены в функциональности, но они способны эффективно решать определенную проблему. Они позволяют писать не императивный код (мы не должны объяснять, как решить проблему), а более или менее декларативным способом (мы просто объявляем задачу), чтобы получить решение на основе данных.

Допустим, у вас есть стандартное определение процесса, которое в конечном итоге можно изменить и улучшить, но обычно вы хотите использовать его с различными форматами данных и результатов. Создавая DSL, вы создаете гибкий инструмент для решения различных проблем в пределах одной предметной области, независимо от того, каким образом ваше решение получит конечный пользователь DSL. Таким образом, вы создаете своего рода API, который, если его освоить, может упростить вашу жизнь и упростить поддержание актуальности системы в долгосрочной перспективе.

Статья посвящена созданию встроенного DSL в Kotlin как языка, реализованного на синтаксисе языков общего назначения. Вы можете прочитать больше об этом здесь .

Область реализации

На мой взгляд, одним из лучших способов использования и демонстрации Kotlin DSL является тестирование.

Предположим, что вы пришли из мира Java. Как часто вы сталкивались с объявлением экземпляров сущности обширной модели данных? Вероятно, вы использовали некоторые компоновщики или, что еще хуже, специальные служебные классы для заполнения значений по умолчанию. Сколько переопределенных методов у вас было? Как часто вам приходится вносить небольшие изменения в значения по умолчанию, и сколько усилий это требует сегодня? Если эти вопросы вызывают только негативные чувства, эта статья для вас.

Именно так мы долго работали в нашем проекте в области образования: мы использовали строителей и вспомогательные классы, чтобы покрыть тестами один из наших самых важных модулей — планирование школьного расписания. Теперь этот подход уступил языку Kotlin и DSL, который используется для описания тестовых сценариев и проверки результатов. Ниже вы можете увидеть, как мы воспользовались Kotlin, чтобы тестирование подсистемы планирования больше не было пыткой.

В этой статье мы углубимся в детали построения DSL, который помогает протестировать алгоритм построения расписаний учителей и учеников.

Ключевые инструменты

Вот основные языковые функции, которые позволяют вам писать более чистый код на Kotlin и создавать свой собственный DSL. Таблица ниже демонстрирует основные улучшения синтаксиса, которые стоит использовать. Посмотрите на это внимательно. Если большинство этих инструментов вам незнакомы, вам лучше прочитать всю статью. Если вы не знаете, один или два из них, не стесняйтесь быстро перейти к соответствующим разделам. Если для вас нет ничего нового, просто перейдите к обзору недостатков DSL в конце статьи. Вы также можете предложить больше инструментов в комментариях.

Инструмент Синтаксис DSL Общий синтаксис
1
Operators overloading
1
collection += element
1
collection.add(element)
1
Type aliases
1
typealias Point = Pair
1
Creating empty inheritors classes and other duct tapes
1
get/set methods convention
1
map["key"] = "value"
1
map.put("key", "value")
1
Destructuring declaration
1
val (x, y) = Point(0, 0)
1
val p = Point(0, 0); val x = p.first; val y = p.second
1
Lambda out of parentheses
1
list.forEach { ... }
1
list.forEach({...})
1
Extension functions
1
mylist.first(); // there isn’t first() method in mylist collection
1
Utility functions
1
Infix functions
1
1 to "one"
1
1.to("one")
1
Lambda with receiver
1
Person().apply { name = «John» }
1
N/A
1
Context control
1
@DslMarker
1
N/A

Нашли что-нибудь новое? Если так, давайте двигаться дальше.

Я намеренно опустил делегированные свойства, так как, по моему мнению, они бесполезны для построения DSL, по крайней мере, в нашем случае. Используя описанные выше функции, мы можем написать более чистый код и избавиться от объемного «шумного» синтаксиса, что делает разработку еще более приятной (может ли это быть?).

Мне понравилось сравнение, которое я встретил в книге «Kotlin in Action»: в естественных языках, как и в английском, предложения построены из слов, а грамматические правила определяют способ объединения этих слов. Точно так же в DSL одна операция может быть построена из нескольких вызовов метода, и проверка типа гарантирует, что конструкция имеет смысл. Конечно, порядок вызовов не всегда может быть очевидным, но это полностью зависит от дизайнера DSL.

Важно подчеркнуть, что в этой статье рассматривается «встроенный DSL», поэтому он основан на языке общего назначения — Kotlin.

Пример финального результата

Прежде чем мы начнем разрабатывать свой собственный предметно-ориентированный язык, я хотел бы показать вам пример того, что вы сможете создать после прочтения этой статьи. Весь код доступен в репозитории GitHub по ссылке .
Приведенный ниже код на основе DSL предназначен для проверки распределения учителя по ученикам по определенным дисциплинам. В этом примере у нас есть фиксированное расписание, и мы проверяем, включены ли классы в расписание для учителей и учеников.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
schedule {
    data {
        startFrom("08:00")
        subjects("Russian",
                "Literature",
                "Algebra",
                "Geometry")
        student {
            name = "Ivanov"
            subjectIndexes(0, 2)
        }
        student {
            name = "Petrov"
            subjectIndexes(1, 3)
        }
        teacher {
           subjectIndexes(0, 1)
           availability {
             monday("08:00")
             wednesday("09:00", "16:00")
           }
        }
        teacher {
            subjectIndexes(2, 3)
            availability {
                thursday("08:00") + sameDay("11:00") + sameDay("14:00")
            }
        }
        // data { } won't be compiled here because there is scope control with
        // @DataContextMarker
    } assertions {
        for ((day, lesson, student, teacher) in scheduledEvents) {
            val teacherSchedule: Schedule = teacher.schedule
            teacherSchedule[day, lesson] shouldNotEqual null
            teacherSchedule[day, lesson]!!.student shouldEqual student
            val studentSchedule = student.schedule
            studentSchedule[day, lesson] shouldNotEqual null
            studentSchedule[day, lesson]!!.teacher shouldEqual teacher
        }
    }
}

Инструментарий

Все функции для создания DSL были перечислены выше. Каждый из них используется в примере из предыдущего раздела. Вы можете проверить, как определить такие конструкции DSL в моем проекте на GitHub.
Мы еще вернемся к этому примеру ниже, чтобы продемонстрировать использование различных инструментов. Пожалуйста, имейте в виду, что описанные подходы предназначены только для иллюстративных целей, и могут быть другие варианты для достижения желаемого результата.
Итак, давайте рассмотрим эти инструменты один за другим. Некоторые языковые функции наиболее эффективны в сочетании с другими, и первая в этом списке — лямбда из скобок.

Лямбда из скобок

Документация

Лямбда-выражение — это блок кода, который можно передать в функцию, сохранить или вызвать. В Kotlin лямбда-тип определяется следующим образом: (список типов параметров) -> возвращаемый тип. Следуя этому правилу, самый примитивный лямбда-тип — это () -> Unit, где Unit является эквивалентом Void с одним важным исключением. В конце лямбды нам не нужно писать обратную… конструкцию. Таким образом, у нас всегда есть возвращаемый тип, но в Kotlin это делается неявно.
Ниже приведен базовый пример назначения лямбда-переменной:

1
val helloPrint: (String) -> Unit = { println(it) }

Обычно компилятор пытается вывести тип из уже известных. В нашем случае есть параметр. Эта лямбда может быть вызвана следующим образом:

1
helloPrint("Hello")

В приведенном выше примере лямбда принимает один параметр. Внутри лямбды этот параметр называется по умолчанию, но если их было больше, вам пришлось бы явно указывать их имена или использовать подчеркивание, чтобы их игнорировать. Смотрите такой случай ниже:

1
2
val helloPrint: (String, Int) -> Unit = { _, _ -> println("Do nothing") }
helloPrint("Does not matter", 42) //output: Do nothing

Базовый инструмент — который вы, возможно, уже знаете из Groovy — это лямбда из скобок. Посмотрите еще раз на пример с самого начала статьи: почти каждое использование фигурных скобок, кроме стандартных конструкций, является лямбда-выражением. Существует как минимум два способа создания x {…}: — подобной конструкции:

  • объект x и его унарный оператор вызывают (мы обсудим это позже);
  • функция х, которая принимает лямбду.

В обоих случаях мы используем лямбду. Давайте предположим, что есть функция x (). В Kotlin, если лямбда является последним аргументом функции, ее можно поместить в скобки, кроме того, если лямбда является единственным параметром функции, скобки могут быть опущены. В результате конструкция x ({…}) может быть преобразована в x () {}, а затем, опуская скобки, мы получаем x {}. Вот как мы объявляем такие функции:

1
fun x( lambda: () -> Unit ) { lambda() }

В сжатой форме однострочная функция также может быть написана так:

1
fun x( lambda: () -> Unit ) = lambda()

Но что, если x является экземпляром класса или объектом, а не функцией? Ниже приведено еще одно интересное решение, основанное на фундаментальной концепции предметной области: перегрузка операторов.

Перегрузка оператора

Документация

Kotlin предоставляет широкий, но ограниченный выбор операторов. Модификатор оператора позволяет определять функции по соглашениям, которые будут вызываться при определенных условиях. В качестве очевидного примера, функция плюс выполняется, если вы используете оператор «+» между двумя объектами. Полный список операторов можно найти в документации по ссылке выше.
Давайте рассмотрим менее тривиальный оператор вызова . Основной пример этой статьи начинается с конструкции schedule {}, которая определяет блок кода, отвечающий за тестирование расписания. Эта конструкция построена немного иначе, чем та, что упоминалась выше: мы используем оператор invoke + «лямбда из скобок». Определив оператор invoke, теперь мы можем использовать конструкцию schedule (…) , хотя schedule является объектом. Фактически, когда вы вызываете schedule (…) , компилятор интерпретирует его как schedule.invoke (…) . Посмотрим, как объявлено расписание :

1
2
3
4
5
object schedule {
    operator fun invoke(init: SchedulingContext.() -> Unit)  {
        SchedulingContext().init()
    }
}

Идентификатор расписания отсылает нас к единственному экземпляру класса расписания (singleton), который помечен специальным ключевым словом object (более подробную информацию о таких объектах можно найти здесь ). Таким образом, мы вызываем метод invoke экземпляра расписания , получая лямбду в качестве единственного параметра и помещая ее за пределы скобок. В результате построение графика {…} соответствует следующему:

1
schedule.invoke( { code inside lambda } )

Однако, если вы внимательно посмотрите на метод invoke, вы увидите не обычную лямбду, а «лямбду с обработчиком» или «лямбду с контекстом», тип которой определяется как

1
SchedulingContext.() -> Unit

Давайте рассмотрим это подробнее.

Лямбда с обработчиком

Документация

Kotlin позволяет нам устанавливать контекст для лямбда-выражений (контекст и обработчик здесь означают одно и то же). Контекст — это просто объект. Тип контекста определяется вместе с типом лямбда-выражения. Такая лямбда-функция получает свойства нестатического метода в классе контекста, но имеет доступ только к открытым методам этого класса.

Хотя тип нормальной лямбды определяется как () -> Unit, тип лямбды с контекстом X определяется следующим образом: X. () -> Unit, и, если обычные лямбды можно вызывать обычным способом:

1
2
val x : () -> Unit = {}
x()

лямбда с контекстом требует контекста:

01
02
03
04
05
06
07
08
09
10
11
class MyContext
 
val x : MyContext.() -> Unit = {}
 
//x() //won’t be compiled, because a context isn’t defined
 
val c = MyContext() //create the context
 
c.x() //works
 
x(c) //works as well

Я хотел бы напомнить, что мы определили оператор invoke в объекте расписания (см. Предыдущий абзац), который позволяет нам использовать конструкцию:

1
schedule { }

Лямбда, которую мы используем, имеет контекст типа SchedulingContext. В этом классе есть метод данных. В результате мы получаем следующую конструкцию:

1
2
3
4
5
schedule {
    data {
        //...
    }
}

Как вы уже, наверное, догадались, метод данных также принимает лямбда с контекстом, однако это другой контекст. Таким образом, мы получаем вложенные структуры, имеющие несколько контекстов внутри одновременно. Чтобы понять, как это работает, давайте удалим весь синтаксический сахар из примера:

1
2
3
4
schedule.invoke({
    this.data({
    })
})

Как видите, все довольно просто. Давайте посмотрим на реализацию оператора вызова.

1
2
3
operator fun invoke(init: SchedulingContext.() -> Unit)  {
    SchedulingContext().init()
}

Мы вызываем конструктор для контекста SchedulingContext (), а затем с созданным объектом ( контекстом ) вызываем лямбду с идентификатором init, который мы передали в качестве параметра. Это очень напоминает общий вызов функции. В результате в одной строке SchedulingContext (). Init () мы создаем контекст и вызываем лямбду, переданную оператору. Для большего количества примеров рассмотрим применение и методы из стандартной библиотеки Kotlin.

В последних примерах мы обнаружили оператор invoke и его комбинацию с другими инструментами. Далее мы сосредоточимся на инструменте, который формально является оператором и делает код чище — соглашение о методах get / set .

соглашение о методах get / set

Документация

При создании DSL мы можем реализовать способ доступа к картам одним или несколькими ключами. Давайте посмотрим на пример ниже:

1
2
availabilityTable[DayOfWeek.MONDAY, 0] = true
println(availabilityTable[DayOfWeek.MONDAY, 0]) //output: true

Чтобы использовать квадратные скобки, нам нужно реализовать методы get или set (в зависимости от того, что нам нужно, читать или обновлять) с модификатором оператора . Вы можете найти пример такой реализации в классе Matrix на GitHub . Это простая оболочка для матричных операций. Ниже вы видите фрагмент кода по теме:

1
2
3
4
5
class Matrix(...) {
    private val content: List>
    operator fun get(i: Int, j: Int) = content[i][j]
    operator fun set(i: Int, j: Int, value: T) { content[i][j] = value }
}

Вы можете использовать любые типы параметров get и set , единственным ограничением является ваше воображение. Вы можете использовать один или несколько параметров для функций get / set, чтобы обеспечить удобный синтаксис для доступа к данным. Операторы в Котлине предоставляют множество интересных функций, которые описаны в документации .

Удивительно, но в стандартной библиотеке Kotlin есть класс Pair . Большая часть сообщества разработчиков считает, что Pair вреден: при использовании Pair логика связывания двух объектов теряется, поэтому непонятно, почему они связаны. Два инструмента, которые я покажу вам далее, покажут, как сохранить парные смыслы без создания дополнительных классов.

Введите псевдонимы

Документация

Предположим, нам нужен класс-обёртка для географической точки с целочисленными координатами. На самом деле, мы могли бы использовать класс Pair, но имея такую ​​переменную, мы можем в одно мгновение потерять понимание того, почему мы соединили эти значения.

Простое решение — создать пользовательский класс или что-то еще хуже. Kotlin обогащает инструментарий разработчика псевдонимами типов со следующими обозначениями:

1
typealias Point = Pair

На самом деле, это не что иное, как переименование конструкции. Благодаря такому подходу нам больше не нужно создавать класс Point , поскольку он будет дублировать только пару . Теперь мы можем создать точку следующим образом:

1
val point = Point(0, 0)

Однако у класса Pair есть два атрибута: первый и второй , которые нам нужно как-то переименовать, чтобы стереть любые различия между необходимой точкой и исходным классом Pair . Конечно, мы не можем переименовывать сами атрибуты (однако вы можете создавать свойства расширения ), но в нашем инструментарии есть еще одна примечательная особенность, называемая декларацией деструктурирования .

Уничтожающая декларация

Документация

Давайте рассмотрим простой случай: предположим, у нас есть объект типа Point, который, как мы уже знаем, просто переименованный тип Pair. Если мы посмотрим на реализацию класса Pair в стандартной библиотеке, то увидим, что в ней есть модификатор данных, который указывает компилятору реализовать методы componentN внутри этого класса. Давайте узнаем больше об этом.

Для любого класса мы можем определить оператор componentN, который будет отвечать за предоставление доступа к одному из атрибутов объекта. Это означает, что вызов point.component1 будет равен вызову point.first . Зачем нам такое дублирование?
Декларация разрушения — это средство «разложения» объекта на переменные. Эта функциональность позволяет нам писать конструкции следующего вида:

1
val (x, y) = Point(0, 0)

Мы можем объявить несколько переменных одновременно, но каким значениям они будут назначены? Вот почему нам нужны сгенерированные методы componentN : используя индекс, начинающийся с 1 вместо N, мы можем разложить объект на набор его атрибутов. Итак, вышеуказанная конструкция равна следующему:

1
2
3
val pair = Point(0, 0)
val x = pair.component1()
val y = pair.component2()

что, в свою очередь, равно:

1
2
3
val pair = Point(0, 0)
val x = pair.first
val y = pair.second

где first и second — атрибуты объекта Point.

Цикл for в Kotlin выглядит следующим образом, где x принимает значения 1, 2 и 3:

1
for(x in listOf(1, 2, 3)) { … }

Обратите внимание на блок утверждений в DSL из основного примера. Я повторю часть этого для удобства:

1
for ((day, lesson, student, teacher) in scheduledEvents) { … }

Эта линия должна быть очевидной. Мы перебираем коллекцию scheduleEvents , каждый элемент которой разбит на 4 атрибута.

Функции расширения

Документация

Добавление новых методов к объектам из сторонних библиотек или к Java Collection Framework — это то, о чем мечтали многие разработчики. Теперь у нас есть такая возможность. Вот как мы объявляем функции расширения:

1
fun AvailabilityTable.monday(from: String, to: String? = null)

По сравнению со стандартным методом мы добавляем имя класса в качестве префикса для определения расширяемого класса. В приведенном выше примере AvailabilityTable является псевдонимом для типа Matrix, и, поскольку псевдонимы в Kotlin представляют собой не что иное, как переименование, это объявление равно приведенному ниже, что не всегда удобно:

1
fun Matrix.monday(from: String, to: String? = null)

К сожалению, здесь мы ничего не можем сделать, кроме как не использовать инструмент или добавлять методы только в определенный класс контекста. В этом случае магия появляется только там, где она вам нужна. Более того, вы можете использовать такие функции даже для расширения интерфейсов. В качестве хорошего примера, первый метод расширяет любой итерируемый объект :

1
fun  Iterable.first(): T

По сути, любая коллекция, основанная на интерфейсе Iterable , независимо от типа элемента, получает первый метод. Стоит отметить, что мы можем поместить метод расширения в класс контекста и, таким образом, иметь доступ к методу расширения только в этом контексте (аналогично лямбда с контекстом). Кроме того, мы можем создать функции расширения для типов Nullable (объяснение типов Nullable здесь выходит за рамки, более подробно см. Эту ссылку ). Например, вот как мы можем использовать функцию isNullOrEmpty из стандартной библиотеки Kotlin, которая расширяет тип CharSequence :

1
2
val s: String? = null
s.isNullOrEmpty() //true

Ниже подпись этой функции:

1
fun CharSequence?.isNullOrEmpty(): Boolean

При работе с такими функциями расширения Kotlin из Java они доступны как статические функции.

Инфиксные функции

Документация

Еще один способ приукрасить наш синтаксис — использовать инфиксные функции. Проще говоря, этот инструмент помогает нам избавиться от лишнего кода в простых случаях. Блок подтверждений из основного фрагмента демонстрирует вариант использования этого инструмента:

1
teacherSchedule[day, lesson] shouldNotEqual null

Эта конструкция эквивалентна следующему:

1
teacherSchedule[day, lesson].shouldNotEqual(null)

В некоторых случаях скобки и точки могут быть избыточными. Для таких случаев мы можем использовать модификатор infix для функций.

В приведенном выше коде конструкция teacherSchedule [день, урок] возвращает элемент расписания, а функция shouldNotEqual проверяет, что этот элемент не равен нулю.

Чтобы объявить инфиксную функцию, вам необходимо:

  • использовать модификатор инфикса;
  • используйте только один параметр.

Объединяя два последних инструмента, мы можем получить код ниже:

1
infix fun  T.shouldNotEqual(expected: T)

Обратите внимание, что универсальный тип по умолчанию является наследником Any (не Nullable), однако в таких случаях мы не можем использовать null, поэтому вам следует явно определить тип Any?

Контроль контекста

Документация

Когда мы используем много вложенных контекстов, на нижнем уровне мы рискуем получить дикий микс. Из-за отсутствия контроля становится возможной следующая бессмысленная конструкция:

1
2
3
4
5
schedule { //context SchedulingContext
    data { //context DataContext + external context SchedulingContext
        data { } //possible, as there is no context control
    }
}

До Kotlin v.1.1 уже был способ избежать беспорядка. Он заключается в создании данных пользовательского метода во вложенном контексте DataContext, а затем в пометке их аннулированной аннотацией с уровнем ОШИБКА.

1
2
3
4
class DataContext {
    @Deprecated(level = DeprecationLevel.ERROR, message = "Incorrect context")
    fun data(init: DataContext.() -> Unit) {}
}

Такой подход исключает возможность построения неправильных DSL. Тем не менее, большое количество методов в SchedulingContext заставило бы нас выполнять рутинную работу, отговаривая от любого управления контекстом.

Kotlin 1.1 предлагает новый инструмент управления — аннотацию @DslMarker. Он применяется к вашим собственным аннотациям, которые, в свою очередь, используются для маркировки вашего контекста. Давайте создадим аннотацию и отметим ее новым инструментом из нашего инструментария:

1
2
@DslMarker
annotation class MyCustomDslMarker

Теперь нам нужно разметить контексты. В основном примере это SchedulingContext и DataContext. Поскольку мы аннотируем оба класса общим маркером DSL, происходит следующее:

01
02
03
04
05
06
07
08
09
10
11
12
13
@MyCustomDslMarker
class SchedulingContext { ... }
 
@MyCustomDslMarker
class DataContext { ... }
 
fun demo() {
    schedule { //context SchedulingContext
        data { //context DataContext + external context SchedulingContext is forbidden
            // data { } //will not compile, as contexts are annotated with the same DSL marker
        }
    }
}

Несмотря на все преимущества этого классного подхода, позволяющего сэкономить много времени и усилий, остается одна проблема. Взгляните на основной пример или, точнее, на эту часть кода:

1
2
3
4
5
6
7
8
schedule {
    data {
        student {
            name = "Petrov"
        }
        ...
    }
}

В этом случае на третьем уровне вложенности мы получаем новый контекст Student, который фактически является классом сущности, поэтому мы должны аннотировать часть модели данных с помощью @MyCustomDslMarker, что, на мой взгляд, неверно. В контексте Student вызовы data {} по-прежнему запрещены, поскольку внешний DataContext все еще на своем месте, но следующие конструкции остаются действительными:

1
2
3
4
5
6
7
schedule {
    data {
        student {
            student { }
        }
    }
}

Попытки решить проблемы с аннотациями приведут к смешению бизнес-логики и тестирования кода, и это, конечно, не лучшая идея. Здесь возможны три решения:

    1. Использование дополнительного контекста для создания студента, например, StudentContext. Это пахнет безумием и перевешивает преимущества @DslMarker.
    2. Создание интерфейсов для всех сущностей, например, IStudent (независимо от имени), затем создание заглушек-контекстов, реализующих эти интерфейсы, и, наконец, делегирование реализации объектам-студентам, что также граничит с безумием.
1
2
@MyCustomDslMarker
class StudentContext(val owner: Student = Student()): IStudent by owner
  1. Использование аннотации @Deprecated, как в приведенных выше примерах. В этом случае это выглядит как лучшее решение для использования: мы просто добавляем устаревший метод расширения для всех идентифицируемых объектов.
    1
    2
    @Deprecated("Incorrect context", level = DeprecationLevel.ERROR)
    fun Identifiable.student(init: () -> Unit) {}

Подводя итог, можно сказать, что комбинирование различных инструментов позволяет вам создать очень удобный DSL для ваших реальных целей.

Минусы использования DSL

Давайте попытаемся быть более объективными в отношении использования DSL в Kotlin и выясним недостатки использования DSL в вашем проекте.

Повторное использование частей DSL

Представьте, что вам нужно повторно использовать часть вашего DSL. Вы хотите взять кусок своего кода и позволить легко его воспроизвести. Конечно, в простейших случаях с одним контекстом мы можем скрыть повторяемую часть DSL в функции расширения, но в большинстве случаев это не будет работать.

Возможно, вы могли бы указать мне на лучшие варианты в комментариях, потому что на данный момент мне приходят на ум только два решения: добавление «именованных обратных вызовов» как части DSL или порождение лямбд. Второй способ проще, но может привести к живому аду, когда вы попытаетесь понять последовательность вызовов. Проблема в том, что чем больше у нас поведения, тем меньше преимуществ от подхода DSL.

Это, это !?

Нет ничего проще, чем потерять значение текущих «это» и «это» во время работы с вашим DSL. Если вы используете «it» в качестве имени параметра по умолчанию, где его можно заменить значимым именем, лучше сделать это. Лучше иметь немного очевидного кода, чем неочевидных ошибок в нем.

Понятие контекста может смутить того, кто с ним никогда не сталкивался. Теперь, когда в вашем наборе есть «лямбды с обработчиком», неожиданные методы внутри DSL с меньшей вероятностью будут появляться. Просто помните, что в худшем случае вы можете установить контекст для переменной, например, val mainContext = this

гнездование

Эта проблема тесно связана с первым недостатком в этом списке. Использование вложенных во вложенных во вложенных конструкциях сдвигает весь ваш значимый код вправо. До определенного предела это смещение может быть приемлемым, но когда оно смещено слишком сильно, было бы разумно использовать лямбды. Конечно, это не уменьшит удобочитаемость вашего DSL, но может быть компромиссом, если ваш DSL подразумевает не только компактные структуры, но и некоторую логику. Когда вы создаете тесты с DSL (случай, описанный в этой статье), эта проблема не является острой, поскольку данные описываются с помощью компактных структур.

Где документы, Лебовски?

Когда вы впервые попытаетесь справиться с чьим-либо DSL, вы почти наверняка задаетесь вопросом, где находится документация. На данный момент я считаю, что если ваш DSL будет использоваться другими, примеры использования будут лучшими документами. Сама документация важна как дополнительная ссылка, но она не очень дружелюбна для читателя. Специалист по предметной области обычно начинает с вопроса «Как мне позвонить, чтобы получить результат?», Поэтому, по моему опыту, примеры подобных случаев лучше говорят сами за себя.

Вывод

У нас есть обзор инструментов, которые позволяют вам с легкостью создавать свой собственный язык, специфичный для предметной области. Я надеюсь, что вы теперь видите, как это работает. Не стесняйтесь предлагать больше инструментов в комментариях.
Важно помнить, что DSL не является панацеей. Конечно, когда вы получаете такой мощный молот, все выглядит как гвоздь, но это не так. Начните с малого, создайте DSL для тестов, учитесь на своих ошибках, а затем, опытный, рассмотрите другие области использования.

Опубликовано на Java Code Geeks с разрешения Ивана Осипова, партнера нашей программы JCG . Смотреть оригинальную статью здесь: Kotlin DSL: от теории к практике

Мнения, высказанные участниками Java Code Geeks, являются их собственными.