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