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