Небольшое вступление
Всем привет! Частенько зависаю на Medium и нахожу уйму полезных статей от зарубежных разработчиков. В один из таких дней искал для себя что-нибудь по DSL в Kotlin и наткнулся на серию статей о том, что такое DSL в Kotlin и как с этим работать. До прочтения я имел поверхностное понятие о DSL, так как совсем изредка сталкивался ними. Во время чтения статьи мне понравилась простота описания и подачи примеров от автора так, что по окончанию прочтения я решил перевести эту пару статей для вас. Разумеется, с одобрения автора :) Ну что ж, начнём.
Кратко о DSLs. Что это?
Для начала можно подсмотреть определение из Википедии:
Domain-specific language (DSL) — компьютерный язык, специализированный для какой-либо конкретной области применения. Он отличается от языков общего назначения (GPL), которые широко применяются во многих областях.
В принципе, DSL — язык, который фокусируется на одной конкретной части приложения, с другой стороны, языки общего назначения, такие как Kotlin и Java, могут использоваться в других частях приложения. Есть несколько DSL, с которыми вы уже наверняка знакомы, например SQL. Если взглянуть на SQL, то можно заметить, что он выглядит почти как обычное предложение на английском языке, благодаря чему он становится вполне читаемым, понятным и выразительным:
SELECT Person.name, Person.age FROM Person ORDER BY Person.age DESC
Каких-то особенных критериев, которые бы отличали DSL от нормального API нет, но часто мы замечаем одно различие: Использование определённой структуры или грамматики. Это делает код более понятным для человека, который лёгок в понимании не только для разработчиков, но и для людей, которые менее подкованы в части языков программирования.
DSLs с Kotlin
Теперь давайте разберёмся, как мы можем создать DSL с некоторыми языковыми особенностями Kotlin и какие преимущества это нам приносит?
Когда мы создаём DSL на каком-то универсальном языке программирования, таком как Kotlin, то мы фактически имеем в виду внутренние DSL. Ведь мы не создаем независимый синтаксис, а просто настраиваем конкретный способ использования данного языка. И именно это даёт нам преимущество использования кода, который мы уже знаем, и позволяет нам добавлять разные операторы, такие как циклы for, в наш DSL.
Кроме того, Kotlin предлагает несколько способом создания более чистого синтаксиса и помогает избежать использования слишком большого количества ненужных символов. И в этой первой части мы рассмотрим три особенности:
- Использование лямбд вне скобок метода
- Лямбды с аргументами (приемниками)
- Функции расширения (от переводчика: они же Extension functions, о которых уже все слышали, наверное)
Как этим всем пользоваться — станет более понятно через минуту, когда мы создадим несколько примеров.
Чтобы сделать всё максимально понятным, в этой части я буду использовать простую модель для создания нашего DSL. Мы не должны создавать DSL при создании класса. Это было бы лишним. Хорошим место для использования DSL может быть класс конфигурации или интерфейс библиотеки, где пользователь не должен знать о моделях.
А теперь давайте напишем наш первый DSL
В этой части мы создадим простой DSL, который сможет создать объект класса Person. Заметьте — это всего лишь просто пример. Итак, вот пример того, что мы собираемся получить в конце этого урока:
val person = person {
name = "John"
age = 25
address {
street = "Main Street"
number = 42
city = "London"
}
}
Можно сразу заметить, что код выше сам по себе описывает себя и лёгок в понимании. Даже тот человек, который не имеет опыта разработчика, сможет прочитать это и даже внести свои правки. Чтобы понять, как мы сможем такое воссоздать, мы совершим несколько шагов. Вот модель, с которой мы начнём:
data class Person(var name: String? = null,
var age: Int? = null,
var address: Address? = null)
data class Address(var street: String? = null,
var number: Int? = null,
var city: String? = null)
Очевидно, что это не самая чистая модель, которую мы можем написать. Но мы хотим иметь иммутабельные проперти (val). И до этого мы доберемся в следующих частях этой серии.
Первое, что мы сделаем — создадим новый файл. В нём будем держать DSL отдельно от фактических классов в нашей модели. Начнём с создания какой-нибудь функции-конструктора для нашего класса Person. Смотря на результат, который мы хотим иметь, мы видим, что все проперти класса Person определены в кодовом блоке. Но на самом деле этим фигурные скобки означают лямбду. Здесь мы и используем первую из трех вышеперечисленных особенностей языка Kotlin: Использование лямбд вне скобок метода.
Если последним параметром функции является лямбда, то мы можем использовать её вне скобок. А если у вас только один параметр, который является лямбдой, то вы можете и вовсе удалить скобки. Это значит, что person {...} тоже самое, что и person({...}). Это приводит к меньшему синтаксическому загрязнению в нашем DSL. Теперь напишем первую версию нашей функции.
fun person(block: (Person) -> Unit): Person {
val p = Person()
block(p)
return p
}
Итак. Здесь у нас функция, которая создает объект Person. Для этого требуется лямбда, у которой есть объект, который мы создаём в строке 2. Когда мы выполняем эту лямбду в строке 3, то мы ожидаем, что объект получит необходимые ему проперти, прежде чем мы вернём объект в строке 4. А теперь давайте посмотрим, как мы можем использовать написанную нами функцию:
val person = person {
it.name = "John"
it.age = 25
}
Поскольку лямбда получает только один аргумент, то мы можем обращаться к объекту person через it. Это выглядит довольно таки неплохо, но это ещё не конец. На самом деле это не совсем то, что мы хотим видеть в нашем DSL. Особенно, когда мы собираемся добавить дополнительные слои объектов. Это приводит нас к следующей упомянутой нами функции Kotlin: Лямбды с аргументами (приемниками).
В определении функции person мы можем добавить приемник к лямбде. Таким образом, мы можем получить доступ только к функциям этого приемника в данной лямбде. Поскольку функции входят в область получателя, то мы можем просто выполнить лямбду на приемнике вместо того, чтобы подставлять его его в качестве аргумента для лямбды.
fun person(block: Person.() -> Unit): Person {
val p = Person()
p.block()
return p
}
А ещё это можно написать попроще. Например, с помощью функции apply, предоставленной Kotlin.
fun person(block: Person.() -> Unit): Person = Person().apply(block)
Теперь мы можем убрать it из нашего DSL.
val person = person {
name = "John"
age = 25
}
Выглядит замечательно, не так ли? :) Мы почти закончили. Но мы упустили один момент — класс Address. В нашем желаемом результате он очень похож на функцию person, которую мы только что создали. Единственное различие здесь в том, что мы должны назначить его как проперти address для объекта Person. Для того, чтобы сделать это, мы воспользуемся последним из трёх упомянутых функций языка Kotlin: Функции расширения.
Функции расширения дают вам возможность добавлять функции к классам без доступа к исходному коду самого класса. Это идеально подходит для создания объекта Address и непосредственно присваивает его для проперти address в Person. Вот окончательная версия нашего файла DSL (на данный момент).
fun person(block: Person.() -> Unit): Person = Person().apply(block)
fun Person.address(block: Address.() -> Unit) {
address = Address().apply(block)
}
Мы добавили функцию address к Person, которая принимает лямбду с Address в качестве приемника, точно так же, как мы делали это с функцией-конструктором person. Затем она устанавливает созданный объект Address в проперти класса Person. Теперь мы создали DSL для создания нашей модели.
val person = person {
name = "John"
age = 25
address {
street = "Main Street"
number = 42
city = "London"
}
}
Это первая часть из длинной серии о том, как писать DSLs в Kotlin. Во второй части мы поговорим о добавлении коллекций, использовании шаблона Builder и аннотации @DslMarker. Существует пример из реальной жизни с использованием GsonBuilder.
Оригинал статьи вы можете найти здесь: Writing DSLs in Kotlin (part 1)
Автор оригинальной серии статей: Fré Dumazy
Автор: Артём