DSL (Domain-specific language) — язык, специализированный для конкретной области применения (Википедия)
На написание этого поста меня натолкнула статья "Почему Kotlin отстой", в которой автор сетует на то, что в Kotlin "нет синтаксиса для описания структур". За некоторое время программирования на Kotlin у меня сложилось впечатление, что в нём если нельзя, но очень хочется, то можно. И я решил попробовать написать свой DSL для описания структуры данных. Вот что из этого получилось.
Disclaimer
Несмотря на то, что хотелось бы действительно получить DSL для описания структур, целью статьи я прежде всего хочу поставить именно объяснение (с примерами) тех возможностей языка Kotlin, с помощью которых написание этого самого DSL вообще становится возможным. Ну и простенький DSL мы, конечно, напишем :)
Синтаксис
Для проcтоты ли, или из-за каких-то личных предпочтений, я хочу, чтобы синтаксис моего будущего DSL для описания структуры данных был похож на JSON. Если коротко, синтаксис подразумевает следующее:
- каждое поле структуры имеет имя и записывается строкой,
- значение каждого поля может быть любым объектом, в том числе и другой вложенной структурой.
Шаг 0. Сначала была пустота
С чего-то надо начинать и начнем мы с того, что заставим компилироваться пустую структуру вида:
struct {
}
Сделать это совсем не сложно, нужно лишь объявить функцию
fun struct(init: () -> Unit){
}
Функция struct(...)
принимает в качестве параметра другую функцию, возвращаующую Unit
и пока больше ничего не делает. Но эта функция раскрывает нам важную фишку Kotlin, которая поможет нам в написании DSL: если последний аргумент функции – это другая функция, то её можно объявить за скобками "(...)". Если у функции всего 1 аргумент, и этот аргумент – функция, то круглые скобки можно не писать вообще.
Таким образом, наш код struct {}
— это эквивалент коду struct({})
, только короче.
Хорошо, у нас есть пустая структура! На самом деле нет, у нас есть только функция struct
, которая даже ничего не возвращает. Нужно что бы она возвращала хоть что-то:
class Struct // этого достаточно, что бы объявить класс в Kotlin
fun struc(init: () -> Unit) : Struct {
return Struct()
}
fun main() {
val struct = struct {
}
}
Вот теперь у нас действительно есть какой-то пустой объект класса Struct
Шаг 1. Потом были данные
Пора бы добавить какое-то содержание. Я пытался найти способ заставить работать конструкцию вида
struct {
"field1": 1,
"field2": 2
}
Точного совпадения мне добиться не удалось, зато получилось сделать аж 3 альтернативных синтаксиса, которые, при желании, можно использовать одновременно :)
struct {
s("field1" to 1)
s("field2" to arrayOf(1, 2, 3))
s("field3" to struct {
s("field3.1" to 31)
})
}
или
struct {
+{ "field1" to 1 }
+{ "field2" to 2 }
+{ "field3" to
struct {
+{ "field3.1" to 31 }
}
}
}
или
struct(
"field1" to 1,
"field2" to 2,
"field3" to struct(
"field1.1" to 11
)
)
Заметьте, что в третьем случае пришлось использовать круглые скобки, а не фигурные, зато в нём меньше всего символов.
Так как же заставить это работать? Во-первых, данные в классе Struct надо где-то хранить. Я выбрал hashMap<String, Any>()
, так как поле структуры у нас – строка, а значение — любой объект.
class Struct {
val children = hashMapOf<String, Any>()
}
Во-вторых, эти данные в структуру нужно как-то добавить. Напомню, что все, что находится внутри фигурных скобок после слова struct
есть функция, которую мы передали в struct(...)
аргументом. Значит, чтобы манипулировать объектом Struct
нам нужно получить доступ к этому объекту внутри переданной функции. И мы можем это сделать!
fun struct(init: Struct.() -> Unit): Struct {
val struct = Struct()
struct.init()
return struct
}
Мы поменяли тип функции init
на Struct.() -> Unit
. Это значит, что переданная функция должна быть функцией класса Struct
или его функцией расширения. При таком объявлении функции мы можем выполнить struct.init()
, а это, в свою очередь, значит, что в внутри функции init()
будет доступ к экземпляру класса Struct
через, например, this
.
Для примера, теперь мы в праве писать такой код:
struct {
this.children.put("field1", 1)
// this - экземпляр класса Struct, который только что был создан в функции struct()
}
Это уже работает, но мало похоже на язык описания структуры данных. Добавим поддержу конструкции
struct {
+{ "field1" to 1 }
}
"field1" to 1
— эквивалент Pair<String, Any>("field1", 1)
. Его оборачивают фигурные скобки, что является лямбда-функцией. Последняя строка лямбда-функции определяет тип возвращаемого ею значения, да и само значение. Другими словами, { "field1" to 1 }
— это лямбда, возвращающая Pair<String, Any>
.
С лямбдой покончили, но что это за "+" перед ней? А это переопределенный унарный оператор "+", вызовом которого мы и добавляем полученную из лямбды пару в нашу структуру. Его реализация выглядит так:
class Struct {
val children = hashMapOf<String, Any>()
operator fun (() -> Pair<String, Any>).unaryPlus() { // мы переопределили оператор + у лямбды
val pair = this.invoke() // вызываем лямбду и получаем пару
children.put(pair.first, pair.second) //сохраняем пару
}
}
Далее разберемся с поддержкой синтаксиса вида:
struct {
s("a" to 2)
}
Здесь нет лямбд, сразу создание объекта Pair
и какой-то символ "s" перед ней. На самом деле "s" — это тоже оператор, но уже инфиксный. Откуда он взялся? Так я сам его написал, вот он:
class Struct {
val children = hashMapOf<String, Any>()
infix fun Struct.s(that: Pair<String, Any>): Unit {
this.children.put(that.first, that.second)
}
}
Он ничего не возвращает, но добавляет переданную ему пару в нашу структуру данных. Букву "s" я выбрал просто так, название оператора может быть любым. К слову, to
в выражении "field1" to 1
это тоже инфиксный оператор, возвращающий пару Pair("field1", 1)
Наконец, добавим поддержу третего варианта синтаксиса. Самого лаконичного, но самого скучного с точки зрения реализации.
struct(
"field1" to 1
)
Не трудно догадаться, что "field1" to 1
— это просто аргумент функции struct(...)
. Что бы иметь возможность передать несколько пар, мы объявим этот аргумент как vararg
fun struct(vararg data: Pair<String, Any>, init: Struct.() -> Unit): Struct {
val struct = Struct()
for (pair in data) {
struct.children.put(pair.first, pair.second)
}
struct.init()
return struct
}
Шаг 2. И получился DSL?
Мы научились описывать структуру, но она и выеденного яйца не стоит, если мы не дадим возможность с ней работать. Мы же не хотим писать код вроде этого: struct.children.get("field")
, мы вообще ничего знать не хотим про children
. Мы хотим сразу обращаться к полям нашей структуры. Например, так: val value = struct["field1"]
. И мы можем научить наш DSL такому трюку, если определим еще один оператор для нашего класса Struct :)
class Struct {
val children = hashMapOf<String, Any>()
operator fun get(s: String): Any? {
return children[s]
}
}
Да, это оператор "get" (именно оператор, а не геттер), который автоматически вызывается при обращению к объекту через квадратные скобки.
Итого
Можно сказать, что DSL у нас получился. Пусть не идеальный, с очевидными недостатками в виде невозможности автоматически вывести тип каждого поля, но получился. Вероятно, если попрактиковаться еще какое-то время, можно найти способы его улучшить. Может быть у читателей есть идеи?
Пример кода целиком можно посмотреть по ссылке
Комментарии (16)
Oleg_Yozhik
23.02.2017 06:38Народ, помогите перевести документацию по котлину на русский! Или хотя бы поискать ошибки в тексте. Уже много сделано, но хочется больше. Спасибо.
gubber
24.02.2017 08:46+2Вот меня это удивляет: разработчики языка, на сколько я понимаю — наши. А вот ни документации на русском ни книг. Нету :(
Понятно, что всё это бизнес, и английский де факто — стандарт. Но даже не заметно телодвижений в этом направлении.Artem_zin
24.02.2017 20:02+5А меня нисколько не удивляет. Поддерживать документацию на нескольких языках это тысячи работы… И да, английский в разработке стандарт, всё равно без него никуда.
gubber
24.02.2017 08:50+1А как присоединиться к помогающим?
Ни кнопки «Помочь», ни кнопки «войти»/«зарегистрироваться» нетOleg_Yozhik
26.02.2017 03:30Помогать очень просто — все тексты хостятся на GitHub. Можно переводить файлы, добавлять новые, в том числе редактировать меню. Там же можно создавать Issue по поводу ошибок в тексте или предложений функциональности / дизайна сайта.
По умолчанию вы можете делать Pull Request а мы его быстро проверяем, одобряем.
Или обратитесь в группу в Телеграм за доступом на запись, чтобы пушить в напрямую без задержки на проверку.
Спасибо!
fsmorygo
25.02.2017 15:31Раз уж заговорили про способы написать собственные DSL, стоит посмотреть на JetBrains MPS.
dmitryvim
27.02.2017 14:41Выглядит, что ключевое слово «простой». На MPS, по личным ощущениям, порог входа выше. А groovy и kotlin дают решение попроще, но не такое мощное
fogone
Еще можно вот так:
forceLain
Выглядит круто! Только что-то у меня не вызываются в Вашем примере ни invoke, ни to :( Хотя, наверняка, это я где-то что-то упустил.
nerumb
В вашем примере блок
В «пустоту» уйдет…
fogone
это же пример, просто, если не понятно, то вот полноценный рабочий пример:
forceLain
Ага, спасибо! В первом примере просто пропущено слово «infix» у оператора «to», я не сразу заметил. В общем, покапитаню и скажу, что определяя оператор «to» внутри класса Struct мы получаем доступ и к объекту Struct и к строке перед оператором и ко второму параметру, следующему после оператора «to» и в этом сам фокус. С «invoke» та же история.
fogone
да, прошу прощения, в первом примере infix пропустил
lani
Но лучше не надо (какой-то оверинжиниринг)
fogone
а то, что в статье — надо и не оверинжениринг?