Spec-Zone.ru › Kotlin 1.4

Безопасные для типа билдеры

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

Безопасные для типа билдеры позволяют создавать языки предметной области (DSL) на Kotlin, подходящие для построения сложных иерархических структур данных полудекларативным способом. Некоторые примеры вариантов использования билдеров:

  • Генерация разметки с кодом Kotlin, например, HTML или XML;
  • Программное размещение элементов пользовательского интерфейса: Anko
  • Настройка маршрутов для веб-сервера: Ktor.

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

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

import com.example.html.* // see declarations below

fun result() =
    html {
        head {
            title {+"XML encoding with Kotlin"}
        }
        body {
            h1 {+"XML encoding with Kotlin"}
            p  {+"this format can be used as an alternative markup to XML"}

            // an element with attributes and text content
            a(href = "http://kotlinlang.org") {+"Kotlin"}

            // mixed content
            p {
                +"This is some"
                b {+"mixed"}
                +"text. For more see the"
                a(href = "http://kotlinlang.org") {+"Kotlin"}
                +"project"
            }
            p {+"some text"}

            // content generated by
            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)

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

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

html {
    head {
        title {+"XML encoding with Kotlin"}
    }
    // ...
}

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

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

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

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

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

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

html {
    head {
        head {} // should be forbidden
    }
    // ...
}

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

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

Чтобы заставить компилятор управлять областями видимости, достаточно добавить тот же маркер аннотации к типам всех получателей, используемых в DSL. Например, для HTML-билдеров мы объявляем аннотацию @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 { } // error: a member of outer receiver
    }
    // ...
}

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

html {
    head {
        this@html.head { } // possible
    }
    // ...
}

Полное определение пакета 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
}

© 2010–2020 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/reference/type-safe-builders.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API