Перейти к содержимому

Kotlin dsl что это

  • автор:

Типобезопасные строители

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

Типобезопасные строители позволяют на основе Kotlin создавать доменно-ориентированные языки (ориг.: domain-specific languages, DSL), подходящие для построения сложных иерархических структур данных полу-декларативным способом. Примерами использования строителей являются:

  • Создание разметки с помощью кода Kotlin, например HTML или XML,
  • Настройка маршрутов для веб-сервера: Ktor.

Рассмотрим следующий код:

import com.example.html.* // смотрите объявления ниже fun result() = html < head < title > body < h1 p // элемент с атрибутом и текстовым содержанием a(href = "https://kotlinlang.ru") // смешанный контент p < +"Немного" b +"текста. Посмотрите наш" a(href = "https://kotlinlang.org") +"документации Kotlin." > p // контент генерируется в цикле p < for (arg in args) +arg >> > 

Всё это полностью корректный Kotlin-код. Здесь вы можете отредактировать и запустить пример с этим кодом прямо у себя в браузере.

Как это работает

` tag defining children like ` ` and ` `. (See its declaration [below](#full-definition-of-the-com-example-html-package).) —>

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

Теперь давайте вернёмся к вопросу почему вы можете писать вот такой код:

html < // . >

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

fun html(init: HTML.() -> Unit): HTML

Unit`, which is a *function type with receiver*. This means that you need to pass an instance of type `HTML` (a *receiver*) to the function, and you can call members of that instance inside the function.—>

Эта функция принимает один параметр-функцию под названием init . Тип этой функции: HTML.() -> Unit — функциональный тип с объектом-приёмником. Это значит, что вам нужно передать экземпляр класса HTML (приёмник) в функцию, и вы сможете обращаться к членам объекта в теле этой функции.

Обращение происходит через ключевое слово this .

html < this.head < /* . */ >this.body < /* . */ >> 

( head и body — члены класса HTML )

this , как обычно, можно опустить, и вы получите нечто, что уже очень похоже на строителя.

html < head < /*. */ >body < /*. */ >> 

Итак, что же делает этот вызов? Давайте посмотрим на тело функции html , объявленной выше. Она создаёт новый экземпляр HTML , затем инициализирует его путём вызова функции, которая была передана в аргументе (в примере это сводится к вызову head и body у объекта HTML ), и после этого возвращает его значение. Это в точности то, что и должен делать строитель.

Функции head и body в классе HTML объявлены схоже с функцией html . Единственное отличие в том, что они добавляют отстроенные экземпляры в коллекцию children заключающего экземпляра HTML .

fun head(init: Head.() -> Unit): Head < val head = Head() head.init() children.add(head) return head >fun body(init: Body.() -> Unit): Body

На самом деле эти две функции делают одно и тоже, поэтому вы можете использовать обобщённую версию, initTag .

protected fun initTag(tag: T, init: T.() -> Unit): T

Теперь ваши функции выглядят очень просто:

fun head(init: Head.() -> Unit) = initTag(Head(), init) fun body(init: Body.() -> Unit) = initTag(Body(), init) 

` and ` ` tags. —>

И вы можете использовать их для постройки тэгов и .

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

html < head < title > // . > 

Итак, вы просто добавляете строку в тело тэга, приписав + перед текстом, что ведёт к вызову префиксной операции unaryPlus() . Эта операция определена с помощью функции-расширения unaryPlus() , которая является членом абстрактного класса TagWithText (родителя Title ).

operator fun String.unaryPlus()

Иными словами, префикс + оборачивает строку в экземпляр TextElement и добавляет его в коллекцию children .

Всё это определено в пакете com.example.html , который импортирован в начале примера выше. В последнем разделе вы можете прочитать полное описание определений в этом пакете.

Контроль области видимости: @DslMarker

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

html < head < head <>// такое не должно происходить > // . > 

В этом примере должны быть доступны только члены ближайшего неявного приёмника this@head ; head() является членом другого приёмника — this@html , поэтому его вызов в другом контексте должен быть запрещён.

Для решения этой проблемы существует специальный механизм для управления областью видимости приёмника.

Чтобы заставить компилятор запускать контрольные области, нам нужно только аннотировать типы всех получателей, используемых в DSL, той же маркерной аннотацией. Например, для HTML Builders мы объявляем аннотацию @HTMLTagMarker .

@DslMarker annotation class HtmlTagMarker 

Аннотированный класс называется DSL-маркером, если он помечен аннотацией @DslMarker .

В нашем DSL все классы тэгов расширяют один и тот же суперкласс Tag . Вам достаточно аннотировать @HtmlTagMarker только суперкласс, и после этого компилятор Kotlin обработает все унаследованные классы в соответствии с аннотацией.

@HtmlTagMarker abstract class Tag(val name: String) < /*. */ >

Вам не нужно помечать классы HTML или Head аннотацией @HtmlTagMarker , потому что их суперкласс уже аннотирован.

class HTML() : Tag("html") < /*. */ >class Head() : Tag("head") < /*. */ >

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

html < head < head < >// ошибка: член внешнего приёмника > // . > 

Обратите внимание, что всё ещё возможно вызывать члены внешнего приёмника, но для этого вам нужно указать этот приёмник явно.

html < head < this@html.head < >// всё работает > // . > 

Полное описание пакета com.example.html

Перед вами содержание пакета com.example.html (представлены только элементы, использованные в примере выше). Он строит HTML дерево и активно использует расширения и лямбды с приёмниками.

package com.example.html interface Element < fun render(builder: StringBuilder, indent: String) >class TextElement(val text: String) : Element < override fun render(builder: StringBuilder, indent: String) < builder.append("$indent$text\n") >> @DslMarker annotation class HtmlTagMarker @HtmlTagMarker abstract class Tag(val name: String) : Element < val children = arrayListOf() val attributes = hashMapOf() protected fun initTag(tag: T, init: T.() -> Unit): T < tag.init() children.add(tag) return tag >override fun render(builder: StringBuilder, indent: String) < builder.append("$indent>\n") for (c in children) < c.render(builder, indent + " ") >builder.append("$indent\n") > private fun renderAttributes(): String < val builder = StringBuilder() for ((attr, value) in attributes) < builder.append(" $attr=\"$value\"") >return builder.toString() > override fun toString(): String < val builder = StringBuilder() render(builder, "") return builder.toString() >> abstract class TagWithText(name: String) : Tag(name) < operator fun String.unaryPlus() < children.add(TextElement(this)) >> class HTML : TagWithText("html") < fun head(init: Head.() ->Unit) = initTag(Head(), init) fun body(init: Body.() -> Unit) = initTag(Body(), init) > class Head : TagWithText("head") < fun title(init: Title.() ->Unit) = initTag(Title(), init) > class Title : TagWithText("title") abstract class BodyTag(name: String) : TagWithText(name) < fun b(init: B.() ->Unit) = initTag(B(), init) fun p(init: P.() -> Unit) = initTag(P(), init) fun h1(init: H1.() -> Unit) = initTag(H1(), init) fun a(href: String, init: A.() -> Unit) < val a = initTag(A(), init) a.href = href >> class Body : BodyTag("body") class B : BodyTag("b") class P : BodyTag("p") class H1 : BodyTag("h1") class A : BodyTag("a") < var href: String get() = attributes["href"]!! set(value) < attributes["href"] = value >> fun html(init: HTML.() -> Unit): HTML

© 2015—2024 Open Source Community

Используете ли вы самый мощный инструмент Kotlin — лямбда-функции?

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

Есть много ненавистников и много любителей Java, но я почти не видел никого, кто бы не любил Kotlin. Одна из самых недооцененных возможностей Kotlin — от классов данных до перегрузки операторов — лямбда-функции высшего порядка.

Используете ли вы самый мощный инструмент Kotlin - лямбда-функции?

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

5. Создание генератора функций

Мы знаем, что возвращаем переменные и объекты из определяемых нами традиционных функций. Однако с введением функций высшего порядка мы можем возвращать тип функции из функции. Давайте посмотрим:

Генератор функций

Если у нас есть функция с настраиваемым содержимым, мы можем использовать замыкания для возврата функции с определенным намерением. Мы также можем добавить параметр получателя (receiver) для лямбда-функции следующим образом:

Генератор функций с параметром получателя

Генератор функций с параметром получателя

4. Уменьшение дублирования кода

Как мы видели в предыдущем примере, мы можем удалить дублированный код, а также сократить шаблонный код с помощью лямбда-функций.

Например, вы разрабатываете приложение для Android, которое меняет цвет и фон текста на экране.

Без лямбда-функций

С лямбда-функциями

3. Простота итераций

Чтобы перебрать массив, мы можем использовать цикл for или функцию forEach. Однако иногда их использование сложно для понимания. Мы можем воспользоваться преимуществами лямбда-функций, чтобы сделать наш код читабельным.

Обычный Java способ сделать это

Обычный Java-способ сделать это

Современный Kotlin способ

2. Выполнение кода до и после функции

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

Выполнение анализа кода для данного решения

Выполнение анализа для данного решения

1. Котлин DSL

Победитель! Kotlin DSL — один из лучших вариантов использования лямбда-функций в Kotlin. Это позволяет Kotlin создавать веб-сайты и запускать код React.

Используете ли вы самый мощный инструмент Kotlin - лямбда-функции?

Вывод

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

Если вы нашли опечатку — выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.

Kotlin DSL: Теория и Практика

Sql, RegExp, Gradle — что их объединяет? Всё это примеры использования проблемно-ориентированных языков или DSL (domain-specific language). Каждый такой язык решает свою узконаправленную задачу, например, запрос данных из БД, поиск совпадений в тексте или описание процесса сборки приложения. Язык Kotlin предоставляет большое количество возможностей для создания собственного проблемно-ориентированного языка. В ходе статьи мы разберемся, какие инструменты есть в арсенале программиста, и реализуем DSL для предложенной предметной области.

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

Что такое DSL?

Языки программирования можно разделить на 2 типа: универсальные языки (general-purpose programming language) и предметно-ориентированные (domain-specific language). Популярные примеры DSL — это SQL, регулярные выражения, build.gradle. Язык уменьшает объем предоставляемой функциональности, но при этом он способен эффективно решать определенную проблему. Это способ описать программу не в императивном стиле (как нужно получить результат), а в декларативном или близком к декларативному (описать текущую задачу), в таком случае решение проблемы будет получено исходя из заданной информации.

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

В статье я рассмотрел построение «внутреннего» DSL на языке Kotlin. Такой вид проблемно-ориентированных языков реализуется на основе синтаксиса универсального языка. Подробнее об этом вы можете прочитать по ссылке.

Область применения

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

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

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

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

Основные возможности

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

Название функциональности DSL синтаксис Обычный синтаксис
Переопределение операторов collection += element collection.add(element)
Псевдонимы типа typealias Point = Pair

Создание пустых классов-наследников и прочие костыли
Соглашение для get/set методов map[«key»] = «value» map.put(«key», «value»)
Мульти-декларации val (x, y) = Point(0, 0) val p = Point(0, 0); val x = p.first; val y = p.second
Лямбда за скобками list.forEach

list.forEach()
Extention функции mylist.first(); // метод first() отсутствует в классе коллекции mylist Утилитные функции
Infix функции 1 to «one» 1.to(«one»)
Лямбда с обработчиком Person().apply

Нет
Контролирование контекста @DslMarker Нет

Нашли для себя что-то новое? Тогда продолжим.

В таблице намеренно пропущены делегированные свойства, так как, на мой взгляд, они бесполезны для построения DSL в том виде, который мы будем рассматривать. Благодаря указанным возможностям вы сможете писать код чище, избавиться от большого количества «шумного» синтаксиса и при этом сделать разработку еще более приятным занятием («куда уж приятнее?» — спросите вы). Мне понравилось сравнение из книги Kotlin in Action, в натуральных языках, например, в английском, предложения построены из слов и грамматические правила управляют тем, как нужно объединять слова друг с другом. Аналогично в 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 носят демонстративный характер, хотя вы можете повторить увиденное и в собственном проекте, это не означает, что представленный вариант единственно верный. Ниже мы детально рассмотрим каждый инструмент.

Некоторые возможности языка особенно хороши в совокупности с другими и первый инструмент в этом списке — лямбда вне скобок.

Лямбда вне скобок

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

Ниже приведен простейший пример того, как можно сохранить лямбду в переменную:

val helloPrint: (String) -> Unit =

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

helloPrint("Hello") 

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

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

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

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

Независимо от варианта, мы используем лямбды. Допустим, есть функция x() . В языке Kotlin действует следующее правило: если лямбда является последним аргументом функции, то её можно вынести за скобки, если при этом лямбда единственный параметр, то скобки можно не писать. В результате, конструкция x() может быть преобразована в x() <> , а затем, убрав скобки, мы получаем x <> . Объявление такой функции выглядит следующим образом:

fun x( lambda: () -> Unit )

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

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

Но что если 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 (синглтону), который помечен специальным ключевым словом object (подробнее о таких объектах, можно прочитать здесь). Таким образом, мы вызываем метод invoke у экземпляра schedule и при этом единственным параметром метода определяем лямбду, которую выносим за скобки. В итоге, конструкция schedule равносильна следующей:

schedule.invoke( < code inside lambda >) 

Однако если вы посмотрите внимательнее на метод invoke, то увидите не обычную лямбду, а «лямбду с обработчиком» или «лямбду с контекстом», тип которой записывается следующим образом: SchedulingContext.() -> Unit
Пора разобраться с тем, что это такое.

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

Kotlin дает нам возможность установить контекст для лямбда-выражения. Контекст — это обычный объект. Тип контекста опрелеляется вместе с типом лямбда-выражения. Такая лямбда приобретает свойства нестатического метода в классе контекста, но с доступом только к публичному API этого класса.
В то время как тип обычной лямбды определяется так: () -> Unit , тип лямбды с контекстом типа Х определяется так: X.()-> Unit и, если первый тип лямбд можно запускать привычным образом:

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 

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

schedule

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

schedule < data < //. >> 

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

Чтобы детально понять как работает этот пример, давайте уберем весь синтаксический сахар:

schedule.invoke(< this.data(< >) >) 

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

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

Мы вызываем конструктор для контекста: SchedulingContext() , а затем на созданном объекте (контексте) вызываем лямбду с идентификатором init, которую мы передали в качестве параметра. Это очень похоже на вызов обычной функции. В результате, в одной строке SchedulingContext().init() мы создаем контекст и вызываем переданную в оператор лямбду. Если вас интересуют другие примеры, то обратите внимание на методы apply и with из стандартной библиотеки Kotlin.

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

Соглашение для get/set методов

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

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

Чтобы использовать квадратные скобки, необходимо реализовать методы get или 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 >> 

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

К удивлению, в стандартной библиотеке Kotlin есть класс Pair, но почему? Большая часть сообщества считает, что класс Pair — это плохо, с ним пропадает смысл связи двух объектов и становится не очевидно, почему они в паре. Следующие два инструмента демонстрируют, как можно и осмысленность пары сохранить, и не создавать лишние классы.

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

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

typealias Point = Pair 

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

val point = Point(0, 0) 

Однако у класса Pair есть два свойства, first и second, и как бы нам переименовать эти свойства так, чтобы стереть всякие различия между желаемым классом Point и Pair? Сами свойства переименовать не удастся, но в нашем инструментарии есть замечательная возможность, которую народные умельцы обозначили как мульти-декларации.

Мульти-декларации (Destructuring declaration)

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

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

Что такое мульти-декларации? Это способ «разложить» объект по переменным. Благодаря этой функциональности, мы можем написать следующую конструкцию:

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

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

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 

где first и second это свойства объекта Point.

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

for(x in listOf(1, 2, 3))

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

for ((day, lesson, student, teacher) in scheduledEvents)

Теперь всё должно быть очевидно. Мы перебираем коллекцию scheduledEvents, каждый элемент которой раскладывается на 4 свойства, описывающие текущий объект.

Extension функции

Добавление собственных методов к объектам из сторонних библиотек или добавление методов в Java Collection Framework — давняя мечта многих разработчиков. И теперь у всех нас есть такая возможность. Объявление расширяющих функций выглядит следующим образом:

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

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

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

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

fun Iterable.first(): T 

В итоге, любая коллекция, основанная на интерфейсе Iterable, вне зависимости от типа элемента, получает метод first. Интересно то, что мы можем поместить extension метод в класс контекста и благодаря этому иметь доступ к расширяющему методу только в определенном контексте (см. выше лямбда с контекстом). Более того, мы можем создавать extension функции и для Nullable типов (объяснение Nullable типов выходит за рамки статьи, но при желании вы можете почитать здесь). Например, функция isNullOrEmpty из стандартной библиотеки Kotlin, которая расширяет тип CharSequence?, может быть использована следующим образом:

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

Сигнатура этой функции представлена ниже:

fun CharSequence?.isNullOrEmpty(): Boolean 

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

Infix функции

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

teacherSchedule[day, lesson] shouldNotEqual null 

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

teacherSchedule[day, lesson].shouldNotEqual(null) 

Есть ситуации, когда скобки и точки излишни. Именно на этот случай нам нужен infix модификатор для функций.
В коде выше, конструкция teacherSchedule[day, lesson] возвращает элемент расписания, а функция shouldNotEqual проверяет, что элемент не равен null.

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

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

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

infix fun T.shouldNotEqual(expected: T) 

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

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

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

schedule < //context SchedulingContext data < //context DataContext + external context SchedulingContext data < >//possible, as there is no context control > > 

До версии Kotlin 1.1 уже существовал способ, как этого избежать. Создание собственного метода data во вложенном контексте DataContext, а затем пометка его аннотацией Deprecated с уровнем ERROR.

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

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

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

@DslMarker annotation class 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 никуда не делся, но эти конструкции остаются валидны:
javaschedule < data < student < student < >> > >

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

  1. Использовать дополнительный контекст для создания студента, например, StudentContext. Это похоже на безумие и перестает оправдывать преимущества @DslMarker.
  2. Создать интерфейсы для всех сущностей, например, IStudent (наименование здесь не важно), создать контексты-пустышки, наследующие эти интерфейсы, и делегировать реализацию объектам студентов, что тоже на грани бреда.
    @MyCustomDslMarker
    class StudentContext(val owner: Student = Student()): IStudent by owner
  3. Воспользоваться аннотацией @Deprecated, как в примерах выше. В данном случае, пожалуй, это лучшее решение, которым можно воспользоваться.
    Просто добавляем deprecated extension метод для всех Identifiable объектов.

 @Deprecated("Incorrect context", level = DeprecationLevel.ERROR) fun Identifiable.student(init: () -> Unit) <> 

В итоге, комбинируя разные инструменты, мы строим комфортный DSL для решения наших задач.

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

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

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

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

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

This, it!?

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

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

Вложенность

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

Где доки, Зин?

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

Заключение

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

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

Потренируйтесь «на кошках», как герой одного известного фильма, сделайте DSL для тестов, а затем, сделав множество ошибок, и после появления опыта, рассмотрите и другие применения.
Желаю успехов в разработке проблемно-ориентированных языков!

Kotlin DSL: Теория и Практика

Разработка тестов приложения — не самое приятное занятие. Этот процесс занимает долгое время, требует большой концентрации и при этом крайне востребован. Язык Kotlin дает набор инструментов, который позволяет довольно легко построить собственный проблемно-ориентированный язык (DSL). Есть опыт, когда Kotlin DSL заменил билдеры и статические методы для тестирования модуля планирования ресурсов, что превратило добавление новых тестов и поддержку старых из рутины в увлекательный процесс.

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

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

Статья основана на докладе Ивана Осипова (i_osipov) на конференции JPoint. Дальнейшее повествование ведется от его лица. Иван работает программистом в компании Haulmont. Основной продукт компании – CUBA, платформа для разработки энтерпрайза и различных веб-приложений. В том числе на этой платформе делаются и аутсорсинговые проекты, среди которых недавно был проект в области образования, в котором Иван занимался построением расписания для образовательного учреждения. Так сложилось, что последние три года Иван так или иначе работает с планировщиками, и конкретно в Haulmont в течение года они этот самый планировщик тестируют.

Для желающих позапускать примеры — держите ссылку на GitHub. По ссылке вы найдете весь код, который сегодня мы с вами будем разбирать, запускать и писать. Открывайте код и вперед!

Сегодня мы обсудим:

  • что такое проблемно-ориентированные языки;
  • встроенные проблемно-ориентированные языки;
  • построение расписания для образовательного учреждения;
  • как это все тестируется вместе с Kotlin.

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

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

Немного про тестирование планировщика.

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

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

Давайте напишем самый простой тест для того, чтобы вы в общем понимали картину.
Что первое приходит на ум, когда думаешь про тестирование? Возможно, это несколько примитивные тесты такого вида: создаешь класс, в нем создаешь метод, помечаешь его аннотацией Test. В итоге, мы пользуемся возможностями JUnit, и инициализируем какие-то данные, значения по умолчанию, затем специфические для теста значения, делаем все то же самое для остальной части модели, и, наконец, создаем объект-планировщик, передаем в него наши данные, запускаем, получаем результаты и проверяем их. Более-менее стандартный процесс. Но в нем, очевидно, есть дублирование кода. Первое, что приходит на ум, это возможность все вынести в статические методы. Раз есть куча значений по умолчанию, почему бы это не скрыть?

Это хороший первый шаг по пути уменьшения дублирования.

Глядя на это, ты понимаешь, что хотелось бы модель держать более компактно. Тут у нас появляется паттерн-строитель, в котором где-то под капотом инициализируется значение по умолчанию, и тут же инициализируются специфичные для теста значения. Становится уже лучше, однако, мы все еще пишем boilerplate-код, и пишем его мы каждый раз заново. Представьте 200 тестов – 200 раз придется написать эти три строчки. Очевидно, хотелось бы от этого как-то избавиться. Развивая идею, мы приходим к некоторому пределу. Так, например, мы можем создать паттерн-билдер вообще для всего.

Можно создавать планировщик с нуля и до конца, задавать все нужные нам значения, запускать планирование и все здорово. Если взглянуть подробно на этот пример и детально его разобрать, то окажется, что пишется большое количество ненужного кода. Хотелось бы сделать тесты более читаемыми, чтобы можно было взглянуть и сразу понять, не вникая в паттерны и так далее.

Итак, у нас есть какое-то количество ненужного кода. Несложная математика подсказывает, что тут на 55% больше букв, чем нам необходимо, и хотелось бы как-то от них уйти.

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

Идеальный тест

Здорово говорить, как все плохо, но давайте подумаем, как бы было очень хорошо. Идеальный пример, который мы хотели бы получить в результате:

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

Domain Specific Language

Глядя на это все, начинает казаться, что это похоже на некоторый проблемно-ориентированный язык. Нужно понять, что это такое и в чем разница. Языки можно разделить на два типа: языки общего назначения (то, на чем мы с вами пишем постоянно, решаем абсолютно любые задачи и справляемся абсолютно со всем) и языки проблемно-ориентированные. Так, например, SQL нам помогает отлично вытаскивать данные из базы, а какие-то другие языки также помогают решать другие специфичные проблемы.

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

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

Первый вариант – Groovy. Замечательный, динамичный язык, который отлично показал себя в построении проблемно-ориентированных языков. Снова можно привести пример build файла в Gradle, которым многие из нас пользуются. Eще есть Scala, которая имеет огромное количество возможностей для реализации чего-то своего. И наконец, есть Kotlin, который нам также помогает строить проблемно-ориентированный язык, и сегодня именно о нем пойдет речь. Я бы не хотел разводить войн и сравнивать Kotlin с чем-то другим, скорее, это остается на вашей совести. Сегодня я покажу вам то, что есть в Kotlin для разработки проблемно-ориентированных языков. Когда вы захотите сравнить это и сказать, что какой-то язык лучше, вы сможете вернуться к этой статье и легко увидеть разницу.

Что дает нам Kotlin для разработки проблемно-ориентированного языка?

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

В-третьих, есть отличная поддержка среды разработки, и это неудивительно, ведь та же компания, делает основную на сегодня среду разработки, и она же делает Kotlin.
Наконец, внутри DSL, очевидно, мы можем использовать Kotlin. На мой субъективный взгляд, поддерживать DSL намного проще, чем поддерживать утилитные классы. Как вы увидите далее, читаемость оказывается немного лучше билдеров. Что я понимаю под «лучше»: у вас получается несколько меньше синтаксиса, который вам необходимо писать, — тот, кто будет читать ваш проблемно-ориентированный язык, будет быстрее это воспринимать. Наконец, написать свой велосипед намного веселее! Но на самом деле, реализовать проблемно-ориентированный язык намного проще, чем изучить какой-то новый фреймворк.

Я напомню еще раз ссылку на GitHub, если вы захотите писать демки дальше, то вы можете зайти и забрать код по ссылке.

Проектирование идеала на Kotlin

Перейдем к проектированию нашего идеала, но уже на Kotlin. Взглянем на наш пример:

И поэтапно начнем его отстраивать.

У нас есть тест, который превращается в функцию в Kotlin, которую можно именовать, используя пробелы.

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

Schedule у нас превращается в блок. То же самое происходит с большим количеством конструкций, так как мы все-таки работаем в Kotlin.

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

Наш student превращается в некоторый блок, в котором идет работа со свойствами, с методами, и это мы дальше с вами будем разбирать.

Наконец, преподаватели. Здесь у нас появляются некоторые вложенные блоки.

В коде ниже мы переходим к проверкам. Нам нужны проверки на совместимость с Java-языками – и да, Kotlin совместим с Java.

Арсенал разработки DSL на Kotlin

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

В таблице приведено некоторое сравнение проблемно-ориентированного синтаксиса и обычного синтаксиса, который имеется в языке.

Лямбды в Kotlin

val lambda: () -> Unit =

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

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

Если мы хотим передать какой-то параметр, во-первых, мы должны описать это в типе.
Во-вторых, мы имеем доступ к идентификатору по умолчанию it, которым мы можем пользоваться, однако, если нас это как-то не устраивает, можно задать своё имя параметра и пользоваться ими.

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

Если мы захотим передать больше одного параметра, нужно явно определить их идентификаторы.

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

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

Где это применяется? Абсолютно везде. То есть те самые фигурные скобки, про которые мы с вами уже говорили, их мы и используем, это и есть те самые лямбды.

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

Для чего это нужно? Это нужно для того, чтобы внутри лямбды мы имели доступ к ключевому слову this – это самое ключевое слово, указывает нам на наш контекст, то есть на некоторый объект, который мы связали с нашей лямбдой. Так, например, мы можем создать лямбду, которая будет выводить некоторую строку, естественно, мы воспользуемся классом строки для объявления контекста и вызов такой лямбды будет выглядеть вот так:

Если вам хочется передать контекст в качестве параметра, вы можете это точно также сделать. Однако, совсем передать контекст мы не можем, то есть лямбда с контекстом требует – внимание! – контекста, да. Что будет, если мы начнем передавать лямбду с контекстом в какой-то метод? Вот посмотрим снова на наш метод exec:

Переименуем его в метод student – ничего не изменилось:

Так мы постепенно движемся к нашей конструкции, конструкции student, которая под фигурными скобками скрывает всю инициализацию.

Давайте в ней разберемся. У нас есть какая-то функция student, которая принимает лямбду с контекстом Student.

Очевидно, нам нужен контекст.

Здесь мы создаем объект и на нем же запускаем эту лямбду.

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

Благодаря этому, внутри лямбды мы получаем доступ к ключевому слову this – то, ради чего, наверное, и существуют лямбды с контекстом.

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

Опять же, если у нас есть не только проперти, а еще есть какие-то методы, мы можем их также вызывать, это выглядит довольно естественно.

Применение

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

Резюмируя по лямбдам – у нас есть лямбды обычные, есть с контекстом, и теми, и другими можно пользоваться.

Операторы

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

Посмотрим на преподавателя и на его доступность. Допустим, мы говорим, что преподаватель работает по понедельникам с 8 утра в течение 1 часа. Еще мы хотим сказать, что, кроме этого одного часа, он работает с 13.00 в течение 1 часа. Хочется выразить это с помощью оператора +. Как это можно сделать?

Имеется некоторый метод availability, который принимает лямбду с контекстом AvailabilityTable . Это значит, что есть некоторый класс, который так и называется, и в этом классе объявлен метод monday. Этот метод возвращает DayPointer , т.к. нужно к чему-то прикрепить наш оператор.

Давайте разберемся в том, что такое DayPointer. Это указатель на таблицу доступности некоторого преподавателя, и день в его же расписании. Также у нас есть функция time, которая будет так или иначе превращать какие-то строки в целочисленные индексы: в Kotlin у нас для этого есть класс IntRange .

Слева есть DayPointer , справа есть time, и нам хотелось бы их объединить оператором +. Для этого в классе DayPointer можно создать наш оператор. Он будет принимать диапазон значений типа Int и возвращать DayPointer для того, чтобы мы цепочкой могли снова и снова склеивать наш DSL.
Теперь взглянем на ключевую конструкцию, с которой все начинается, с которой начинается наш DSL. Ее реализация немного отличается, и сейчас мы в этом разберемся.
В Kotlin есть понятие синглтона, встроенное прямо в язык. Для этого вместо ключевого слова class используется ключевое слово object . Если мы создаем метод внутри синглтона, то можно обращаться к нему так, что нет необходимости снова создавать инстанс этого класса. Мы просто обращаемся к нему как к статическому методу в классе.

Если взглянуть на результат декомпиляции (то есть, в среде разработки прокликать Tools –> Kotlin –> Show Kotlin Bytecode –> Decompile), то можно увидеть следующую реализацию синглтона:

Это всего лишь обычный класс, и ничего сверхъестественного здесь не происходит.
Имеется еще один интересный инструмент – это оператор invoke . Представим, что у нас есть некоторый класс А, у нас есть его инстанс, и мы хотели бы словно запускать этот инстанс, то есть вызывать круглые скобки у объекта этого класса, и мы можем это сделать благодаря оператору invoke .

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

Создавать каждый раз инстансы то еще занятие, поэтому мы можем совместить предыдущие знания и текущие.

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

Отлично, про schedule мы поговорили, давайте взглянем на наши проверки.
У нас есть преподаватели, мы построили какое-то расписание, и хотим проверить, что в расписании этого преподавателя в определенный день в определенном занятии есть какой-то объект, с которым мы будем работать.

Хотелось бы использовать квадратные скобки и обращаться к нашему расписанию способом, визуально похожим на доступ к массивам.

Сделать это можно с помощью оператора: get / set:

Здесь мы не делаем ничего нового, просто следуем соглашениям. В случае оператора set нужно дополнительно передать значения в наш метод:

Итак, квадратные скобки для чтения превращаются в get, а квадратные скобки, через которые мы присваиваем, превращаются в set.

Демо: object, operators

Дальнейший текст можно или читать, или смотреть видео по ссылке. У видео есть четкое время начало, но не указано времени окончания — в принципе, однажды начав, можно досмотреть его до конца статьи.

Для удобства я кратко изложу суть видео прямо в тексте.

Давайте напишем тест. У нас есть некоторый объект schedule, и если мы через ctrl+b перейдем к его реализации, то мы увидим все, о чем я перед этим говорил.

Внутри объекта schedule мы хотим проинициализировать данные, затем выполнить какие-то проверки, и в рамках данных мы хотели бы сказать, что:

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

И здесь проявляется один из минусов Kotlin и проблемно-ориентированных языков в принципе: довольно сложно адресовать какие-то объекты, которые мы создали раньше. В этом демо я буду указывать все в качестве индексов, то есть rus – это индекс 0, математика – это индекс 2. И преподаватель естественно, тоже что-то ведет. Он не просто на работу ходит, а чем-то занимается. Для читателей этой статьи я хотел бы предложить еще один вариант адресации, вы можете завести уникальные теги и по ним сохранять сущности в Map, а когда нужно обратиться к какой-то из них, то по тегу вы всегда можете её найти. Продолжим разбирать DSL.

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

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

То есть у нас это коллекция ивентов. Ивент в себя инкапсулирует набор свойств, например: что имеется студент, преподаватель, в какой день на какой урок они встречаются.

Продолжим писать тест дальше.

Здесь, опять же, мы пользуемся оператором get, перейти к его реализации не так просто, но мы можем это сделать.

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

Ивент – это, по сути, инкапсулированный набор из 4 свойств. Хочется раскладывать этот ивент на набор свойств, словно кортеж. В русском языке такая конструкция называется мульти-декларации (я нашел только такой перевод), или destructuring declaration, и работает это следующим образом:

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

Работает это потому, что у нас есть метод componentN, то есть это метод, который генерируется компилятором благодаря модификатору data, который мы пишем перед классом.

Вместе с этим нам прилетает большое количество других методов. Нас интересует именно метод componentN, генерируется на основе перечисленных в списке параметров primary-конструктора свойств.

Если бы у нас не было модификатора data, необходимо было бы вручную написать оператор, который будет делать все то же самое.

Итак, у нас какие-то методы componentN, и они, раскладываются вот в такой вызов:

По сути, это синтаксический сахар над вызовом нескольких методов.

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

Не нужно никакого дополнительного класса: можно взять матрицу булевских значений и переименовать для большей очевидности. Это можно сделать с помощью так называемого typealias или псевдонима типа. К сожалению, никаких дополнительных бонусов мы от этого не получаем, это просто переименование. Если вы возьмете и availability переименуете обратно в матрицу булевских значений, вообще ничего не изменится. Код как работал, так и будет работать.

Давайте взглянем на преподавателя, вот как раз на эту самую доступность, и поговорим о нем:

У нас есть преподаватель, и у него вызывается метод availability (вы еще не потеряли нить рассуждений? :-). Откуда он взялся? То есть, преподаватель — это какая-то entity, у которой есть класс, и это — бизнес-код. И не может там быть никакого дополнительного метода.

Этот метод появляется благодаря extension-функциям. Берем и прикручиваем к нашему классу какому-то еще одну функцию, которую можем запускать на объектах этого класса.
Если мы передадим этой функции некоторую лямбду, а затем запустим ее на существующем свойстве, то все отлично — метод availability в своей реализации инициализирует свойство availability. От этого можно избавиться. Мы уже знаем про оператор invoke, который может и крепиться к типу, и быть одновременно extension-функцией. Если в этот оператор передавать лямбду, то тут же, на ключевом слове this, мы можем эту лямбду запускать. В результате, когда мы работаем с преподавателем, доступность – свойство преподавателя, а не какой-то дополнительный метод, и тут никакого рассинхрона не происходит.

В качестве бонуса, extension-функции можно создавать для nullable типов. Это хорошо, так как если будет переменная с nullable типом, содержащим значение null, наша функция к этому уже готова, и не упадет с NullPointer. Внутри этой функции this может быть равен null, и это нужно обработать.

Резюмируя по extension-функциям: необходимо понимать, что имеется доступ только к публичному API класса, а сам класс никак не модифицируется. Extension-функция определяется по типу переменной, а не по фактическому типу. Более того, член класса с той же сигнатурой окажется приоритетней. Можно создавать extension-функцию для одного класса, но написать ее в совершенно другом классе, и внутри этой extension-функции будет доступ к одновременно двум контекстам. Получается пересечение контекстов. Ну и наконец, это отличная возможность взять и прикрутить операторы вообще в любое место, где мы хотим.

Следующий инструмент — инфиксные функции. Очередной опасный молоток в руках разработчика. Почему опасный? То, что вы видите – это код. Такой код можно написать в Kotlin, и не надо так делать! Пожалуйста, не делайте так. Но тем не менее, подход хороший. Благодаря этому есть возможность избавляться от точек, скобочек — от всего того шумного синтаксиса, от которого мы пытаемся уйти как можно дальше и сделать наш код немного чище.

Как это работает? Давайте возьмем более простой пример — переменную типа integer. Создадим для нее extension-функцию, назовем ее shouldBeEqual, она что-то будет делать, но это уже неинтересно. Если мы допишем слева от нее модификатор infix – все, этого достаточно. Можно избавляться от точек и скобочек, но есть парочка нюансов.

На основе этого реализована как раз конструкция data и assertions, скрепленные вместе.

Давайте в ней разберемся. У нас есть SchedulingContext — общий контекст запуска планирования. Есть функция data, которая возвращает результат этого планирования. При этом мы создаем extension-функцию и одновременно инфикс-функцию assertions, которая будет запускать лямбду, проверяющую наши значения.

Имеется субъект, объект и действие, и нужно их как-то связать. В этом случае результат выполнения data с фигурными скобками – это субъект. Лямбда, которую мы передаем в метод assertions – объект, а сам метод assertions – действие. Все это как бы склеивается.

Говоря про инфикс функции, важно понимать, что это шаг по избавлению от шумного синтаксиса. Однако, у нас обязательно должен существовать субъект и объект этого действия, и нужно воспользоваться модификатором infix. Может быть точно один параметр — то есть ноль параметров не может быть, два не может быть, три – ну вы поняли. Можно передавать в эту функцию, например, лямбды, и таким образом получаются конструкции, которые вы раньше не видели.

Перейдем к следующей демке. Ее лучше смотреть на видео, а не читать текстом.

Теперь все выглядит готовым: инфикс функции вы увидели, extension функции увидели, destructuring declaration готов.

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

Бывают ситуации, когда мы можем взять DSL и переиспользовать его прям внутри него же, а мы этого делать не хотим. Наш пользователь (возможно, неопытный пользователь), пишет data внутри data, и это не имеет никакого смысла. Нам хотелось бы как-то запретить ему это делать.

До Kotlin версии 1.1 мы должны были сделать следующее: в ответ на то, что у нас в SchedulingContext есть метод data, мы должны были в DataContext создать еще один метод data, в который принимаем лямбду (пускай без реализации), должны были пометить этот метод аннотацией @Deprecated и сказать компилятору не компилировать такое. Видишь, что такой метод запускается – не компилируй. Используя такой подход, мы получим даже некоторое осмысленное сообщение, когда будем писать неосмысленный код.

После версии Kotlin 1.1, появилась замечательная аннотация @DslMarker . Эта аннотация нужна, чтобы помечать производные аннотации. Ими, в свою очередь, мы будем размечать проблемно-ориентированные языки. Для каждого проблемно-ориентированного языка вы можете создать одну аннотацию, которую пометите @DslMarker и будете её вешать на каждый контекст, который необходим. Больше нет потребности в том, чтобы писать дополнительные методы, которые нужно запрещать компилировать — оно все просто работает. Не компилируется.

Тем не менее, есть один такой специальный случай, когда мы работаем с нашей бизнес-моделью. Обычно она написана на Java. Есть контекст, есть аннотация, которой нужно пометить контекст. Как думаете, какой контекст внутри метода студент? Класс Student . Это – кусок нашей бизнес-модели, там Kotlin нет.

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

Варианта у нас есть три.

  1. Создать целый контекст, который отвечает за нашего студента. Назовем его StudentContext. Опишем там все свойства, и потом будем на основе него создавать студента. Некоторое такое безумие – пишется куча кода, наверное, больше, чем для продакшена.
  2. Второй вариант – можем взять и создать некоторый интерфейс, который отражает нашего студента, то есть просто перечисляет свойства. Но переиспользуем этот же интерфейс в наших тестах. Возьмем StudentContext и скажем, что он реализует некоторый интерфейс IStudent посредством делегирования реализации этого интерфейса другому объекту. То есть, создается тут же на месте объект Student, и от него берется вся реализация интерфейса IStudent для StudentContext. Помечаем аннотацией DslMarker и прекрасно, все работает.
  3. Любимый способ: воспользуемся аннотацией deprecated и запретим компилировать неправильный код. Просто перечислим то, что нам необходимо. Обычно в иерархии сущностей находится такая сущность, которая содержит идентификатор. На эту сущность мы можем повесить extension-функцию, которую мы и запретим вызывать. В том числе и студента внутри студента.

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

Резюмируя про контроль контекста. Защищайте ваших пользователей от ошибок. Понятно, что некоторые ошибки пользователи делать не будут, ведь это очевидно, но контролировать это все равно желательно. Тем более, что реализация такого контроля занимает не так много средств и времени. Пользуйтесь аннотацией @DslMarker, которой вы помечаете ваши собственные аннотации. В тех ситуациях, когда вы не можете пользоваться аннотацией @DslMarker, воспользуйтесь аннотацией @Deprecated, это поможет вам обойти те случаи, которые пока не работают.

Итак, демка контроля контекста:

Минусы и проблемы

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

Представим, что у вас есть какой-то кусочек кода, и вы хотите его просто повторять, например, в цикле иметь возможность создавать студентов, много-много раз одинаковых студентов, или любые другие сущности. Как это сделать? Можно воспользоваться циклом for — не самый лучший вариант. Можно создать дополнительный метод внутри вашего DSL, и это будет уже более хорошим решением, однако, решать такие проблемы придется прямо на уровне DSL. Следите за ключевым словом this и дефолтным именованием параметра it. К счастью, с версии Kotlin плагина 1.2.20 у нас есть хинты, которые видны прямо в среде разработки. Серенький код нам подсказывает, с каким контекстом мы работаем или что такое it.

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

Наконец, документация. На мой субъективный взгляд, лучшая документация для вашего проблемно-ориентированного языка – это больше количество примеров этого DSL. Здорово, когда у вас есть Kotlin-доки, это хороший бонус. Однако, если пользователь DSL понятия не имеет, какие конструкции имеются, ему и Kotlin-доки смотреть негде. Чувствовали такое когда-нибудь? Когда вы приходите писать Gradle-файл, в самом начале, вы не понимаете, что в нем есть, и нужны какие-то примеры. Вам наплевать на какие-то контексты, вы хотите примеры, и вот это – та самая лучшая документация, которой можно пользоваться новым юзерам вашего DSL.

Не суйте DSL’и во все щели, пожалуйста. Это очень хочется делать, когда вы владеете этим инструментом. Хочется сказать, давайте создадим DSL сюда, может быть, сюда и сюда. Во-первых – это неблагодарная работа. Во-вторых, все-таки желательно применять это по месту назначения. Там, где вам это действительно помогает решать какую-то проблему.
Наконец, изучайте Kotlin. Изучайте возможности, которые приходят в этот язык, новые функции, благодаря чему ваш код будет все чище, короче, компактнее, читать его будет намного проще. И когда вы будете снова возвращаться к тестированию (например, что-то дописали, на это нужно сделать тест), вам будет намного приятнее это делать, потому что DSL максимально компактный, комфортный, и у вас нет проблем с тем, чтобы создать с десяток студентов. Просто в пару строчек это делается.

Тренируйтесь на «кошках», как герой одного известного фильма. На мой взгляд, сначала проще привнести в ваш проект Kotlin в качестве тестирования. Это хорошая возможность проверить язык, попробовать его, посмотреть на его фичи. Это такое поле боя, на котором даже если ничего не получится — ничего страшного, все еще можно этим пользоваться.
Наконец, предварительно проектируйте DSL. Сегодня я показал некоторый идеальный пример, и мы прошли поэтапно до построения проблемно-ориентированного языка. Если заранее спроектировать DSL, в конечном итоге будет намного проще, вы не будете по 10 раз переделывать его, вы не будете париться о том, что контексты каким-то образом пересекаются и логически сильно связаны. Просто предварительно спроектируйте DSL – это довольно легко сделать на бумажке, когда вы знаете набор конструкций, которые я вам сегодня рассказал.

И наконец, контакты для связи. Меня зовут Иван Осипов, Telegram: @ivan_osipov, Twitter: @_osipov_, Хабр: i_osipov. Буду ждать ваших комментариев.

Минутка рекламы. Если вам понравился этот доклад с конференции JPoint — обратите внимание, что 19-20 октября в Санкт-Петербурге пройдет Joker 2018 — крупнейшая в России Java-конференция. В его программе тоже будет много интересного. Конференция анонсирована совсем недавно, но на сайте уже есть первые спикеры и доклады.

  • Блог компании JUG Ru Group
  • Программирование
  • Java
  • Компиляторы
  • Kotlin

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *