Spec-Zone.ru › Kotlin 1.8

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

Используя функции с осмысленными именами в качестве билдеров в сочетании с лямбда-выражениями с получателем, можно создавать безопасные для типа, статически типизированные билдеры на 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 (получатель) в функцию, и вы можете вызывать члены этого экземпляра внутри функции.

К получателю можно получить доступ с помощью ключевого слова 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
}
Последнее изменение: 10 января 2023 г.
Перегрузка операторов Использование билдеров с выводом типа билдера

© 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