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

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

Типобезопасные строители позволяют на основе Kotlin создавать доменно-ориентированные языки (ориг.: domain-specific languages, DSL), подходящие для построения сложных иерархических структур данных полу-декларативным способом. Примерами использования строителей являются:

  • Создание разметки с помощью кода Kotlin, например HTML или XML,
  • Настройка маршрутов для веб-сервера: Ktor.

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

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

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

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

            // смешанный контент
            p {
                +"Немного"
                b {+"смешанного"}
                +"текста. Посмотрите наш"
                a(href = "https://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).

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

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

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

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

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

html {
    head {
        head {} // такое не должно происходить
    }
    // ...
}

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

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

Чтобы заставить компилятор запускать контрольные области, нам нужно только аннотировать типы всех получателей, используемых в 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 дерево и активно использует расширения и лямбды с приёмниками.

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
}