суботу, 5 серпня 2017 р.

Kotlin. Часть 5. Пишем DSL

Вот и пришла пора для заключительной статьи в основном цикле о Kotlin. Что это значит? Пользуясь знаниями из предыдущих статей (Введение, Незнакомые конструкции, Мигрируем из Java, Неловкие моменты) ниже мы с вами напишем собственный DSL (domain-specific language), обсудим что это такое и чем Kotlin, как язык, способствует написанию предметно-ориентированных языков, а также как это упрощает разработчикам жизнь, я расскажу о собственном опыте в разработке DSL и о проблемах, которые возникают. Статья может показаться довольно большой, так и есть, я постарался уместить в неё основной концентрат собственных знаний о построении DSL в Kotlin.

Давай же, Kotlin! На тебя все смотрят!

Что такое DSL?

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

"Чистое API"

"Чистым" называется API, которое требует минимальных действий для получения результата, никакой мишуры и фантиков. Давайте рассмотрим, что нам дает Kotlin для построения такого API.

На пути к совершенству

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

Преимущества Kotlin, которыми стоит пользоваться

Благодаря этим возможностям вы способны писать код чище, избавиться от множества вызовов методов и при этом сделать разработку еще более приятным занятием ("куда уж приятнее?" - спросите вы). Мне понравилось сравнение из книжки, что в натуральных языках, например, в английском, предложения построены из слов и граматические правила управляют тем как нужно объединять слова друг с другом. Аналогично в DSL, одна операция может быть сложена из нескольких вызовов методов, а проверка типов обеспечит гарантию, что конструкция имеет смысл.

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

Внутренний DSL

Проще всего понять что такое "внутренний" DSL на примере. Вот у вас есть реляционная база данных и мы хотим выбрать из неё определенные записи, как мы это делаем? Очевидно, SQL. У нас будет select запрос в котором мы укажем что и как нужно достать. В перевес этому мы можем взять фреймворк Exposed на Kotlin (не то чтобы я сторонник миллионов оберток, но это хороший пример). Exposed - это работа с базой данных в sql подобном стиле прямо из кода. Больше подробностей по фреймворку по ссылке в конце статьи.

Пример DSL

Предлагаю посмотреть, а ради чего всё это. Я приведу пример из практики. Библиотека Clabo для создания телеграм ботов в декларативном стиле, демонстрирует возможности, которые дает Kotlin для построения DSL. Каждый бот имеет свой ключик, который выдается при создании бота для управления им. При работе с ботом нам нужно при каждом запросе указывать этот ключик в URL в качестве авторизации. Long Pooling боты основываются на повторных подвисающих запросах на серверах telegram. Чтобы обработать сообщения или другие события, нужно запросить у сервера последние изменения, затем правильно распознать, например, команду и обработать её. Сформировать ответное сообщение и отправить его обратно. Оценили количество действий? А теперь давайте посмотрим как эту тонну стандартных действий можно спрятать за DSL.

fun main(args: Array<String>) {
    bot("apiKey") longPool {
        onStart { command ->
            command.message reply "Hi! I'm Bot"
        }
    }
}

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

Лямбда - украшение стола

Выше были перечислены различные фичи, которые помогают в построении DSL. Давайте рассмотрим их в порядке роста важности (на мой взгляд).

Переопределение операторов

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

Таблица превращений инкремента и декремента в функции

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

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

Как это помогает?

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

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

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

Как обрабатываются get и set операторы

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

Делегированные свойства

Один из наиболее популярных примеров делегированных свойств - ленивая инициализация. Фактически, одной строкой мы можем описать, что поле должно проинициализироваться один раз, а затем возвращаться это значение. В Kotlin из-за наличия nullability мы не можем говорить, что свойство notnull, а её backing field (мы их рассматривали в прошлой статье) - nullable. По этому, для сохранения notnull типа свойства от нас требуется заводить дополнительную nullable переменную, выглядит всё это следующим образом.

class UserComponent {
    private var _hugeData: HugeData? = null
    val hugeData: HugeData
        get() {
            if(_hugeData == null) {
                _hugeData = loadHugeData()
            }
            
            return _hugeData!!
        }
    fun loadHugeData(): HugeData {
        return HugeData().apply { 
            //load and fill huge data
        }
    }
}

Естественно, это отвратительно и да, это можно исправить. На помощь приходят делегированные свойства. Весь код выше можно заменить на код ниже, он делает то же самое.

class UserComponent {
    val hugeData by lazy { loadHugeData() }

    fun loadHugeData(): HugeData {
        return HugeData().apply {
            //load and fill huge data
        }
    }
}

Ключевое слово by используется для прикрепления делегата. Что же это означает? Создается специальный объект, который имеет метод getValue к которому переадресовываются все вызовы. Это и есть делегат.

Давайте учиться на существующий примерах. lazy - это first-class функция, которую можно вызвать везде в проекте. В эту функцию можно передать лямбду, которую в Java 8 мире принято называть Supplier'ом. Внутри этой функции мы видим как создается объект SyncronizedLazyImpl . Благодаря этому теперь мы знаем, то что есть 3 разных вида Lazy Property, которые регулируются параметром с типом LazyThreadSafetyMode. Внутри SyncronizedLazyImpl мы увидим свойство value. Вот она реализация lazy. Но не хватает метода getValue, где же он? У него должна быть определенная сигнатура и мы находим его в том же файле в виде Extention функции. Объявляется он вот так

operator fun <T> Lazy<T>.getValue(thisRef: Any?, property: KProperty<*>): T

Т.е. кто угодно унаследованный от Lazy может быть использован как делегат. Проверим? Конечно!

Хочу сразу сказать, что наследоваться от Lazy - не обязательное условие, вы можете просто объявить эту функцию (getValue) в своем классе. Давайте так и сделаем завернем загрузку properties из файла в делегат и пусть при первом обращении мы будем загружать, а затем использовать. Создадим файл для демо. Это простой .property файл в котором только одна запись key=value. Разберем на код ниже

fun main(args: Array<String>) {
    val component = UserComponent("D://someProperties.properties")
    println("Component is initialized")
    val keyProperty = component.properties["key"]
    println(keyProperty)
}

class UserComponent(propertiesPath: String) {
    val properties by LazyPropertyLoader(propertiesPath)
}

class LazyPropertyLoader(val filePath: String) {

    private var properties: Properties? = null

    operator fun getValue(thisRef: Any?, property: KProperty<*>): Properties {
        if (properties == null) {
            properties = Properties()
            properties!!.load(FileInputStream(filePath))
            println("Property is loaded")
        }

        return properties!!
    }
}

В методе main мы создаем компонент, который хранит эти Properties, а класс LazyProperyLoader это делегат, который работает благодаря методу getValue. Да, эта реализация потоко-небезопасна, но пример очень прост и компактен. Теперь когда у объектов с типом UserComponent будет запрашиваться свойство properties, на самом деле вызов будет переадресовываться к getValue и в свою очередь лениво тянуть поле properties.

Всё очевидно и просто, здесь и ленивая загрузка и делегат, в общем всё что нужно! Ничего сложного.

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

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

typealias Point = Pair<Int,Int>

Такой псевдоним не вводит новый тип, но он эквивалентен вызову пары, первая цель этой функциональности - уменьшить длинные декларации типов. Как вы думаете, почему в популярной библиотеке Google Guava нет класса "пара"? Думаете не досмотрели? Как бы не так! Этот класс упрощает жизнь разработчику, но усложняет её тому, кто будет читать код, т.к. стирается весь смысл связи двух объектов. В Kotlin есть стандартный класс пары и когда мы хотим добавить ему смысла мы легко можем добавить псевдоним. Еще один хороший пример - Telegram API. Если вы когда либо разрабатывали ботов для телеграм, то вы отлично знаете, что есть id чата, id сообщения, id кучи чего еще и хорошо бы наделить смыслом сухой String. Мне нравится делать следующим образом, когда мне нужно объявить тип очередного идентификатора, я смело указываю его псевдоним, например

val processor: (ChatId, MessageId) -> Unit = { chatId, messageId ->...}

А на деле это

val processor: (String, String) -> Unit = { chatId, messageId ->...}


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

Как вы знаете, JetBrains не переизобретали язык программирования, а честно позаимствовали лучшие практики. Благодаря этому появился хороший, практичный язык программирования Kotlin. Одна из заимствованных возможностей это вынесение лямбды за скобки. Это довольно просто, если лямбда является последним параметром, то её можно вынести за скобки, а если других параметров нет, то скобки можно удалить. Пример ниже

fun run(lambda: () -> Unit) {
    lambda()
}
fun parametrizedRun(param: String, lambda: (String) -> Unit) {
    lambda(param)
}
fun invoke() {
    run {
        println("no param")
    }
    parametrizedRun("param") {
        println(it)
    }
    parametrizedRun("param") { param ->
        println(param)
    }
}

Давайте разберемся. Внутри функции invoke вызывается функция run у которой первый и единственный параметр лямбда. Это тот случай когда мы можем избавиться от круглых скобок. Затем вызывается parametrizedRun, в который нужно передать параметр. Здесь от скобок нам не избавиться никак, но обратите внимание, что по умолчанию, от нас не требуют писать название передаваемого параметра и мы можем использовать название по умолчанию it. Прошу заметить, что если у вас несколько параметров, то проигнорировать их имена, как здесь, не удастся, однако вы можете использовать специальный символ _ , благодаря которому вам не придется писать имена параметров, которые вы не используете. Эта возможность появилась в релизе языка под номером 1.1. Наконец, если вы всё-таки хотите именовать свой параметр, то вам никто не запрещает и на этот случай есть третий пример.

Extention функция

Иногда нужно прикрутить один не сложный метод к классу, которого очень не хватает, а класс из библиотеки и изменить его не вариант или мы хотим сделать более читаемой обработку какого-то объекта и при этом не тащить эту логику в него, в таких ситуациях помогают extention функции. Фактически, это функции, которые крепятся к какому либо классу, интерфейсу или даже обобщенному типу, а затем могут быть вызваны в любом месте где идет обработка инстансов. На деле, такие функции не встраиваются в класс и не получают доступ к приватному API, а всего лишь работают с тем, что доступно всем остальным. Объявляются они очень просто fun ClassName.funName(...). Здесь вы можете использовать дженерики или просто указать интерфейс, компилятор позволит использовать метод только там, где это возможно в соответствии с описанной сигнатурой метода.

Infix функции

Пожалуй, одна из самых сладких фич, благодаря которой вызовы функций выглядят как предложения на естественном языке - infix функция. Такая функция применяется для двух параметров, при чем первый - это контекст вызова функции, т.е. некоторый объект на котором она будет вызвана, а второй параметр передается в функцию. Эта фича замечательно сочетается с лямбдой за скобками и extention функцией. Сначала рассмотрим простой пример. Ранее мы ввели псевдоним Point, пришло время им воспользоваться. Напишем infix функцию для перемещения игрока по клеточкам

class Player(var position:Point = Point(0, 0)) {
    infix fun moveTo(point: Pair<Int, Int>) {
        position = point
    }
}

Готово! Модификатор infix позволяет нам использовать функцию следующим образом

val player = Player()
player moveTo Point(1, 1)

Не потрясающе ли? Никакого шума в виде точек или скобок! Но это превращается в нечто более интересное, если мы совместим лямбду за скобками с infix функцией. Декларация этого выглядит так:

class Player(var position:Point = Point(0, 0)) {
    infix fun processPosition(processing: (Point) -> Unit) {
        processing(position)
    }
}

Мы передаем лямбду в метод processPosition и тут же её вызываем. Единственный параметр в лямбде - Point. В итоге применение такого метода выглядит следующим образом

val player = Player()
player processPosition {
    println("X:${it.first} Y:${it.second}")        
}

Как можно улучшить этот пример? Давайте взглянем на деструктирующую декларацию

Деструктирующая декларация

Страшно звучит, не хочу это литературно переводить - ни к чему. В прошлых статьях я писал о модификаторе data и о том, что он добавляет к классу методы componentN. Фактически, каждый такой метод, component1, component2 и т.д. говорит о том каким по счету вернется возвращаемый параметр при "разбирании" объекта на набор полей. Представим, что у нас есть Point, который, как мы знаем, Pair, и у него есть 2 параметра в первичном конструкторе и модификатор data, это значит, что для такого класса будет сгенерировано 2 метода component1 и component2. Для примера продемонстрируем следующее сравнение

val point = Point(1, 1)
val (x, y) = point

этот код равносилен следующему

val point = Point(1, 1)
val x = point.component1()
val y = point.component2()

Такое "подслащение" дает возможность обновить предыдущий пример вызова infix функции до такого

val player = Player()
player processPosition { (x, y) ->
    println("X:$x Y:$y")
}

Код стал чуть более читаемым. По крупицам мы почти собрали джентельменский набор для построения DSL. Жемчужина этого набора - лямбда с обработчиком.

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

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

fun <T> T.apply(block: T.() -> Unit): T

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

T.() -> Unit

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

Player().apply {
    println(this)
}

При печати мы увидим вывод метода toString класса Player. "Почему же это жемчужина?" - спросите вы. Всё просто! В совокупности с остальными методами на основе таких лямбд строится большая часть DSL, например, мы хотим создавать игроков определенной игры и код может выглядеть, например, так

data class Player(var name: String? = null)

class Game(val players: MutableSet<Player> = HashSet())

infix fun Game.newPlayer(init: Player.() -> Unit) {
    val player = Player()
    player.init()
    players += player
}

fun main(args: Array<String>) {
    val game = Game()
    game newPlayer {
        name = "Иванов"
    }
    println(game.players)
}

Здесь мы видим использование infix функции, лямбды с обработчиком за скобками и использование оператора. Важно понимать, что лямбда с ресивером вызывается на определенном контексте и по этому мы не можем сделать init(), а делаем player.init()

Резюме по фичам

Каждая фича в отдельности не плоха, но вместе они порождают гремучую смесь, это самая настоящая синергия.

Мой опыт

Прежде всего, хочу сказать, что в познании Kotlin мне очень помогла книжка, Kotlin In Action, хотя изучить язык вы сможете и без неё, документация у Koltin хорошая, но это книга дает более глубокое, если позволите, понимание философии языка и целостное повествование. Вторым источником знаний для меня было множество видео на YouTube. Я не считаю себя глубоким экспертом, но мне нравится к этому стремиться и эти статьи тоже часть пути.

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

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

Для желающих добавить Kotlin в рабочий проект

Прежде всего, будьте готовы к тому что любая инициатива имеет свои последствия. Мне не нравится фраза "Инициатива наказуема" и её аналоги. Я сторонник того, что если можешь сделать стратегически лучше - сделай. Если вы чувствуете в себе силы отвечать за последствия, а это означает любую поломку так или иначе связанную с языком, то делайте. Для начала вы можете привнести Kotlin для тестирования, а затем постепенно захватывать проект :)

Минусы DSL

Не бывает всё идеально, и на этой полянке растут свои поганки. Давайте их обсудим.

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

Что если вы хотите переиспользовать часть своего DSL? Что если внутри переиспользуемой части используется множество различных контекстов? Встает вопрос: "Как это сделать?". Возможно вы подскажете варианты лучше моих, но мне известно два работающих пути: добавлять "именованные callback'и", как часть DSL или плодить лямбды (вернее замыкания). Второй вариант проще, но его последствия могут вылиться в самый настоящий ад, когда вы пытаетесь отследить последовательность и голова уже не способна понять. Что было вызвано? В каком контексте? Решение проблемы простое, там где нужна большая вложенность разбейте на большее количество лямбд с говорящим названием. Почему здесь не подходят функции? Вот она, причина

val lambda = {
    fun a() {
        if(Random().nextBoolean()) {
            b()
        }
    }
    fun b() {
        if(Random().nextBoolean()) {
            a()
        }
    }
}

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

This, it!?

Очень легко потерять смысл текущего this и it. Там где вам нужен it, пожалуйста, пишите осмысленное название для параметра, эта пара секунд упростит вам жизнь в будущем. С this вы тоже можете выйти сухим из воды. Там, где появляется запутанность, используйте другую ссылку на this, объявленную в том месте, где нет двусмысленности или она минимальная

val mainContext = this

Вложенность

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

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

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

Заключение

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

Ссылки, которые обещал:

Справка по операторам здесь

Примеры DSL: Clabo, Exposed

Эту статью я подготовил на основе собственного опыта и материалов из книги Kotlin In Action.

Немає коментарів:

Дописати коментар

HyperComments for Blogger

comments powered by HyperComments