Безопасные для типов билдеры
Используя именованные функции в качестве билдеров в сочетании с лямбда-выражениями с получателем, можно создавать безопасные для типов статически типизированные билдеры на 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
}
© 2010–2022 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