Spec-Zone.ru › Kotlin 1.9

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

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

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

  • Генерация разметки с кодом Kotlin, например, HTML или XML

  • Конфигурирование маршрутов для веб-сервера: 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 = "https://kotlinlang.org") {+"Kotlin"}

            // mixed content
            p {
                +"This is some"
                b {+"mixed"}
                +"text. For more see the"
                a(href = "https://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 (a получатель) в функцию, и внутри функции вы можете вызывать члены этого экземпляра.

К получателю можно получить доступ через ключевое слово 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

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

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

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

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

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

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
}
Последнее изменение: 02 февраля 2022 г.
Перегрузка операторов Использование билдеров с выводом типа билдера

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

Spec-Zone.ru

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