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

  • Tutorial

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


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

EastBanc Technologies

124,00

Специалисты по цифровой трансформации бизнеса

Поделиться публикацией

Похожие публикации

Комментарии 16
    0
    Еще можно вот так:
    class Struct {
        
        fun <T> String.to(valut:T) {
            
        }
        
        operator fun <T> String.invoke(value:T) {
            
        }
        
    }
    
    fun build(builder:Struct.()->Unit): Struct = Struct().apply(builder)
    
    fun main(args: Array<String>) {
        
        build { 
            "test1" to 10
            "test2" to "name"
            "test3"(50)
        }
        
    }
    
      0
      Выглядит круто! Только что-то у меня не вызываются в Вашем примере ни invoke, ни to :( Хотя, наверняка, это я где-то что-то упустил.
        0
        В вашем примере блок
        "test1" to 10
        "test2" to "name"
        "test3"(50)
        

        В «пустоту» уйдет…
          0
          это же пример, просто, если не понятно, то вот полноценный рабочий пример:
          class Struct(val fields: Map<String, Any>)
          
          class StructBuilder {
          
              private val fields = HashMap<String, Any>()
          
              infix fun <T : Any> String.to(value: T) {
                  fields[this] = value
              }
          
              operator fun <T : Any> String.invoke(value: T) {
                  fields[this] = value
              }
          
              fun build(): Struct = Struct(fields)
          
          }
          
          
          fun build(builder: StructBuilder.() -> Unit): Struct =
                  StructBuilder().apply(builder).build()
          
          fun main(args: Array<String>) {
          
              val struct = build {
                  "test1" to 10
                  "test2" to "name"
                  "test3"(50)
              }
          
          }
          
            0
            Ага, спасибо! В первом примере просто пропущено слово «infix» у оператора «to», я не сразу заметил. В общем, покапитаню и скажу, что определяя оператор «to» внутри класса Struct мы получаем доступ и к объекту Struct и к строке перед оператором и ко второму параметру, следующему после оператора «to» и в этом сам фокус. С «invoke» та же история.
              0
              да, прошу прощения, в первом примере infix пропустил
          0

          Но лучше не надо (какой-то оверинжиниринг)

            +1
            а то, что в статье — надо и не оверинжениринг?
          0
          Народ, помогите перевести документацию по котлину на русский! Или хотя бы поискать ошибки в тексте. Уже много сделано, но хочется больше. Спасибо.
            +2
            Вот меня это удивляет: разработчики языка, на сколько я понимаю — наши. А вот ни документации на русском ни книг. Нету :(
            Понятно, что всё это бизнес, и английский де факто — стандарт. Но даже не заметно телодвижений в этом направлении.
              +5

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

              +1
              А как присоединиться к помогающим?
              Ни кнопки «Помочь», ни кнопки «войти»/«зарегистрироваться» нет
                +1

                слева в оглавлении внизу раздел "Редактору" — там написано как редактировать

                  0
                  Помогать очень просто — все тексты хостятся на GitHub. Можно переводить файлы, добавлять новые, в том числе редактировать меню. Там же можно создавать Issue по поводу ошибок в тексте или предложений функциональности / дизайна сайта.

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

                  Спасибо!
                0
                Раз уж заговорили про способы написать собственные DSL, стоит посмотреть на JetBrains MPS.
                  0
                  Выглядит, что ключевое слово «простой». На MPS, по личным ощущениям, порог входа выше. А groovy и kotlin дают решение попроще, но не такое мощное

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

                Самое читаемое