type | layout | category | title |
---|---|---|---|
doc |
reference |
Syntax |
Типобезопасные строители |
Идея строителей (builders) довольна популярна в сообществе Groovy. Строители позволяют объявлять данные в полудекларативном виде. Строители хороши для генерации XML, вёрстки компонентов UI, описания 3D сцен и многого другого...
В отличие от Groovy, Kotlin проверяет типы строителей, что делает их более приятными в использовании в большинстве юзкейсов.
Для прочих случаев Kotlin поддерживает Динамически типизированные строители (Dynamic types builders).
Рассмотрим следующий код:
import com.example.html.* // смотрите объявления ниже
fun result(args: Array<String>) =
html {
head {
title {+"XML кодирование с Kotlin"}
}
body {
h1 {+"XML кодирование с Kotlin"}
p {+"этот формат может быть использован как альтернатва XML"}
// элемент с атрибутом и текстовым содержанием
a(href = "http://kotlinlang.ru") {+"Kotlin"}
// смешанный контент
p {
+"Немного"
b {+"смешанного"}
+"текста. Посмотрите наш"
a(href = "http://kotlinlang.org") {+"перевод"}
+"документации Kotlin."
}
p {+"немного текста"}
// контент генерируется в цикле
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)
И мы можем использовать их для постройки тэгов <html>
и <body>
.
Ещё одна вещь, которую следует обсудить, это добавление текста в тело тэга. В примере выше мы используем такой синтаксис:
html {
head {
title {+"XML кодирование с Kotlin"}
}
// ...
}
Итак, мы просто добавляем строку в тело тэга, приписав +
перед текстом, что ведёт к вызову префиксной операции unaryPlus()
.
Эта операция определена с помощью функции-расширения unaryPlus()
,
которая является членом абстрактного класса TagWithText
(родителя Title
).
fun String.unaryPlus() {
children.add(TextElement(this))
}
Иными словами, префикс +
оборачивает строку в экземпляр TextElement
и добавляет его в коллекцию children
.
Всё это определено в пакете com.example.html
, который импортирован в начале примера выше.
В последней секции вы можете прочитать полное описание определений в этом пакете.
При использовании DSL
может возникнуть проблема, когда слишком много функций могут быть вызваны в определённом контексте.
Мы можем вызывать методы каждого неявного приёмника внутри лямбды, и из-за этого может возникать противоречивый результат, как, например, тэг head
внутри другого тэга head
:
html {
head {
head {} // должен быть запрещён
}
// ...
}
В этом примере должны быть доступны только члены ближайшего неявного приёмника this@head
; head()
является членом другого приёмника — this@html
, поэтому его вызов в другом контексте должен быть запрещён.
Для решения этой проблемы в Kotlin 1.1 был введен специальный механизм для управления областью приёмника.
Чтобы заставить компилятор запускать контрольные области, нам нужно только аннотировать типы всех получателей, используемых в DSL, той же маркерной аннотацией.
Например, для HTML Builders мы объявляем аннотацию @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 { } // ошибка: член внешнего приёмника
}
// ...
}
Обратите внимание, что всё ещё возможно вызывать члены внешнего приёмника, но для этого вам нужно указать этот приёмник явно:
html {
head {
this@html.head { } // всё работает
}
// ...
}
Перед вами содержание пакета com.example.html
(представлены только элементы, использованные в примере выше).
Он строит HTML дерево и активно использует расширения и лямбды с приёмниками.
Примечание: Аннотация @DslMarker доступна в Kotlin начиная с версии 1.1
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
}