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

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

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

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

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

package html

fun main() {
    //sampleStart
    val result = html {
        head {
            title { +"HTML-кодирование с Kotlin" }
        }
        body {
            h1 { +"HTML-кодирование с Kotlin" }
            p {
                +"этот формат можно использовать как"
                +"альтернативную разметку для HTML"
            }

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

            // Смешанное содержимое
            p {
                +"Это немного"
                b { +"смешанного" }
                +"текста. Подробнее см. в"
                a(href = "http://kotlinlang.org") {
                    +"Kotlin"
                }
                +"проекте"
            }
            p {
                +"немного текста"
                ul {
                    for (i in 1..5)
                        li { +"${i}*2 = ${i*2}" }
                }
            }
        }
    }
    //sampleEnd
    println(result)
}

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 ul(init: UL.() -> Unit) = initTag(UL(), init)
    fun a(href: String, init: A.() -> Unit) {
        val a = initTag(A(), init)
        a.href = href
    }
}

class Body() : BodyTag("body")
class UL() : BodyTag("ul") {
    fun li(init: LI.() -> Unit) = initTag(LI(), init)
}

class B() : BodyTag("b")
class LI() : BodyTag("li")
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
}

{kotlin-runnable=“true” kotlin-min-compiler-version=“1.3” id=“kotlin-type-safe-builders”}

<html>
  <head>
    <title>
      HTML-кодирование с Kotlin
    </title>
  </head>
  <body>
    <h1>
      HTML-кодирование с Kotlin
    </h1>
    <p>
      этот формат можно использовать как
      альтернативную разметку для HTML
    </p>
    <a href="http://kotlinlang.org">
      Kotlin
    </a>
    <p>
      Это немного
      <b>
        смешанного
      </b>
      текста. Подробнее см. в
      <a href="http://kotlinlang.org">
        Kotlin
      </a>
      проекте
    </p>
    <p>
      немного текста
      <ul>
        <li>
          1*2 = 2
        </li>
        <li>
          2*2 = 4
        </li>
        <li>
          3*2 = 6
        </li>
        <li>
          4*2 = 8
        </li>
        <li>
          5*2 = 10
        </li>
      </ul>
    </p>
  </body>
</html>

{collapsible=“true” collapsed-title=“Пример вывода”}

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

Предположим, что вам нужно реализовать типобезопасный строитель на 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 {} // должно быть запрещено
    }
    // ...
}

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

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

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

@DslMarker
@Target(AnnotationTarget.CLASS)
annotation class HtmlTagMarker

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

Аннотация @Target ограничивает места, где можно применять @HtmlTagMarker. DSL-маркеры влияют на контроль области видимости только при применении к:

  • объявлениям типов (CLASS): классам или интерфейсам, используемым как DSL-приёмники;
  • использованиям типов (TYPE): типам приёмников в сигнатурах функциональных типов;
  • псевдонимам типов (TYPEALIAS): псевдонимам типов, которые разворачиваются в типы DSL-приёмников.

Применение DSL-маркера к другим целям, например функциям или свойствам, не влияет на контроль области видимости.

Подробнее о том, как работает DSL-маркер, см. в соответствующем KEEP-документе.

{style=“note”}

В нашем 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 {
        [email protected] { } // возможно
    }
    // ...
}

Аннотацию @DslMarker также можно применять напрямую к функциональным типам. Для этого нужно добавить AnnotationTarget.TYPE в цели аннотации:

@DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
annotation class HtmlTagMarker

В результате аннотацию @DslMarker можно применять к функциональным типам, чаще всего к лямбдам с приёмниками. Например:

fun html(init: @HtmlTagMarker HTML.() -> Unit): HTML { ... }

fun HTML.head(init: @HtmlTagMarker Head.() -> Unit): Head { ... }

fun Head.title(init: @HtmlTagMarker Title.() -> Unit): Title { ... }

При вызове таких функций аннотация @DslMarker ограничивает доступ к внешним приёмникам в теле помеченной ей лямбды, если эти приёмники не указаны явно:

html {
    head {
        title {
            // Доступ к title, head и другим функциям внешних приёмников здесь ограничен.
        }
    }
}

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

Когда член неявного приёмника и объявление из контекстного параметра с одним и тем же именем находятся в одной области видимости, компилятор сообщает предупреждение, потому что неявный приёмник скрыт контекстным параметром. Чтобы решить проблему, используйте квалификатор this для явного вызова приёмника или contextOf<T>() для вызова контекстного объявления:

interface HtmlTag {
    fun setAttribute(name: String, value: String)
}

// Объявляет функцию верхнего уровня с таким же именем,
// которая доступна через контекстный параметр
context(tag: HtmlTag)
fun setAttribute(name: String, value: String) { tag.setAttribute(name, value) }

fun test(head: HtmlTag, extraInfo: HtmlTag) {
    with(head) {
        // Вводит контекстное значение того же типа во внутренней области видимости
        context(extraInfo) {
            // Сообщает предупреждение:
            // Использует неявный приёмник, скрытый контекстным параметром
            setAttribute("user", "1234")

            // Явно вызывает член приёмника
            this.setAttribute("user", "1234")

            // Явно вызывает контекстное объявление
            contextOf<HtmlTag>().setAttribute("user", "1234")
        }
    }
}

Полное определение пакета 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
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
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
}

См. также: выражения this, использование строителей с выводом типов строителей.