21 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Receivers и context parameters | Лямбды с получателем, несколько получателей в области видимости, DslMarker и context parameters вместо context receivers |
|
Получатель (receiver) - это объект, доступный в блоке кода как this без явного указания. На этом механизме построены расширения, scope-функции, билдеры и все DSL в Kotlin.
fun StringBuilder.addLine(text: String) {
append(text) // this.append — this есть неявно
append("\n")
}
fun main() {
val sb = StringBuilder()
sb.addLine("первая")
sb.addLine("вторая")
print(sb)
// первая
// вторая
}
Функциональный тип с получателем
Тип T.() -> R - это функция, внутри которой this имеет тип T:
fun buildText(block: StringBuilder.() -> Unit): String {
val sb = StringBuilder()
sb.block() // вызываем лямбду с sb как получателем
return sb.toString()
}
fun main() {
val text = buildText {
append("Привет") // this == StringBuilder
append(", ")
append("мир")
}
println(text) // Привет, мир
}
Разница с обычным (StringBuilder) -> Unit только в том, как обращаться к объекту:
fun withReceiver(block: StringBuilder.() -> Unit) = StringBuilder().apply(block).toString()
fun withParameter(block: (StringBuilder) -> Unit) = StringBuilder().also(block).toString()
fun main() {
println(withReceiver { append("через this") }) // через this
println(withParameter { it.append("через it") }) // через it
}
Типы взаимозаменяемы при передаче: лямбда с получателем подходит там, где ожидается функция с параметром, и наоборот - компилятор конвертирует их сам.
Так устроены все scope-функции:
// В stdlib:
// inline fun <T, R> T.run(block: T.() -> R): R = block()
// inline fun <T> T.apply(block: T.() -> Unit): T { block(); return this }
// inline fun <T, R> with(receiver: T, block: T.() -> R): R = receiver.block()
// inline fun <T, R> T.let(block: (T) -> R): R = block(this)
// inline fun <T> T.also(block: (T) -> Unit): T { block(this); return this }
run/apply/with принимают T.() -> R, let/also - (T) -> R. Вот и всё различие между this и it в scope-функциях.
Два получателя одновременно
Расширение, объявленное внутри класса, имеет два получателя: экземпляр класса (dispatch receiver) и объект расширения (extension receiver).
class HtmlBuilder {
private val sb = StringBuilder()
fun String.tag(name: String) { // this: String, this@HtmlBuilder: HtmlBuilder
sb.append("<$name>").append(this).append("</$name>")
}
fun build(block: HtmlBuilder.() -> Unit): String {
block()
return sb.toString()
}
}
fun main() {
val html = HtmlBuilder().build {
"Заголовок".tag("h1")
"Текст".tag("p")
}
println(html) // <h1>Заголовок</h1><p>Текст</p>
}
Внутри tag доступны оба: this (или this@tag) - строка, this@HtmlBuilder - билдер. Приоритет у ближайшего, то есть у extension receiver.
Квалифицированный this
Когда получателей несколько, их различают метками:
class Outer2 {
val name = "внешний"
inner class Inner3 {
val name = "внутренний"
fun show(): String {
val local = "локальный"
return "$name / ${this@Inner3.name} / ${this@Outer2.name} / $local"
}
}
}
fun main() {
println(Outer2().Inner3().show())
// внутренний / внутренний / внешний / локальный
}
То же в лямбдах с получателем:
fun main() {
val result = StringBuilder().apply {
append("a")
"b".run {
this@apply.append(this) // this — String "b", this@apply — StringBuilder
this@apply.append("c")
}
}
println(result) // abc
}
Метка берётся из имени функции, принимающей лямбду. Поэтому для двух вложенных run метка this@run указала бы на внутренний - ближайший одноимённый. Когда уровней больше одного, выбирайте разные scope-функции или явные имена параметров; если имени нет (анонимная функция), доступ только через параметры.
Вложенные билдеры и DslMarker
Проблема вложенных получателей: внутренний блок видит и внешний.
class Table {
private val rows = mutableListOf<String>()
fun row(block: Row.() -> Unit) { rows.add(Row().apply(block).render()) }
fun render() = rows.joinToString("\n")
}
class Row {
private val cells = mutableListOf<String>()
fun cell(text: String) { cells.add(text) }
fun render() = cells.joinToString(" | ")
}
fun table(block: Table.() -> Unit) = Table().apply(block).render()
fun main() {
println(table {
row {
cell("a")
cell("b")
// row { } // компилируется! внешний получатель видим
}
row { cell("c") }
})
// a | b
// c
}
Вложенный row { } внутри row - логическая ошибка, но компилятор её не видит. Решение - аннотация @DslMarker:
@DslMarker
annotation class TableDsl
@TableDsl
class Table2 {
private val rows = mutableListOf<String>()
fun row(block: Row2.() -> Unit) { rows.add(Row2().apply(block).render()) }
fun render() = rows.joinToString("\n")
}
@TableDsl
class Row2 {
private val cells = mutableListOf<String>()
fun cell(text: String) { cells.add(text) }
fun render() = cells.joinToString(" | ")
}
fun table2(block: Table2.() -> Unit) = Table2().apply(block).render()
fun main() {
println(table2 {
row {
cell("a")
// row { } // теперь ошибка компиляции
}
})
// a
}
@DslMarker запрещает неявный доступ к внешнему получателю того же «семейства». Явный this@table2.row { } при этом остаётся доступен. Подробнее: DSL
Получатель в расширении для DSL
Полезный приём - расширение, доступное только внутри определённого контекста:
class Validator {
private val errors = mutableListOf<String>()
fun String.required(field: String) {
if (isBlank()) errors.add("$field обязателен")
}
fun String.maxLength(field: String, max: Int) {
if (length > max) errors.add("$field длиннее $max")
}
fun result(): List<String> = errors
}
fun validate(block: Validator.() -> Unit): List<String> = Validator().apply(block).result()
fun main() {
val errors = validate {
"".required("имя")
"очень длинное значение".maxLength("описание", 5)
"ок".required("статус")
}
println(errors) // [имя обязателен, описание длиннее 5]
}
required существует только внутри validate { } - снаружи такого расширения на String нет. Это способ не засорять глобальное пространство имён.
Context parameters
Начиная с Kotlin 2.2 (стабилизированы в 2.4) есть контекстные параметры - неявные зависимости, которые передаются по области видимости, а не через аргументы. Они заменили экспериментальные context receivers.
interface Logger2 {
fun log(message: String)
}
context(logger: Logger2)
fun processOrder(id: Int) {
logger.log("обработка заказа $id")
logger.log("заказ $id готов")
}
class ConsoleLogger : Logger2 {
override fun log(message: String) = println("[LOG] $message")
}
fun main() {
with(ConsoleLogger()) {
processOrder(42)
}
// [LOG] обработка заказа 42
// [LOG] заказ 42 готов
}
Отличия от получателя:
- контекстный параметр именованный (
logger), а не безымянныйthis - он не участвует в разрешении вызовов:
log("x")безlogger.не скомпилируется - таких параметров может быть несколько
- он не делает функцию расширением - вызов остаётся
processOrder(42)
interface Transaction { fun commit() }
interface Metrics { fun count(name: String) }
context(tx: Transaction, metrics: Metrics)
fun saveUser(name: String) {
metrics.count("user.save")
println("сохранён $name")
tx.commit()
}
fun main() {
val tx = object : Transaction { override fun commit() = println("транзакция закрыта") }
val metrics = object : Metrics { override fun count(name: String) = println("метрика: $name") }
with(tx) {
with(metrics) {
saveUser("Иван")
}
}
// метрика: user.save
// сохранён Иван
// транзакция закрыта
}
Задача, которую это решает: передать «окружение» (логгер, транзакцию, локаль, конфигурацию) через несколько уровней вызовов, не протаскивая параметр руками и не используя глобальное состояние.
Контекстный параметр можно объявить и у свойства:
interface Locale2 { val code: String }
context(locale: Locale2)
val greeting: String
get() = if (locale.code == "ru") "Привет" else "Hello"
fun main() {
val ru = object : Locale2 { override val code = "ru" }
val en = object : Locale2 { override val code = "en" }
println(with(ru) { greeting }) // Привет
println(with(en) { greeting }) // Hello
}
| Параметр | Получатель (T.()) |
Контекстный параметр | |
|---|---|---|---|
| вызов | f(x, dep) |
x.f() |
f(x), зависимость из области |
| доступ внутри | по имени | this, неявно |
по имени |
| сколько | сколько угодно | один | несколько |
| влияет на разрешение вызовов | нет | да | нет |
| видно в сигнатуре вызова | да | да | нет |
Практическое правило: получатель - когда объект главный в операции (sb.append), контекстный параметр - когда это служебная зависимость (логгер, транзакция), обычный параметр - когда это данные.
[!info] Отличия от Java
Java Kotlin thisтолько в методах своего классаthisв любой лямбде с получателембилдер с return thisT.() -> Unitпередача зависимостей аргументами или DI-контейнером контекстные параметры ThreadLocalдля «окружения»контекстные параметры нет аналога @DslMarkerЛямбда с получателем аналога в Java не имеет. Это и есть причина, по которой DSL на Kotlin (Gradle Kotlin DSL, HTML-билдеры, конфигурация Ktor) выглядят как отдельный язык, а в Java ограничиваются цепочками методов:
new StringBuilder().append("a").append("b").toString();В Kotlin блок кода сам становится областью, где доступны методы объекта - без повторения имени переменной и без
return thisв каждом методе класса.Контекстные параметры решают задачу, для которой в Java используют
ThreadLocalили DI. Типичный Java-подход к «окружению» - либо протащить параметр через все слои, либо положить его вThreadLocal(как делают Spring-транзакции и MDC в логировании), либо внедрить через контейнер. Первый вариант шумный, второй - неявное глобальное состояние, ломающееся при переходе между потоками, третий требует фреймворка. Контекстный параметр проверяется компилятором: функция не вызовется без нужного контекста:interface Tenant { val id: String } context(tenant: Tenant) fun currentTenantId(): String = tenant.id fun main() { val t = object : Tenant { override val id = "acme" } println(with(t) { currentTenantId() }) // acme // println(currentTenantId()) // ошибка компиляции: нет контекста }Для корутин у той же задачи есть другое решение -
CoroutineContext. Подробнее: Диспетчеры и контекстКвалифицированный
this@Labelобобщает Java-овскийOuter.this. В JavaOuter.thisработает только для вложенных классов; в Kotlin метки действуют и для лямбд, и для расширений, поэтому вложенные области разрешаются одним механизмом.Сравнить: Вложенные классы в Java, Builder
[!warning] Подводные камни Внутренний получатель затеняет внешний:
fun main() { val result = StringBuilder("внешний").run { StringBuilder("внутренний").run { toString() // внутренний — не тот, что ожидали? } } println(result) // внутренний }При вложенности больше одного уровня используйте
this@labelили обычные переменные.Без
@DslMarkerвложенные билдеры принимают неверный код:class Menu { private val items = mutableListOf<String>() fun item(name: String) { items.add(name) } fun submenu(block: Menu.() -> Unit) { items.add("[${Menu().apply(block).items.joinToString()}]") } fun render() = items.joinToString(", ") } fun main() { val m = Menu().apply { item("файл") submenu { item("открыть") } } println(m.render()) // файл, [открыть] }Здесь
itemвнутриsubmenuпопадает во внутреннее меню - как и задумано. Но если имена методов у разных уровней совпадают лишь частично, вызов молча уйдёт не туда, куда ожидал автор.Получатель и параметр с одинаковым именем:
class Box9(val value: Int) fun Box9.compare(value: Int) = "${this.value} vs $value" fun main() { println(Box9(1).compare(2)) // 1 vs 2 }Без
this.обращение уходит к параметру - ближайшая область выигрывает. Это корректно, но при чтении легко ошибиться.Лямбда с получателем скрывает, откуда взялся метод:
fun main() { val text = buildString { append("a") appendLine("b") insert(0, "!") // метод StringBuilder, не видно из вызова } println(text.trim()) // !ab }В длинном блоке непонятно, какие функции доступны - помогает только IDE. Для публичных DSL это аргумент за короткие блоки и явные имена.
Контекстные параметры не участвуют в разрешении вызовов:
interface Printer2 { fun show(m: String) } context(printer: Printer2) fun demo2() { printer.show("через имя") // так // show("без имени") // ошибка: не найдено } fun main() { with(object : Printer2 { override fun show(m: String) = println(m) }) { demo2() } }Это сознательное отличие от отменённых context receivers: там метод вызывался без префикса, что делало код неоднозначным.
Контекст передаётся через
with, и это выглядит странно. Чтобы вызвать функцию с контекстным параметром, нужно внести значение в область видимости - обычно черезwith. Для нескольких контекстов получается вложенностьwith(a) { with(b) { ... } }, которая читается хуже, чем явные аргументы. Для двух зависимостей обычный параметр часто честнее.Контекстные параметры - свежая фича. Они стабилизированы недавно, поддержка в инструментах и библиотеках неполная, а до них была экспериментальная версия (context receivers) с другим синтаксисом, которую убрали. Код из старых статей (
context(Logger)без имени параметра) не компилируется. Подробнее: Компилятор K2Из Java функции с контекстными параметрами выглядят как функции с дополнительными аргументами - контекст становится обычным параметром в начале списка. Никакой «неявности» для Java-клиента нет. Подробнее: Kotlin из Java
Расширение внутри класса недоступно снаружи:
class Scope { fun String.shout() = uppercase() + "!" fun use() = "привет".shout() } fun main() { println(Scope().use()) // ПРИВЕТ! // println("привет".shout()) // ошибка: вне Scope расширения нет }Это полезное свойство (ограничение области), но в документации к DSL его нужно объяснять - иначе пользователь не поймёт, почему метод «исчезает».
thisв лямбде с получателем нельзя переназначить. Если нужно и внешний объект, и внутренний одновременно, единственный способ - метки или явный параметр:fun with2(block: (Outer, Inner) -> Unit).
Далее: DSL