Типобезопасные строители

Идея строителей (builders) довольна популярна в сообществе Groovy. Строители позволяют объявлять данные в полудекларативном виде. Строители хороши для генерации XML, вёрстки компонентов UI, описания 3D сцен и многого другого...

В отличие от Groovy, Kotlin проверяет типы строителей, что делает их более приятными в использовании в большинстве юзкейсов.

Для прочих случаев Kotlin поддерживает Динамически типизированные строители (Dynamic types builders).

Пример типобезопасного строителя

Рассмотрим следующий код:

import com.example.html.* // смотрите объявления ниже

fun result(args: Array<String>) =
    html {
        head {
            title {+"XML кодирование с Kotlin"}
        }
        body {
            h1 {+"XML кодирование с Kotlin"}
            p  {+"этот формат может быть использован как альтернатва XML"}

            // элемент с атрибутом и текстовым содержанием
            a(href = "http://kotlinlang.ru") {+"Kotlin"}

            // смешанный контент
            p {
                +"Немного"
                b {+"смешанного"}
                +"текста. Посмотрите наш"
                a(href = "http://kotlinlang.org") {+"перевод"}
                +"документации Kotlin."
            }
            p {+"немного текста"}

            // контент генерируется в цикле
            p {
                for (arg in args)
                    +arg
            }
        }
    }

Всё это полностью корректный Kotlin-код. Здесь вы можете отредактировать и запустить пример с этим кодом прямо у себя в браузере.

Как это работает

Давайте рассмотрим механизм реализации типобезопасных строителей в Kotlin. Прежде всего, нам нужно определить модель, которую мы собираемся строить. В данном случае это HTML-тэги. Мы можем сделать это без труда с помощью нескольких классов. К примеру, HTML — это класс, который описывает тэг <html>, т.е. он определяет потомков, таких как <head> и <body>. (См. его объявление ниже.)

Теперь давайте вернёмся к вопросу почему мы можем писать вот такой код:

html {
 // ...
}

На самом деле, html является вызовом функции, которая принимает лямбда-выражение в качестве аргумента. Вот как эта функция определена:

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()
    html.init()
    return html
}

Эта функция принимает один параметр-функцию под названием init. Тип этой функции: HTML.() -> Unitфункциональный тип с объектом-приёмником. Это значит, что нам нужно передать экземпляр класса HTML (приёмник) в функцию, и мы сможем обращаться к членам объекта в теле этой функции. Обращение происходит через ключевое слово this:

html {
    this.head { /* ... */ }
    this.body { /* ... */ }
}

(head и body — члены класса HTML)

Теперь this может быть опущено, и мы получим что-то, что уже очень похоже на строителя:

html {
    head { /* ... */ }
    body { /* ... */ }
}

Итак, что же делает этот вызов? Давайте посмотрим на тело функции html, объявленной выше. Она создаёт новый экземпляр HTML, затем инициализирует его путём вызова функции, которая была передана в аргументе (в нашем примере это сводится к вызову head и body у объекта HTML), и после этого возвращает его значение. Это в точности то, что и должен делать строитель.

Функции head и body в классе HTML объявлены схоже с функцией html. Единственное отличие в том, что они добавляют отстроенные экземпляры в коллекцию children заключающего экземпляра HTML:

fun head(init: Head.() -> Unit) : Head {
    val head = Head()
    head.init()
    children.add(head)
    return head
}

fun body(init: Body.() -> Unit) : Body {
    val body = Body()
    body.init()
    children.add(body)
    return body
}

На самом деле эти две функции делают одно и тоже, поэтому мы можем использовать обобщённую версию, initTag:

protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
    tag.init()
    children.add(tag)
    return tag
}

Теперь наши функции выглядят очень просто:

fun head(init: Head.() -> Unit) = initTag(Head(), init)

fun body(init: Body.() -> Unit) = initTag(Body(), init)

И мы можем использовать их для постройки тэгов <html> и <body>.

Ещё одна вещь, которую следует обсудить, это добавление текста в тело тэга. В примере выше мы используем такой синтаксис:

html {
    head {
        title {+"XML кодирование с Kotlin"}
    }
    // ...
}

Итак, мы просто добавляем строку в тело тэга, приписав + перед текстом, что ведёт к вызову префиксной операции unaryPlus(). Эта операция определена с помощью функции-расширения unaryPlus(), которая является членом абстрактного класса TagWithText (родителя Title).

fun String.unaryPlus() {
    children.add(TextElement(this))
}

Иными словами, префикс + оборачивает строку в экземпляр TextElement и добавляет его в коллекцию children.

Всё это определено в пакете com.example.html, который импортирован в начале примера выше. В последней секции вы можете прочитать полное описание определений в этом пакете.

Контроль области видимости: @DslMarker (с версии 1.1)

При использовании DSL может возникнуть проблема, когда слишком много функций могут быть вызваны в определённом контексте. Мы можем вызывать методы каждого неявного приёмника внутри лямбды, и из-за этого может возникать противоречивый результат, как, например, тэг head внутри другого тэга head:

html {
    head {
        head {} // должен быть запрещён
    }
    // ...
}

В этом примере должны быть доступны только члены ближайшего неявного приёмника this@head; head() является членом другого приёмника — this@html, поэтому его вызов в другом контексте должен быть запрещён.

Для решения этой проблемы в Kotlin 1.1 был введен специальный механизм для управления областью приёмника.

Чтобы заставить компилятор запускать контрольные области, нам нужно только аннотировать типы всех получателей, используемых в DSL, той же маркерной аннотацией. Например, для HTML Builders мы объявляем аннотацию @HTMLTagMarker:

@DslMarker
annotation class HtmlTagMarker

Аннотированный класс называется DSL-маркером, если он помечен аннотацией @DslMarker.

В нашем DSL все классы тэгов расширяют один и тот же суперкласс Tag. Нам достаточно аннотировать @HtmlTagMarker только суперкласс, и после этого компилятор Kotlin обработает все унаследованные классы в соответствии с аннотацией:

@HtmlTagMarker
abstract class Tag(val name: String) { ... }

Нам не нужно помечать классы HTML или Head аннотацией @HtmlTagMarker, потому что их суперкласс уже аннотирован:

class HTML() : Tag("html") { ... }
class Head() : Tag("head") { ... }

После добавления этой аннотации, компилятор Kotlin знает, какие неявные приёмники являются частью того же DSL, и разрешает обращаться только к членам ближайших приёмников:

html {
    head {
        head { } // ошибка: член внешнего приёмника
    }
    // ...
}

Обратите внимание, что всё ещё возможно вызывать члены внешнего приёмника, но для этого вам нужно указать этот приёмник явно:

html {
    head {
        this@html.head { } // всё работает
    }
    // ...
}

Полное описание пакета com.example.html

Перед вами содержание пакета com.example.html (представлены только элементы, использованные в примере выше). Он строит HTML дерево и активно использует расширения и лямбды с приёмниками.

Примечание: Аннотация @DslMarker доступна в Kotlin начиная с версии 1.1

package com.example.html

interface Element {
    fun render(builder: StringBuilder, indent: String)
}

class TextElement(val text: String) : Element {
    override fun render(builder: StringBuilder, indent: String) {
        builder.append("$indent$text\n")
    }
}

@DslMarker
annotation class HtmlTagMarker

@HtmlTagMarker
abstract class Tag(val name: String) : Element {
    val children = arrayListOf<Element>()
    val attributes = hashMapOf<String, String>()

    protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
        tag.init()
        children.add(tag)
        return tag
    }

    override fun render(builder: StringBuilder, indent: String) {
        builder.append("$indent<$name${renderAttributes()}>\n")
        for (c in children) {
            c.render(builder, indent + "  ")
        }
        builder.append("$indent</$name>\n")
    }

    private fun renderAttributes(): String {
        val builder = StringBuilder()
        for ((attr, value) in attributes) {
            builder.append(" $attr=\"$value\"")
        }
        return builder.toString()
    }

    override fun toString(): String {
        val builder = StringBuilder()
        render(builder, "")
        return builder.toString()
    }
}

abstract class TagWithText(name: String) : Tag(name) {
    operator fun String.unaryPlus() {
        children.add(TextElement(this))
    }
}

class HTML : TagWithText("html") {
    fun head(init: Head.() -> Unit) = initTag(Head(), init)

    fun body(init: Body.() -> Unit) = initTag(Body(), init)
}

class Head : TagWithText("head") {
    fun title(init: Title.() -> Unit) = initTag(Title(), init)
}

class Title : TagWithText("title")

abstract class BodyTag(name: String) : TagWithText(name) {
    fun b(init: B.() -> Unit) = initTag(B(), init)
    fun p(init: P.() -> Unit) = initTag(P(), init)
    fun h1(init: H1.() -> Unit) = initTag(H1(), init)
    fun a(href: String, init: A.() -> Unit) {
        val a = initTag(A(), init)
        a.href = href
    }
}

class Body : BodyTag("body")
class B : BodyTag("b")
class P : BodyTag("p")
class H1 : BodyTag("h1")

class A : BodyTag("a") {
    var href: String
        get() = attributes["href"]!!
        set(value) {
            attributes["href"] = value
        }
}

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()
    html.init()
    return html
}