Spec-Zone.ru › Kotlin 2

Билдеры с проверкой типов

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

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

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

  • Настройка маршрутов для веб-сервера: Ktor

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

package html

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

            // 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"
                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
}
<html> <head> <title> HTML encoding with Kotlin </title> </head> <body> <h1> HTML encoding with Kotlin </h1> <p> this format can be used as an alternative markup to HTML </p> <a href="http://kotlinlang.org"> Kotlin </a> <p> This is some <b> mixed </b> text. For more see the <a href="http://kotlinlang.org"> Kotlin </a> project </p> <p> some text <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>

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

Предположим, что вам нужно реализовать типобезопасный билдер на 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
@Target(AnnotationTarget.CLASS)
annotation class HtmlTagMarker

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

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

  • Объявлениям типов (CLASS): классам или интерфейсам, используемым в качестве получателей DSL.

  • Использованиям типов (TYPE): типам получателей в сигнатурах типов функций.

  • Псевдонимам типов (TYPEALIAS): псевдонимам типов, раскрывающимся в типы получателей DSL.

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

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

В нашем 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
    }
    // ...
}

Аннотацию @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 {
            // Access to title, head or other functions of outer receivers is restricted here.
        }
    }
}

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

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

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

// Declares a top-level function with the same name,
// which is available through a context parameter
context(tag: HtmlTag)
fun setAttribute(name: String, value: String) { tag.setAttribute(name, value) }

fun test(head: HtmlTag, extraInfo: HtmlTag) {
    with(head) {
        // Introduces a context value of the same type in an inner scope
        context(extraInfo) {
            // Reports a warning:
            // Uses an implicit receiver shadowed by a context parameter
            setAttribute("user", "1234")

            // Calls the receiver's member explicitly
            this.setAttribute("user", "1234")

            // Calls the context declaration explicitly
            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
}
12 августа 2026 г.
Функции высшего порядка и лямбда-выраженияИспользование билдеров с выводом типов билдера

© 2010–2026 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