Files
obsidian/Kotlin/Функциональный Kotlin/Scope-функции.md
T

20 KiB
Raw Blame History

title, description, tags
title description tags
Scope-функции let, run, apply, also, with - различия, выбор и типичные ошибки
kotlin
functional

Пять функций stdlib, которые выполняют блок кода в контексте объекта. Делают они почти одно и то же, различаясь двумя вещами: как доступен объект (this или it) и что возвращается (объект или результат блока).

Функция Объект внутри Возвращает Расширение
let it результат блока да
run this результат блока да
run (без получателя) - результат блока нет
apply this сам объект да
also it сам объект да
with this результат блока нет (принимает аргумент)
data class Person3(var name: String, var age: Int)

fun main() {
    val p = Person3("Иван", 30)

    val letResult = p.let { "${it.name} ${it.age}" }      // it, результат блока
    val runResult = p.run { "$name $age" }                 // this, результат блока
    val applyResult = p.apply { age = 31 }                 // this, сам объект
    val alsoResult = p.also { println("создан ${it.name}") }  // it, сам объект
    val withResult = with(p) { "$name $age" }              // this, результат блока

    println(letResult)        // Иван 30
    println(runResult)         // Иван 30
    println(applyResult)       // Person3(name=Иван, age=31)
    println(alsoResult === p)  // true
    println(withResult)        // Иван 31
}
graph TD
    A["Нужна scope-функция"] --> B{"Что возвращать?"}
    B -->|"сам объект"| C{"Как обращаться?"}
    B -->|"результат блока"| D{"Как обращаться?"}
    C -->|"this - настройка полей"| E["apply"]
    C -->|"it - побочный эффект"| F["also"]
    D -->|"it - преобразование"| G["let"]
    D -->|"this - много обращений"| H["run"]
    D -->|"объект уже есть, не цепочка"| I["with"]

let

Основное применение - работа с nullable и преобразование значения:

fun main() {
    val name: String? = "Иван"

    val length = name?.let { it.length } ?: 0
    println(length)        // 4

    name?.let { println("имя: $it") }        // имя: Иван

    val empty: String? = null
    empty?.let { println("не выполнится") }
    println("после")        // после
}

?.let { } - идиоматичная замена if (x != null), когда внутри нужно значение. Подробнее: Операторы null-безопасности

Второе применение - ограничить область видимости промежуточного значения:

fun main() {
    val result = listOf(1, 2, 3, 4)
        .filter { it % 2 == 0 }
        .let { evens -> "чётных: ${evens.size}, сумма: ${evens.sum()}" }
    println(result)        // чётных: 2, сумма: 6
}

Переменная evens не утекает наружу. Явное имя параметра вместо it здесь делает код понятнее.

run

То же, что let, но объект доступен как this - удобно, когда обращений к нему много:

data class Config3(val host: String, val port: Int, val tls: Boolean)

fun main() {
    val url = Config3("example.com", 8443, true).run {
        val scheme = if (tls) "https" else "http"
        "$scheme://$host:$port"
    }
    println(url)        // https://example.com:8443
}

run без получателя - просто блок, возвращающий значение:

fun main() {
    val value = run {
        val a = 2
        val b = 3
        a * b
    }
    println(value)        // 6
}

Это способ вычислить val несколькими строками, не заводя функцию. Полезно в инициализации свойств.

apply

Возвращает сам объект - значит, предназначен для настройки:

class Request2 {
    var url: String = ""
    var method: String = "GET"
    val headers = mutableMapOf<String, String>()

    override fun toString() = "$method $url $headers"
}

fun main() {
    val request = Request2().apply {
        url = "https://example.com"
        method = "POST"
        headers["Content-Type"] = "application/json"
    }
    println(request)        // POST https://example.com {Content-Type=application/json}
}

Это замена builder-паттерна для классов с изменяемыми свойствами. Подробнее: Паттерны проектирования

Частый случай - настройка при возврате:

fun buildList2(): List<Int> = mutableListOf<Int>().apply {
    add(1)
    add(2)
    addAll(listOf(3, 4))
}

fun main() {
    println(buildList2())        // [1, 2, 3, 4]
}

Хотя для коллекций в stdlib уже есть buildList, который чище.

also

Возвращает объект, доступ через it - для побочных эффектов, не меняющих объект:

fun main() {
    val numbers = listOf(3, 1, 2)
        .also { println("исходный: $it") }
        .sorted()
        .also { println("отсортированный: $it") }
        .filter { it > 1 }
        .also { println("отфильтрованный: $it") }

    println(numbers)
    // исходный: [3, 1, 2]
    // отсортированный: [1, 2, 3]
    // отфильтрованный: [2, 3]
    // [2, 3]
}

also вставляется в цепочку, ничего в ней не меняя - идеально для логирования и отладки.

Второе применение - валидация и регистрация при создании:

class Resource(val name: String) {
    fun close() = println("закрыт $name")
}

fun main() {
    val registry = mutableListOf<Resource>()

    val r = Resource("файл").also { registry.add(it) }
    println("${r.name}, в реестре: ${registry.size}")      // файл, в реестре: 1
}

with

Единственная не-расширение: объект передаётся аргументом.

fun main() {
    val sb = StringBuilder()
    with(sb) {
        append("Привет")
        append(", ")
        append("мир")
        append("!")
    }
    println(sb)        // Привет, мир!
}

with(x) { } читается как «с объектом x сделай» и применяется, когда объект уже есть в переменной и цепочка не нужна. Результат - значение блока:

data class Point2(val x: Int, val y: Int)

fun main() {
    val distance = with(Point2(3, 4)) {
        kotlin.math.sqrt((x * x + y * y).toDouble())
    }
    println(distance)        // 5.0
}

Как выбирать

Практические правила, покрывающие почти все случаи:

Задача Функция
выполнить код, если значение не null ?.let { }
преобразовать значение в другое let
настроить свойства нового объекта apply
залогировать значение в середине цепочки also
вычислить значение из нескольких полей объекта run
сгруппировать вызовы на существующем объекте with
вычислить val из нескольких строк run { } без получателя

Выбор между it и this - вопрос читаемости: this короче при многих обращениях к полям, it яснее, когда объект используется один-два раза или когда внутри есть другой this.

Вложенность и затенение

data class Inner(val value: Int)
data class Outer(val inner: Inner, val name: String)

fun main() {
    val o = Outer(Inner(42), "внешний")

    // плохо: два this, непонятно какой
    val bad = o.run { inner.run { "$value" } }
    println(bad)        // 42

    // хорошо: явные имена
    val good = o.let { outer -> outer.inner.let { inner -> "${outer.name}: ${inner.value}" } }
    println(good)        // внешний: 42
}

Вложенные scope-функции - главный источник нечитаемого кода на Kotlin. Правило: вложенность больше одного уровня - заводите явные имена параметров или обычные переменные.

Scope-функции и null

fun main() {
    val maybe: String? = "текст"

    println(maybe?.let { it.length })         // 5 — блок выполнится, если не null
    println(maybe?.run { length })             // 5 — то же
    println(maybe.let { it?.length })          // 5 — блок выполнится всегда, it nullable

    val nothing: String? = null
    println(nothing?.let { it.length })        // null
    println(nothing.let { it?.length })        // null — но блок выполнился!
}

Разница в месте ?.: x?.let { } не вызывает блок при null, x.let { } вызывает всегда с nullable it. Это часто путают.

takeIf и takeUnless

Родственные функции - фильтр для одного значения:

fun main() {
    val number = 42

    println(number.takeIf { it > 0 })          // 42
    println(number.takeIf { it > 100 })         // null
    println(number.takeUnless { it > 0 })       // null

    val name = "  "
    println(name.takeIf { it.isNotBlank() } ?: "(по умолчанию)")    // (по умолчанию)

    val config = mapOf("port" to "8080")
    val port = config["port"]?.takeIf { it.isNotBlank() }?.toIntOrNull() ?: 80
    println(port)        // 8080
}

takeIf превращает условие в nullable-значение, которое дальше обрабатывается через ?: и ?.. Это заменяет if-цепочки в конвейерах преобразования.

[!info] Отличия от Java

Java Kotlin
if (x != null) { ... } x?.let { ... }
Optional.map let
Optional.ifPresent ?.let или ?.also
builder с return this apply
промежуточная переменная let/run
Objects.requireNonNullElse(x, y) x ?: y
нет аналога also в середине цепочки
нет аналога with

Scope-функции заменяют Optional и промежуточные переменные. В Java цепочка преобразований с проверками на null выглядит так:

String result = Optional.ofNullable(user)
    .map(User::getAddress)
    .map(Address::getCity)
    .orElse("неизвестно");

В Kotlin ?. и ?: делают то же без обёртки, а let подключается, когда нужен не просто вызов метода:

data class Address(val city: String)
data class User11(val address: Address?)

fun main() {
    val user: User11? = User11(Address("Москва"))
    val result = user?.address?.city?.let { it.uppercase() } ?: "неизвестно"
    println(result)        // МОСКВА
}

Принципиальная разница: Optional - объект-обёртка со своей стоимостью и своим API, а ?. - синтаксис над обычной ссылкой. Поэтому Optional в Kotlin не используют вообще.

apply заменяет builder-паттерн для изменяемых объектов. В Java чтобы настраивать объект цепочкой, класс должен возвращать this из каждого сеттера - это решение автора класса. apply работает с любым классом, включая чужой:

fun main() {
    val props = java.util.Properties().apply {
        setProperty("host", "localhost")
        setProperty("port", "8080")
    }
    println(props.getProperty("host"))        // localhost
}

Properties из JDK не имеет fluent-API, но с apply ведёт себя как будто имеет.

also в цепочке аналога в Java не имеет. Stream.peek похож, но работает только внутри стрима и его использование не рекомендуется. also применим к любому значению.

Это не магия, а обычные inline-расширения. let объявлена как inline fun <T, R> T.let(block: (T) -> R): R = block(this) - одна строка. Поэтому накладных расходов нет: после компиляции остаётся только тело блока. Подробнее: Inline-функции

Сравнить: Функции в Java, Builder

[!warning] Подводные камни apply возвращает объект, даже если блок что-то вычислил:

fun main() {
    val wrong = StringBuilder().apply { append("текст"); length }
    println(wrong)                 // текст — не 5!
    val right = StringBuilder().run { append("текст"); length }
    println(right)                  // 5
}

Перепутать apply и run легко, а ошибка типов возникает далеко от места.

let на nullable без ?. выполняется всегда:

fun main() {
    val x: String? = null
    x.let { println("выполнилось, it = $it") }       // выполнилось, it = null
    x?.let { println("не выполнится") }
}

Вложенные this и it путаются:

data class Address2(var city: String)
data class User12(var name: String, val address: Address2)

fun main() {
    val u = User12("Иван", Address2("Москва"))
    u.apply {
        name = "Пётр"
        address.apply {
            city = "Казань"
            // name = "x"        // name внешнего this — компилируется, но запутанно
        }
    }
    println(u)        // User12(name=Пётр, address=Address2(city=Казань))
}

Внутренний блок видит и свой this, и внешний. Компилятор разрешает обращаться к членам обоих, и ошибку заметить трудно.

also и apply не защищают от изменения объекта. Оба возвращают тот же объект, а не копию:

data class Counter3(var value: Int)

fun main() {
    val c = Counter3(1)
    val same = c.also { it.value = 99 }
    println("${c.value} ${same === c}")      // 99 true
}

?.let на цепочке проверяет только последнее звено:

data class Inner2(val value: Int?)

fun main() {
    val i: Inner2? = Inner2(null)
    println(i?.let { it.value?.plus(1) })        // null
    println(i?.value?.plus(1))                    // null — короче и яснее
}

Когда достаточно ?., let только добавляет шум. Используйте let, если внутри нужно больше одного действия.

Цепочка из scope-функций читается хуже обычного кода:

fun main() {
    val bad = "42".let { it.toInt() }.let { it * 2 }.also { println(it) }.let { "$it" }
    println(bad)        // 84 \n 84

    val good = ("42".toInt() * 2).toString()
    println(good)        // 84
}

Scope-функции - инструмент, а не стиль. Если без них короче - пишите без них.

run без получателя путается с run на объекте. x.run { } и run { } - разные функции с одним именем; при чтении диффа разница в одну точку.

Нелокальный return внутри scope-функции выходит из внешней функции:

fun process5(value: String?): String {
    value?.let {
        if (it.isBlank()) return "пусто"        // выход из process5!
    }
    return "обработано: $value"
}

fun main() {
    println(process5("  "))        // пусто
    println(process5("текст"))      // обработано: текст
}

Это работает благодаря inline, но для читателя неочевидно: return внутри лямбды выглядит как возврат из лямбды.

takeIf возвращает nullable - и это заразно:

fun main() {
    val port: Int? = 8080.takeIf { it > 1024 }
    // println(port + 1)        // ошибка: Int? не складывается
    println(port?.plus(1))       // 8081
}

Каждый takeIf добавляет в тип ?, который нужно где-то разрешить.

apply на val-свойствах бесполезен:

data class Immutable(val a: Int, val b: Int)

fun main() {
    val x = Immutable(1, 2).apply {
        // a = 10        // ошибка: val
    }
    println(x)                                   // Immutable(a=1, b=2)
    println(Immutable(1, 2).copy(a = 10))        // Immutable(a=10, b=2)
}

Для неизменяемых классов нужен copy, а не apply. Подробнее: Data class

Далее: Инфиксные и операторные функции