--- title: DSL description: Из чего собирается предметно-ориентированный язык на Kotlin и когда он оправдан tags: - kotlin - functional - dsl --- DSL (domain-specific language) на Kotlin - это обычный Kotlin-код, который выглядит как декларативное описание. Отдельного механизма для этого нет: DSL собирается из четырёх уже знакомых возможностей. | Возможность | Что даёт | |---|---| | [лямбда с получателем](Receivers%20и%20context%20parameters.md) `T.() -> Unit` | блок, внутри которого доступны методы объекта | | [лямбда последним аргументом](../Синтаксис%20и%20типы/Работа%20с%20данными/Лямбды%20и%20функциональные%20типы.md) | `html { }` вместо `html({ })` | | [функции-расширения](Функции-расширения.md) | методы только внутри DSL | | [операторы и `infix`](Инфиксные%20и%20операторные%20функции.md) | `"a" to 1`, `x shouldBe y` | ## Минимальный пример ```kotlin class Pizza { private val toppings = mutableListOf() var size = "средняя" fun topping(name: String) { toppings.add(name) } override fun toString() = "$size пицца: ${toppings.joinToString()}" } fun pizza(block: Pizza.() -> Unit): Pizza = Pizza().apply(block) fun main() { val order = pizza { size = "большая" topping("сыр") topping("грибы") } println(order) // большая пицца: сыр, грибы } ``` Три строки инфраструктуры (`fun pizza(block: ...) = Pizza().apply(block)`) превращают класс в DSL. Это весь базовый приём. ## Вложенные блоки ```kotlin @DslMarker annotation class HtmlDsl @HtmlDsl class Tag(private val name: String) { private val children = mutableListOf() private val attributes = mutableMapOf() private var text: String? = null fun attr(key: String, value: String) { attributes[key] = value } operator fun String.unaryPlus() { text = this } fun tag(name: String, block: Tag.() -> Unit = {}) { children.add(Tag(name).apply(block)) } fun div(block: Tag.() -> Unit) = tag("div", block) fun p(block: Tag.() -> Unit) = tag("p", block) fun render(): String { val attrs = attributes.entries.joinToString("") { " ${it.key}=\"${it.value}\"" } val inner = text ?: children.joinToString("") { it.render() } return "<$name$attrs>$inner" } } fun html(block: Tag.() -> Unit): String = Tag("html").apply(block).render() fun main() { val page = html { div { attr("class", "container") p { +"Первый абзац" } p { +"Второй абзац" } } } println(page) //

Первый абзац

Второй абзац

} ``` Разбор приёмов: - `Tag.() -> Unit` даёт вложенность: внутри `div { }` доступны методы `Tag` - `operator fun String.unaryPlus()` даёт синтаксис `+"текст"` для содержимого - `@DslMarker` запрещает вызвать `div { }` изнутри `div { }` через внешний получатель - значение по умолчанию `block: Tag.() -> Unit = {}` разрешает теги без тела ## Типобезопасность Главное отличие Kotlin-DSL от строковых шаблонов: ошибки видит компилятор. ```kotlin class Query { private var table: String? = null private val conditions = mutableListOf() private var limit: Int? = null fun from(name: String) { table = name } fun where(condition: String) { conditions.add(condition) } fun limit(n: Int) { limit = n } fun build(): String { val t = requireNotNull(table) { "не указана таблица" } return buildString { append("SELECT * FROM $t") if (conditions.isNotEmpty()) append(" WHERE ${conditions.joinToString(" AND ")}") limit?.let { append(" LIMIT $it") } } } } fun query(block: Query.() -> Unit): String = Query().apply(block).build() fun main() { println(query { from("users") where("age > 18") where("active = true") limit(10) }) // SELECT * FROM users WHERE age > 18 AND active = true LIMIT 10 try { query { where("x = 1") } } catch (e: IllegalArgumentException) { println(e.message) // не указана таблица } } ``` Проверка обязательных полей здесь в рантайме. Вынести её в компиляцию можно через типы-состояния: ```kotlin class EmptyQuery { fun from(table: String) = TableQuery(table) } class TableQuery(private val table: String) { private val conditions = mutableListOf() fun where(condition: String): TableQuery = apply { conditions.add(condition) } fun build(): String = "SELECT * FROM $table" + if (conditions.isEmpty()) "" else " WHERE ${conditions.joinToString(" AND ")}" } fun main() { println(EmptyQuery().from("orders").where("total > 100").build()) // SELECT * FROM orders WHERE total > 100 // println(EmptyQuery().build()) // ошибка компиляции: метода нет } ``` Такой приём (phantom types) делает невозможное состояние непредставимым, но усложняет код. Для большинства DSL достаточно `require` в `build()`. ## Коллекции внутри DSL ```kotlin class Routes { private val routes = mutableListOf>() infix fun String.handledBy(handler: String) { routes.add(this to handler) } fun group(prefix: String, block: Routes.() -> Unit) { val nested = Routes().apply(block) nested.routes.forEach { (path, handler) -> routes.add("$prefix$path" to handler) } } fun render() = routes.joinToString("\n") { "${it.first} -> ${it.second}" } } fun routes(block: Routes.() -> Unit) = Routes().apply(block).render() fun main() { println(routes { "/" handledBy "HomeController" group("/api") { "/users" handledBy "UsersController" "/orders" handledBy "OrdersController" } }) // / -> HomeController // /api/users -> UsersController // /api/orders -> OrdersController } ``` `infix` расширение внутри класса (`String.handledBy`) существует только внутри блока `routes { }` - снаружи такого метода у `String` нет. ## Готовые DSL в stdlib ```kotlin fun main() { val text = buildString { append("Привет") appendLine(", мир") append("Вторая строка") } println(text) val list = buildList { add(1) addAll(listOf(2, 3)) } println(list) // [1, 2, 3] val map = buildMap { put("a", 1) putAll(mapOf("b" to 2)) } println(map) // {a=1, b=2} } ``` Эти функции - ровно тот же приём `T.() -> Unit` + `apply`. Числовые и временные DSL: ```kotlin import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.minutes fun main() { val timeout = 2.minutes + 30.seconds println(timeout) // 2m 30s println(timeout.inWholeSeconds) // 150 println((1..10 step 2).toList()) // [1, 3, 5, 7, 9] println((10 downTo 1 step 3).toList()) // [10, 7, 4, 1] } ``` `2.minutes` - свойство-расширение на `Int`. Такой «единичный» DSL делается одной строкой и заметно улучшает читаемость конфигураций. ## Известные DSL в экосистеме | DSL | Для чего | |---|---| | Gradle Kotlin DSL | сборка проекта, `build.gradle.kts` | | Ktor | маршруты и конфигурация сервера | | kotlinx.html | генерация HTML | | Exposed | SQL-запросы с типобезопасностью | | Kotest | описание тестов и матчеры | | Compose | описание UI | Пример в стиле Gradle - разбор см. [Gradle и Kotlin DSL](../Инструментарий/Gradle%20и%20Kotlin%20DSL.md): ```kotlin class Dependencies { private val list = mutableListOf() fun implementation(dep: String) { list.add("implementation $dep") } fun testImplementation(dep: String) { list.add("testImplementation $dep") } fun render() = list.joinToString("\n") } class Project2 { private var name = "unnamed" private var deps = Dependencies() fun name(value: String) { name = value } fun dependencies(block: Dependencies.() -> Unit) { deps = Dependencies().apply(block) } fun render() = "project $name\n${deps.render()}" } fun project(block: Project2.() -> Unit) = Project2().apply(block).render() fun main() { println(project { name("demo") dependencies { implementation("kotlin-stdlib") testImplementation("kotest") } }) // project demo // implementation kotlin-stdlib // testImplementation kotest } ``` ## Когда DSL оправдан | Признак | Вывод | |---|---| | структура описывается многократно, вид стабилен | DSL уместен | | вложенная иерархия (UI, конфиг, разметка) | DSL уместен | | писать будут люди, не знающие внутренностей | DSL уместен | | альтернатива - длинный конструктор или `Map` | DSL уместен | | одно-два места использования | обычный код или `data class` | | нужна сериализация/валидация схемы | формат данных (JSON/YAML) + парсер | | структура меняется каждую неделю | DSL придётся переписывать | Главная цена DSL - его нужно изучать. Для внутреннего инструмента, который используют трое, именованные аргументы `data class` часто выигрывают: ```kotlin data class ServerConfig( val host: String = "localhost", val port: Int = 8080, val tls: Boolean = false, val timeoutSeconds: Int = 30, ) fun main() { val config = ServerConfig(host = "example.com", port = 443, tls = true) println(config) // ServerConfig(host=example.com, port=443, tls=true, timeoutSeconds=30) } ``` Это читается не хуже DSL, проверяется компилятором лучше, бесплатно получает `copy`, `equals` и сериализацию. DSL нужен, когда структура **вложенная** и **повторяющаяся** - там, где `data class` требует громоздких вложенных конструкторов. > [!info] Отличия от Java > | Java | Kotlin | > |---|---| > | fluent builder с `return this` | `T.() -> Unit` | > | XML/YAML/properties для конфигурации | типобезопасный DSL | > | анонимный класс с методами | лямбда с получателем | > | строковая конкатенация SQL | DSL с проверкой типов | > | нет аналога | `@DslMarker` | > > **В Java DSL ограничен цепочками методов.** Максимум, что доступно - fluent interface: > ```java > Html.create() > .div().attr("class", "container") > .p().text("абзац").end() > .end() > .render(); > ``` > Вложенность приходится выражать парами `.div()`/`.end()`, и компилятор не проверит их баланс. В Kotlin вложенность выражается вложенностью блоков - забыть «закрывающий тег» физически невозможно. > > **Это причина, по которой Gradle перешёл на Kotlin.** `build.gradle` на Groovy был динамическим: опечатка в имени свойства обнаруживалась при запуске сборки, автодополнения не было. `build.gradle.kts` - обычный Kotlin-код, поэтому IDE знает все доступные методы, а ошибка в имени не компилируется. Цена - более медленная первая сборка (скрипт нужно скомпилировать). > > **Конфигурация в коде вместо формата данных.** В Java-мире окружение описывают в XML (Spring, Maven) или YAML (Spring Boot). Kotlin-DSL даёт проверку типов и переиспользование (функции, циклы, условия прямо в конфиге), но теряет главное свойство данных: конфигурацию нельзя прочитать и изменить без компилятора. Для конфигурации, которую правят администраторы, формат данных остаётся правильным выбором. > > **Лямбда с получателем - ключевая возможность, которой нет в Java.** Без неё все остальные приёмы (операторы, расширения) не складываются в DSL, поэтому в Java аналога попросту не существует. > > Сравнить: [Builder](../../Программирование%20на%20Java/Паттерны%20проектирования/Builder.md), [Интерфейсы в Java](../../Программирование%20на%20Java/ООП/Интерфейсы.md) > [!warning] Подводные камни > **Без `@DslMarker` внешний получатель виден изнутри:** > ```kotlin > class Node { > val children = mutableListOf() > fun child(name: String) { children.add(name) } > fun nested(block: Node.() -> Unit) { children.add(Node().apply(block).children.toString()) } > } > > fun main() { > val n = Node().apply { > child("a") > nested { child("b") } > } > println(n.children) // [a, [b]] > } > ``` > Здесь всё верно, но стоит добавить метод, которого нет у внутреннего уровня, и вызов молча уйдёт на внешний. `@DslMarker` стоит ставить сразу. > > **DSL скрывает порядок выполнения:** > ```kotlin > class Steps { > private val log = mutableListOf() > fun step(name: String) { log.add(name) } > fun render() = log.joinToString(" -> ") > } > > fun main() { > val s = Steps().apply { > step("первый") > step("второй") > } > println(s.render()) // первый -> второй > } > ``` > Блок выглядит декларативно, но это императивный код: вызовы выполняются по порядку, и переставить строки - значит изменить результат. Пользователь DSL об этом не догадывается. > > **Изменяемое состояние в билдере переиспользуется:** > ```kotlin > class Builder2 { > private val items = mutableListOf() > fun item(x: String) { items.add(x) } > fun build() = items.toList() > } > > fun main() { > val b = Builder2() > b.item("a") > val first = b.build() > b.item("b") > val second = b.build() > println("$first / $second") // [a] / [a, b] > } > ``` > Если билдер доступен снаружи, повторный `build()` даёт другой результат. Делайте билдер одноразовым: создавайте его внутри функции-входа и не возвращайте. > > **`var` в DSL без валидации допускает некорректное состояние:** > ```kotlin > class Window { > var width = 0 > var height = 0 > fun area() = width * height > } > > fun window(block: Window.() -> Unit) = Window().apply(block) > > fun main() { > println(window { width = -5; height = 10 }.area()) // -50 > } > ``` > Нужна проверка в `build()`/`apply` или сеттер с `require`. > > **Вложенные лямбды с получателем трудно отлаживать.** Стек вызовов состоит из `invoke`-методов сгенерированных лямбд, а точка останова внутри блока не показывает, какому уровню она принадлежит. Для глубоких DSL это заметная проблема. > > **DSL без документации непригоден.** Пользователь видит `pizza { }` и не знает, какие методы доступны - IDE подскажет, но ревью в браузере и примеры в README необходимы. Это основной скрытый расход на DSL. > > **Методы DSL попадают в автодополнение не там, где ожидалось.** Если расширение объявлено на верхнем уровне (а не внутри класса DSL), оно появится везде: > ```kotlin > // плохо: operator fun String.unaryPlus() на верхнем уровне сломает код, > // где +"строка" означает что-то другое > class Container2 { > private val parts = mutableListOf() > operator fun String.unaryPlus() { parts.add(this) } // только внутри Container2 > fun render() = parts.joinToString() > } > > fun container(block: Container2.() -> Unit) = Container2().apply(block).render() > > fun main() { println(container { +"a"; +"b" }) } // a, b > ``` > Объявляйте операторы DSL **внутри** класса-контекста. > > **`apply` возвращает билдер, а не результат:** > ```kotlin > class Doc { > private val lines = mutableListOf() > fun line(x: String) { lines.add(x) } > fun render() = lines.joinToString("\n") > } > > fun docWrong(block: Doc.() -> Unit) = Doc().apply(block) // возвращает Doc > fun docRight(block: Doc.() -> Unit) = Doc().apply(block).render() // возвращает String > > fun main() { > println(docWrong { line("a") }::class.simpleName) // Doc > println(docRight { line("a") }) // a > } > ``` > Утечка билдера наружу позволяет менять «готовый» объект после сборки. > > **DSL - не замена валидации данных.** Типобезопасность проверяет структуру, а не смысл: `port = 70000` скомпилируется. Проверки диапазонов, обязательных полей и взаимных зависимостей нужно писать вручную. Подробнее: [Проверки и контракты](../Исключения/Проверки%20и%20контракты.md) > > **DSL тяжело версионировать.** Переименование метода - несовместимое изменение для всех пользователей, а `@Deprecated` на методах внутри класса-контекста работает, но предупреждения в больших блоках теряются. Далее: [Паттерны проектирования](Паттерны%20проектирования.md)