JavaRush /Курсы /Kotlin SELF /Контракты scope‑функций: let/run/apply/also/with

Контракты scope‑функций: let/run/apply/also/with

Kotlin SELF
28 уровень , 0 лекция
Открыта

1. Введение

Если коротко, scope‑функции — это «маленький договор» между вами и стандартной библиотекой: «вот объект, давай я выполню блок кода рядом с ним, а ты верни мне либо результат этого блока, либо сам объект обратно». Kotlin прямо выделяет набор таких функций в стандартной библиотеке: let, run, with, apply, also.

Самая частая причина, почему новичкам они сначала не нравятся: кажется, что это какая-то особая конструкция языка. На самом деле это просто функции (да, со слегка хитрыми сигнатурами и лямбдами). То есть компилятор не включает «режим колдовства»; он просто помогает вам написать компактнее то, что вы и так могли бы написать обычным кодом.

С точки зрения практики это полезно в трёх ситуациях: когда вы хотите «протащить» значение через цепочку вычислений, когда вы хотите настроить объект несколькими действиями подряд, и когда вам нужно вставить побочный шаг (например, печать для отладки), не ломая основную логику.

2. Две оси выбора: this или it и что возвращается

Чтобы scope‑функции не превращались в лотерею «пальцем в небо», их удобно запомнить не по названиям, а по двум осям. Это как выбирать обувь: сначала решаем, для чего она (бег/офис/по лужам), а потом — размер. Здесь похожая логика: сначала решаем, как внутри блока обращаться к объекту, а потом — что должно вернуться наружу.

Первая ось: как объект доступен внутри блока. Либо как it (явный параметр лямбды), либо как this (неявный «получатель», receiver). it обычно делает код более «честным»: вы видите, что работаете с параметром. this делает код компактнее, но иногда может скрывать контекст (особенно если вы внутри вложенных блоков).

Вторая ось: что возвращает вызов scope‑функции. Либо возвращается результат последнего выражения блока, либо возвращается сам исходный объект. И вот это — ключ к выбору. Потому что большая часть ошибок со scope‑функциями выглядит так: «я хотел получить число/строку/список, а получил обратно исходный объект» (или наоборот).

3. Шпаргалка по контрактам: this/it и результат

Перед тем как разбирать каждую функцию отдельно, полезно один раз увидеть таблицу. Потом мы её «оживим» примерами.

Функция Как внутри блока виден объект Что возвращается из вызова
let
it
результат блока
also
it
исходный объект
run (как obj.run { })
this
результат блока
apply
this
исходный объект
with(obj) { }
this
результат блока

Обратите внимание: with(obj) { ... } отличается тем, что вы передаёте объект аргументом (не через точку), но внутри блока он всё равно становится this, и по смыслу with очень близок к run.

4. Функции по смыслу: преобразовать, настроить, вставить побочный шаг

let: объект как it, наружу — результат вычисления

let обычно «заходит» первым, потому что он очень похож на привычную идею «взяли значение → преобразовали → получили новое значение». Если вы раньше писали что-то вроде «прочитал строку, обрезал пробелы, посчитал длину», то let — это способ оформить это как маленькое преобразование без лишнего промежуточного val, но при этом не превращать всё в один нечитаемый ком.

Внутри let объект доступен как it. Это хорошо, когда блок короткий: буквально 1–3 выражения. А наружу возвращается результат последнего выражения блока — то есть let отлично подходит для вычислений.

Пример: «взяли строку → получили длину после trim()».

fun main() {
    val raw = "   Kotlin  "
    val len = raw.let { it.trim().length }

    println(len) // 6
}

Обратите внимание на ощущение: raw не меняется, а len — это новый результат. Это очень «функциональная» манера: меньше мутации, больше явных преобразований.

Ещё пример: «взяли строку → сделали нормализованный вариант». Пока без сложной токенизации, просто покажем идею «преобразования».

fun main() {
    val input = "  HeLLo, KoTLiN  "
    val normalized = input.let { it.trim().lowercase() }

    println(normalized) // hello, kotlin
}

Здесь let помогает держать «взял → сделал» в одной точке.

run: объект как this, наружу — результат вычисления

После let логичный следующий шаг — run в форме obj.run { ... }. По смыслу он очень похож на let: тоже возвращает результат вычисления, но внутри блока объект доступен как this. Это удобно, когда вы хотите вызвать несколько методов/свойств объекта подряд, и вам не хочется каждый раз писать it.

Важно: сегодня мы говорим именно про obj.run { ... }, то есть про вызов через точку у объекта. (Есть ещё run { ... } без объекта — это отдельная история, мы к ней аккуратно подойдём в следующей лекции.)

Пример: возьмём строку и получим «последний символ после обрезки пробелов».

fun main() {
    val raw = " Kotlin "
    val lastChar = raw.trim().run { last() }

    println(lastChar) // n
}

Почему здесь run приятен: last() выглядит как «родной» вызов строки. Да, это мелочь, но из мелочей потом и складывается читабельность цепочек.

Ещё пример, уже с StringBuilder. Нам нужно собрать строку и вернуть именно String, а не сам StringBuilder. Это классический кейс «хочу результат блока».

fun main() {
    val text = StringBuilder().run {
        append("Hello")
        append(", ")
        append("Kotlin")
        toString()
    }

    println(text) // Hello, Kotlin
}

Здесь важная мысль: run возвращает то, что стоит последним. Поэтому если последним стоит toString(), наружу выйдет строка.

with: почти как run, но без точки

with часто вызывает вопрос: «зачем он нужен, если есть run?» Ответ прагматичный: with удобно читать, когда объект уже есть как отдельное имя, и вы хотите выполнить над ним набор действий «внутри контекста», но не оформлять это как цепочку вызовов через точку.

Формально with — это не extension‑вызов (obj.with), а обычная функция (with(obj)). Но внутри блока объект доступен как this, и наружу возвращается результат блока, то есть по контракту он очень похож на run.

Пример: у нас есть StringBuilder sb, и мы хотим в одном месте собрать текст и получить строку.

fun main() {
    val sb = StringBuilder()

    val result = with(sb) {
        append("Text ")
        append("Analyzer ")
        append("v0")
        toString()
    }

    println(result) // Text Analyzer v0
}

Если сравнить с sb.run { ... }, разница в основном стилистическая. Часто выбирают так: если уже есть obj. слева — берут run, если хочется «обрамить» действия вокруг объекта — берут with.

apply: объект как this, наружу — тот же объект

apply — это про настройку объекта. Представьте, что вы купили мебель из IKEA (или, если вам повезло, просто стул), и хотите его «собрать»: прикрутить ножки, наклеить наклейку «не садиться коту», поставить на место. apply — это как «собрал → вернул тот же стул обратно».

Контракт apply: внутри блока объект — this, а наружу возвращается исходный объект, уже «настроенный». Поэтому apply идеален, когда вы хотите в одном выражении создать объект и подготовить его к использованию.

Классический пример — StringBuilder, но теперь нам важен именно объект, потому что мы хотим продолжить с ним работу (или вызвать toString() отдельно).

fun main() {
    val sb = StringBuilder().apply {
        append("Hello")
        append(", ")
        append("apply")
    }

    println(sb.toString()) // Hello, apply
}

Тонкость, на которой многие спотыкаются: если вы сделаете val s = StringBuilder().apply { ... }, то s будет типа StringBuilder, а не String. Это не ошибка Kotlin, это вы выбрали контракт «верни объект».

Ещё пример: часто нужно подготовить какой-то «отчёт» как многострочную строку. Мы ещё не строим полный пайплайн, но уже можем показать, как удобно настраивать StringBuilder через apply.

fun main() {
    val report = StringBuilder().apply {
        appendLine("Text Analyzer")
        appendLine("--------------")
        appendLine("Status: OK")
    }.toString()

    println(report)
    // Text Analyzer
    // --------------
    // Status: OK
}

Обратите внимание: здесь мы всё-таки хотим строку, поэтому после apply мы делаем toString(). Это хороший стиль: apply — для настройки, отдельный шаг — для получения финального представления.

also: объект как it, наружу — тот же объект

Если apply — «настроить объект», то also — «сделать что-то дополнительно по пути». Чаще всего это побочный эффект: вывести в консоль, записать лог, проверить инвариант, посчитать что-нибудь для отладки. И при этом не сломать основное значение, которое течёт дальше.

Контракт also: внутри блока объект доступен как it, наружу возвращается исходный объект. Почему it, а не this? Потому что в also очень часто хочется явно видеть, что именно вы «подсматриваете» или печатаете.

Пример: сортируем список и хотим увидеть «до/после», не меняя основного результата.

fun main() {
    val xs = listOf(3, 1, 2)
        .also { println("before: $it") }   // before: [3, 1, 2]
        .sorted()
        .also { println("after: $it") }    // after: [1, 2, 3]

    println(xs) // [1, 2, 3]
}

Важный момент: also не должен быть местом, где живёт «главная логика». Если у вас внутри also вычисление, от которого зависит результат — это тревожный звоночек. also хорош именно как «боковой кармашек»: достали фонарик, подсветили, положили обратно и пошли дальше.

5. Мини‑правило выбора и пример Text Analyzer v0

Сейчас будет полезная «честная шпаргалка», но мы оформим её не списком, а как мысль: сначала спрашивайте себя, вы хотите получить новое значение или вернуть тот же объект. Если вы хотите новое значение, почти всегда вы в мире let/run/with. Если вы хотите вернуть тот же объект, вы в мире apply/also.

Дальше уточняете стиль внутри блока. Если блок короткий и вам хочется явности — берите вариант с it (let для преобразования, also для побочного шага). Если блок похож на «настройку» с множеством обращений к методам/свойствам — чаще удобнее this (run для вычисления результата или apply для настройки объекта).

Давайте аккуратно начнём наш учебный «Text Analyzer v0». Пока без сложных разборов, без токенов и частот: сегодня мы лишь сделаем маленькую заготовку, в которой scope‑функции будут смотреться естественно.

fun main() {
    println("Enter text:")
    val input = readln()

    val normalized = input
        .let { it.trim() }
        .also { println("Trimmed: '$it'") } // например: Trimmed: 'Hello'

    val header = StringBuilder().apply {
        appendLine("Text Analyzer v0")
        appendLine("Length: ${normalized.length}")
    }.toString()

    print(header)
    // Text Analyzer v0
    // Length: 5
}

Что здесь важно заметить именно по контрактам, а не по красоте. let возвращает результат вычисления, поэтому мы получили строку trim() и продолжили с ней. also не меняет значение, но позволил вставить «отладочную печать» по пути. apply настроил StringBuilder, но вернул всё ещё StringBuilder, поэтому мы отдельно сделали toString().

Мы не пытались «впихнуть» сюда весь анализ текста — это будет чуть позже. Сегодня задача проще: научиться видеть, что вернётся из каждой функции, и не путать «преобразование» с «настройкой».

6. Типичные ошибки при выборе let/run/apply/also/with

Ошибка №1: перепутали apply и run, а потом удивились типу результата.
Обычно это выглядит так: вы хотели получить строку, написали настройку StringBuilder и ожидаете, что наружу выйдет String. Но apply по контракту возвращает объект, то есть StringBuilder. Лечится это не «допишите as String» (так делать не надо), а правильным выбором: либо используйте run, если хотите вернуть вычисленный результат, либо оставьте apply, но добавьте явный шаг toString().

Ошибка №2: попытка делать «основную работу» внутри also.
also легко превращается в мусорную корзину: «ну раз уж тут блок, давайте тут ещё посчитаем, тут ещё отфильтруем…». В итоге код становится нечитаемым: снаружи кажется, что это побочный шаг, а внутри — половина бизнес‑логики. Хорошее правило: если без also результат программы меняется, значит, вы используете also не по назначению. Пусть also остаётся для печати, логирования и лёгкой диагностики.

Ошибка №3: слишком длинные блоки и потеря читабельности.
Scope‑функции должны упрощать чтение, а не создавать «матрёшку из лямбд». Если ваш блок в let/run/apply/with/also разросся до 15 строк, у вас почти наверняка появилась логика, которая заслуживает отдельной функции с нормальным именем. И да, это тот самый момент, когда «лишняя строка кода» делает программу понятнее, а не хуже.

Ошибка №4: путаница из‑за вложенных it.
Когда вы пишете something.let { ... map { ... } ... }, появляется два it, и мозг начинает работать как компилятор: «так, какой it сейчас где?». Kotlin не запрещает, но читать это тяжело. В таких местах лучше честно назвать параметры: let { text -> ... }, map { token -> ... }. Это не «многословность», это страховка от логических ошибок.

Ошибка №5: использовать with и run вперемешку без причины.
И with(obj) {}, и obj.run {} дают this и возвращают результат блока. Если вы сегодня в одном стиле, а завтра в другом, код начинает выглядеть «рваным». Выберите привычку: либо чаще obj.run {}, либо чаще with(obj) {} — и придерживайтесь её хотя бы внутри одного проекта. Так читателю не придётся каждый раз вспоминать, почему тут внезапно with.

1
Задача
Kotlin SELF, 28 уровень, 0 лекция
Недоступна
Проверка никнейма
Проверка никнейма
1
Задача
Kotlin SELF, 28 уровень, 0 лекция
Недоступна
Последний значок
Последний значок
1
Задача
Kotlin SELF, 28 уровень, 0 лекция
Недоступна
Экран статуса
Экран статуса
1
Задача
Kotlin SELF, 28 уровень, 0 лекция
Недоступна
Диагностика сортировки
Диагностика сортировки
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ