Kotlin guide done. (20.10.26)

This commit is contained in:
2026-10-20 12:54:25 +03:00
parent da3ad9dd1a
commit 6853ada348
81 changed files with 30112 additions and 0 deletions
@@ -0,0 +1,444 @@
---
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<String>()
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<Tag>()
private val attributes = mutableMapOf<String, String>()
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</$name>"
}
}
fun html(block: Tag.() -> Unit): String = Tag("html").apply(block).render()
fun main() {
val page = html {
div {
attr("class", "container")
p { +"Первый абзац" }
p { +"Второй абзац" }
}
}
println(page)
// <html><div class="container"><p>Первый абзац</p><p>Второй абзац</p></div></html>
}
```
Разбор приёмов:
- `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<String>()
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<String>()
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<Pair<String, String>>()
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<String, Int> {
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<String>()
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<String, Any>` | 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<String>()
> 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<String>()
> 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<String>()
> 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<String>()
> 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<String>()
> 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)