DSL на Kotlin: как создать свой язык программирования за вечер

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Что такое DSL и зачем он вам нужен?

Представьте, что вы пишете не код, а инструкцию на понятном языке, которая сама превращается в работающую программу. Именно так работают предметно-ориентированные языки (DSL — Domain Specific Language). В отличие от универсальных языков (Java, Python, C#), DSL создаётся для решения одной конкретной задачи — будь то конфигурация сервера, описание бизнес-правил или построение сложных SQL-запросов.

Kotlin — идеальный кандидат для создания DSL. Благодаря лямбдам с ресивером, расширениям и type-safe builders, вы можете писать код, который выглядит как родной синтаксис. В этой статье мы разберём не просто теорию, а создадим реальный DSL для тестового фреймворка — и вы увидите, насколько это просто и увлекательно.

Почему Kotlin — король DSL?

Kotlin завоевал любовь разработчиков не только из-за совместимости с Java. Его синтаксические возможности позволяют создавать внутренние DSL — языки, которые живут внутри хост-языка и используют его синтаксис. Вот три кита, на которых держится любой Kotlin DSL:

  • Лямбды с ресивером — позволяют обращаться к методам объекта без явного указания имени. Это основа builder-паттерна.
  • Расширения — добавляют новые функции к любому классу, даже если вы не можете его изменить.
  • Инфиксные функции — позволяют писать код в стиле естественного языка: should be equalTo 5.

Эти возможности превращают написание DSL в конструктор Lego: вы берёте готовые блоки — лямбды, функции, свойства — и собираете из них понятный и безопасный язык.

Создаём простой DSL для тестов: шаг за шагом

Давайте напишем DSL для написания тестов. Цель — чтобы тесты выглядели как читаемое описание, а не как набор вызовов assert:

test("Калькулятор должен складывать числа") {
    val result = Calculator.add(2, 3)
    result should beEqualTo 5
    result shouldNot beNull
}

Согласитесь, это почти английский язык. Теперь разберём, как это работает.

Шаг 1. Создаём контекст теста

Нам нужна функция test, которая принимает имя и блок кода. Блок выполняется в контексте, где доступны should и shouldNot:

class TestContext {
    fun Any.should(matcher: Matcher) {
        if (!matcher.matches(this)) {
            throw AssertionError("Expected ${matcher.expected}, but got $this")
        }
    }

    fun Any.shouldNot(matcher: Matcher) {
        if (matcher.matches(this)) {
            throw AssertionError("Expected not ${matcher.expected}, but got $this")
        }
    }
}

fun test(name: String, block: TestContext.() -> Unit) {
    println("Running test: $name")
    try {
        TestContext().block()
        println("✓ Passed")
    } catch (e: AssertionError) {
        println("✗ Failed: ${e.message}")
    }
}

Ключевой момент: TestContext.() -> Unit — это лямбда с ресивером. Внутри блока this ссылается на объект TestContext, поэтому методы should и shouldNot становятся доступны неявно.

Шаг 2. Реализуем матчеры

Теперь создадим простые матчеры. beEqualTo и beNull — это функции, возвращающие объекты Matcher:

interface Matcher {
    val expected: String
    fun matches(value: Any?): Boolean
}

fun beEqualTo(expected: Any) = object : Matcher {
    override val expected = "equal to $expected"
    override fun matches(value: Any?) = value == expected
}

val beNull = object : Matcher {
    override val expected = "null"
    override fun matches(value: Any?) = value == null
}

Обратите внимание: beEqualTo — это обычная функция, но благодаря инфиксному стилю (result should beEqualTo 5) она выглядит как часть языка. Инфиксной функцией является should, потому что она объявлена с модификатором infix.

Шаг 3. Добавляем вложенные блоки (скоуп-контроль)

Настоящая мощь DSL раскрывается, когда нужно создавать вложенные структуры. Добавим поддержку групп тестов:

class GroupContext {
    private val tests = mutableListOf<Pair<String, TestContext.() -> Unit>>()

    fun test(name: String, block: TestContext.() -> Unit) {
        tests.add(name to block)
    }

    fun runAll() {
        tests.forEach { (name, block) ->
            test(name, block)
        }
    }
}

fun describe(name: String, block: GroupContext.() -> Unit) {
    println("\n=== $name ===")
    val group = GroupContext()
    group.block()
    group.runAll()
}

Теперь можно писать:

describe("Калькулятор") {
    test("сложение") {
        Calculator.add(2, 3) should beEqualTo 5
    }
    test("вычитание") {
        Calculator.subtract(5, 2) should beEqualTo 3
    }
}

Каждый блок describe создаёт свой контекст, где функция test доступна только внутри группы. Это называется скоуп-контролем — вы не сможете случайно вызвать test вне describe.

Продвинутые техники: делаем DSL ещё круче

1. Расширения для существующих типов

Допустим, вы хотите, чтобы String имела метод shouldStartWith. Просто добавьте расширение:

infix fun String.shouldStartWith(prefix: String) {
    if (!this.startsWith(prefix)) {
        throw AssertionError("'$this' should start with '$prefix'")
    }
}

// Использование:
"Hello World" shouldStartWith "Hello"

2. Type-safe builders для сложных структур

Kotlin поддерживает type-safe builders — это когда компилятор проверяет, что вы используете правильные типы в правильном порядке. Пример — построение HTML:

fun html(block: HTML.() -> Unit): HTML {
    val html = HTML()
    html.block()
    return html
}

class HTML {
    private val children = mutableListOf<String>()
    fun body(block: BODY.() -> Unit) {
        val body = BODY()
        body.block()
        children.add("<body>${body.render()}</body>")
    }
    fun render() = "<html>${children.joinToString("")}</html>"
}

class BODY {
    private val elements = mutableListOf<String>()
    fun h1(text: String) = elements.add("<h1>$text</h1>")
    fun p(text: String) = elements.add("<p>$text</p>                

Блоги

Рекомендаций по книгам