Pull to refresh
0
True Engineering
Лаборатория технологических инноваций

Пишем простой DSL на Kotlin в 2 шага

Reading time5 min
Views11K

image


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 у нас получился. Пусть не идеальный, с очевидными недостатками в виде невозможности автоматически вывести тип каждого поля, но получился. Вероятно, если попрактиковаться еще какое-то время, можно найти способы его улучшить. Может быть у читателей есть идеи?


Пример кода целиком можно посмотреть по ссылке

Tags:
Hubs:
+8
Comments16

Articles

Change theme settings

Information

Website
www.trueengineering.ru
Registered
Founded
Employees
101–200 employees
Location
Россия