Рубрики
Без рубрики

Котлин DSL: От теории к практике

Полный обзор инструментов для построения dsl в Kotlin. С тегами kotlin, dsl, java, jvm.

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

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

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

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

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

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

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

Если эти вопросы не вызывают ничего, кроме негативных чувств, эта статья для вас.

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

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

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

Перегрузка операторов коллекция коллекция.добавить(элемент)
Псевдонимы типов типеалии Создание пустых классов-наследников и другая клейкая лента
соглашение о методах получения/установки map.put(“ключ”, “значение”)
Объявление о разрушении val (x,(0, 0) val(0, 0); val.первый; val.второй
Лямбда-выражение за скобками список.forEach { … } list.forEach({…})
Функции расширения my list.first(); // там нет первого () () метод в коллекции mylist Полезные функции
Инфиксные функции от 1 до “одного” 1.to (“один”)
Лямбда-код с приемником Персона().применить { имя = « Джон» } N/A
Контекстное управление @DslMarker N/A

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

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

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

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

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

Пример Конечного Результата

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

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

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-конструкции в моем project на GitHub.

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

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

Лямбда-выражение За скобками

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

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

Ниже приведен базовый пример присвоения лямбда переменной:

значение helloPrint: (строка) -> Единица измерения = { println(it) }

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

hello Print("Привет")

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

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

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

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

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

fun x( лямбда: () -> Единица измерения) { лямбда() }

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

fun x( лямбда: () -> Единица измерения()

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

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

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

Kotlin предоставляет широкий, но несколько ограниченный выбор операторов. Модификатор operator позволяет определять функции по соглашениям, которые будут вызываться при определенных условиях. В качестве очевидного примера функция plus выполняется, если вы используете оператор “+” между двумя объектами. Полный список операторов можно найти в документации по ссылке выше.

Давайте рассмотрим менее тривиальный оператор: invoke . Основной пример этой статьи начинается с конструкции schedule { } , которая определяет блок кода, ответственный за тестирование расписания. Эта конструкция построена несколько иным способом, чем упомянутая выше: мы используем оператор invoke + “лямбда вне скобок”.

Определив оператор invoke, мы теперь можем использовать конструкцию schedule(…) , хотя schedule является объектом. Фактически, когда вы вызываете schedule(…) , компилятор интерпретирует его как schedule.invoke(…) . Давайте посмотрим, как объявляется schedule :

object schedule {
    operator fun invoke(init: SchedulingContext.() -> Unit)  { 
        SchedulingContext().init()
    }
}

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

schedule.invoke( { код внутри лямбды } )

Однако, если вы внимательно посмотрите на метод invoke, вы увидите не обычную лямбду, а “лямбда с получателем” или “лямбда с контекстом”, тип которого определяется как:

Контекст планирования. () -> Единица измерения

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

Лямбда-Код С Приемником

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

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

В то время как тип обычной лямбды определяется как () -> Единица измерения, тип лямбды с контекстом X определяется следующим образом: X.()-> Единица измерения. И, если это нормально, лямбды могут быть вызваны обычным способом:

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

Между тем, лямбда-выражение с контекстом требует контекста:

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 в объекте schedule (см. предыдущий абзац), который позволяет нам использовать конструкцию:

расписание { }

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

schedule { 
    data { 
        //... 
    } 
} 

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

schedule.invoke({ 
    this.data({ }) 
}) 

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

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

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

В результате в одной единственной строке — SchedulingContext().init() — мы создаем контекст и вызываем лямбду, переданную оператору. Для получения дополнительных примеров рассмотрим apply и с |/методами из стандартной библиотеки Kotlin.

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

соглашения о методах получения/установки

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

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

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

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

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 }
}

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

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

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

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

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

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

типеалии

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

val(0, 0)

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

Деструктурирующие объявления

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

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

Для любого класса мы можем определить оператор component , который будет отвечать за предоставление доступа к одному из атрибутов объекта. Это означает, что вызов point.component 1 будет равен вызову point.first . Зачем нам нужно такое дублирование?

Объявление деструктурирования – это средство “разложения” объекта на переменные. Эта функциональность позволяет нам писать конструкции следующего вида:

val (x,(0, 0)

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

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

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

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

Где сначала и second являются атрибутами точечного объекта.

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

для(x в списке(1, 2, 3)) { ... }

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

для ((день, урок, ученик, учитель) в запланированных событиях) { ... }

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

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

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

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

таблица доступности развлечений.понедельник(от: String, до:)

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

веселая матрица.понедельник(от: String, до:)

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

забавный итерируемый.first(): T

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

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

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

забавная последовательность символов?.IsNullOrEmpty(): Логическое значение

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

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

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

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

Расписание занятий[день, урок] Не должно быть равно нулю

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

Расписание учителей[день, урок].не должно быть равно(null)

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

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

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

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

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

инфикс fun T.Не должен быть равен (ожидаемый: T)

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

Контекстное управление

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

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

schedule { //context SchedulingContext
    data { //context DataContext + external context SchedulingContext
        data { } //possible, as there is no context control
    }
}

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

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

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

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

@DslMarker класс аннотаций MyCustomDslMarker

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

@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
        }
    }
}

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

schedule {
    data {
        student {
            name = "Petrov"
        }
        ...
    }
}

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

schedule {
    data {
        student {
            student { }
        }
    }
}

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

  1. Использование дополнительного контекста для создания учащегося — например, StudentContext. Это пахнет безумием и перевешивает преимущества @DslMarker.
  2. Создание интерфейсов для всех сущностей — например, IStudent (независимо от имени), затем создание контекстов-заглушек, реализующих эти интерфейсы, и, наконец, делегирование реализации объектам student. Это тоже граничит с безумием. @@MyCustomDslMarker класс StudentContext(val owner:()): студент по владельцу
  3. Используя аннотацию @deprecated , как в приведенных выше примерах. В данном случае это выглядит как лучшее решение для использования: мы просто добавляем устаревший метод расширения для всех идентифицируемых объектов. @Устаревший("Неправильный контекст",. ОШИБКА) fun Identified.student(инициализация: () -> Единица измерения) {}

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

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

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

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

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

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

Это?! Это?!

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

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

Гнездование

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

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

Когда вы впервые попытаетесь справиться с чьим-то DSL, вы почти наверняка зададитесь вопросом, где находится документация. На данный момент я считаю, что если ваш DSL будет использоваться другими, примеры использования будут лучшими документами. Документация сама по себе важна как дополнительная ссылка, но она не очень удобна для читателя. Специалист, специализирующийся на конкретной предметной области, обычно начинает с вопроса: “Что мне позвонить, чтобы получить результат?” Так что, по моему опыту, примеры подобных случаев будут лучше говорить сами за себя.

Вывод

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

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

Об авторе

Я разработчик программного обеспечения в Haulmont. В прошлом году моей сферой ответственности было составление расписания занятий в сфере образования. Я применил описанные выше технологии при разработке приложений на основе CUBA Platform Фреймворк Java. Я провожу свободное время с Spring, с Kotlin DSL для разработки Telegram-ботов и, конечно же, со своей женой.

Оригинал: “https://dev.to/ivanosipov/kotlin-dsl-from-theory-to-practice-6h7”