JavaRush /Курсы /Kotlin SELF /Ошибки scope‑функций: результат, it и лишняя магия

Ошибки scope‑функций: результат, it и лишняя магия

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

1. Когда scope‑функции вредят: сахар тоже липнет

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

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

Scope‑функции — это как специи: щепотка делает блюдо лучше, а половина банки превращает борщ в эксперимент над студентами. В Kotlin они действительно упрощают код, но цена — появление «скрытых» решений: что вернётся из выражения, какой сейчас тип у результата, какой it имеется в виду, где мы что-то посчитали, но забыли использовать результат.

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

2. Ошибка №1: потеря результата из‑за неверной scope‑функции

Самая частая и самая коварная ошибка: вы уверены, что «сейчас посчитаю и верну», а Kotlin такой: «конечно, верну… только не это, а объект, на котором всё происходило». Виновники торжества — apply и also, потому что они возвращают сам объект, а не результат последнего выражения блока. Это прекрасно для настройки объекта, но плохо для вычислений.

Чтобы не гадать, держите мини-табличку в голове (мы её уже обсуждали, но сейчас она нужна как аптечка):

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

Классика жанра: apply на StringBuilder, а ожидали String

Начнём с примера, который ломал нервы примерно половине человечества (вторая половина просто ещё не пробовала).

fun main() {
    val text = StringBuilder().apply {
        append("A")
        append("B")
    }

    println(text) // AB (на самом деле это будет содержимое StringBuilder, но тип — StringBuilder)
}

На печати это выглядит «как будто всё ок», потому что StringBuilder умеет красиво печататься. Но дальше вы внезапно захотите сделать text.uppercase() — и получите ошибку компиляции, потому что uppercase() есть у String, а text — это StringBuilder.

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

fun main() {
    val s: String = StringBuilder().run {
        append("A")
        append("B")
        toString()
    }

    println(s) // AB
}

Здесь важно уловить мысль: run — это «сделай несколько действий в контексте объекта и верни то, что я скажу последним».

«Я отсортировал список в apply, почему он не отсортировался?»

Это прям отдельный вид боли, потому что выглядит логично, а логика иногда предатель.

fun main() {
    val xs = listOf(3, 1, 2).apply {
        sorted() // я как бы вызвал sorted...
    }

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

Почему так? Потому что:

  • listOf(...) создаёт неизменяемый (read‑only) список.
  • sorted() не меняет список, а возвращает новый.
  • apply возвращает исходный объект — то есть исходный список.
  • Результат sorted() вы вычислили… и выкинули.

Если вы хотите «взяли список → получили новый список», это задача для let:

fun main() {
    val xs = listOf(3, 1, 2).let { it.sorted() }

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

Здесь let честно вернёт результат блока.

«Я отфильтровал в also, почему ничего не изменилось?»

also — для побочных действий: лог, печать, диагностика. Он почти всегда плох для изменения смысла данных в цепочке.

fun main() {
    val tokens = listOf("kotlin", "", "is").also {
        it.filter { s -> s.isNotBlank() } // результат опять выкинули
    }

    println(tokens) // [kotlin, , is]
}

Правильное решение: фильтрация — это преобразование, значит let (или просто filter без scope‑функции).

fun main() {
    val tokens = listOf("kotlin", "", "is")
        .filter { it.isNotBlank() }

    println(tokens) // [kotlin, is]
}

А вот так also применить можно и нужно — как диагностику, не меняя результат:

fun main() {
    val tokens = listOf("kotlin", "", "is")
        .filter { it.isNotBlank() }
        .also { println("tokens = $it") } // tokens = [kotlin, is]

    println(tokens.size) // 2
}

Где apply реально хорош и почему это не let

apply — отличный инструмент «создай и настрой». Например, при инициализации изменяемых коллекций он делает код компактным и читаемым: вы создали MutableMap и тут же наполнили. Это ровно тот сценарий, где «вернуть тот же объект» — правильный контракт.

3. Ошибка №2: путаница it и this во вложенных блоках

Вторая большая проблема появляется, когда scope‑функции начинают вкладываться друг в друга. В Kotlin лямбды — это нормально, мы уже давно пишем map { ... }, filter { ... }, groupingBy { ... }. Но когда к этому добавляется let { ... }, потом внутри ещё let { ... }, а потом вишенка — filter { ... }, у вас в одном экране оказывается три разных «неявных» параметра. И все называются it.

На этом месте мозг обычно делает вид, что он garbage collector: «я потом разберусь, сейчас пропущу».

Пример: «двойной it» в пайплайне Text Analyzer

Представим, что вы пишете пайплайн «быстро, в одну строку, красиво». И вот что получается:

fun main() {
    val input = " Kotlin   kotlin  "
    val tokens = input.let {
        it.trim().lowercase().split(" ").filter { it.isNotBlank() }
    }

    println(tokens) // [kotlin, kotlin]
}

Этот код работает. Но он неприятный: it внутри let — это строка, а it внутри filter — это уже токен. Глазу это неочевидно. Сегодня вы понимаете, завтра вы устали, послезавтра вы случайно добавили ещё одну вложенность — и всё, пошли «почему оно не компилируется».

Решение простое и взрослое: назвать параметры.

fun main() {
    val input = " Kotlin   kotlin  "
    val tokens = input.let { text ->
        text.trim()
            .lowercase()
            .split(" ")
            .filter { token -> token.isNotBlank() }
    }

    println(tokens) // [kotlin, kotlin]
}

Теперь даже если вы откроете этот код через месяц, вы поймёте, что происходит.

А можно вообще без вложенности?

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

fun main() {
    val input = " Kotlin   kotlin  "

    val tokens = input
        .trim()
        .lowercase()
        .split(" ")
        .filter { it.isNotBlank() }

    println(tokens) // [kotlin, kotlin]
}

Scope‑функции тут вообще не нужны. И это нормально! Самая «профессиональная» scope‑функция — это та, которую вы не использовали, потому что без неё читается лучше.

Ошибка с this: «какой именно this сейчас?»

Когда вы начинаете активно применять run/apply/with, появляется другая ловушка: this становится не «я и моя функция», а «текущий объект-ресивер». Это круто, пока ресивер один. Но если у вас появятся вложенные ресиверы (например, вы строите строку в StringBuilder().apply { ... }, а внутри делаете ещё что-то с run), легко потерять ориентацию.

Поэтому правило очень практичное: если блок маленький (1–3 строки) — this внутри apply/run читается отлично; если блок разрастается, появляются вложенности или несколько объектов — либо называйте параметры через let { x -> ... }, либо выносите кусок в отдельную функцию. Не потому что «так надо», а потому что вы не робот (и это нормально, хоть и обидно).

4. Ошибка №3: лишняя «магия» в цепочках

Scope‑функции часто соблазняют: «смотри, я могу без единой переменной, в одну цепочку, и ещё also для логов вставлю!». А потом вы открываете это через неделю и понимаете, что это не цепочка, а макраме.

В Kotlin даже в официальных соглашениях по стилю scope‑функции упоминаются как отдельная тема выбора и читаемости: ими стоит пользоваться осознанно, а не по принципу «чем больше, тем Kotlin‑ее».

Симптом: слишком длинный блок внутри let/run

Если внутри let у вас внезапно появились ветвления, несколько промежуточных переменных, println, и вы сами не можете кратко сказать «что делает этот блок» — значит, scope‑функция тут уже не помогает, а прячет сложность.

Допустим, вы делаете форматирование top‑N и втискиваете всё в let:

fun main() {
    val freq = mapOf("kotlin" to 2, "java" to 1)

    val report = freq.let {
        val top = it.entries.sortedByDescending { e -> e.value }.take(1)
        val line = top.joinToString { e -> "${e.key}: ${e.value}" }
        "TOP:\n$line"
    }

    println(report) // TOP: kotlin: 2
}

Это ещё терпимо, но уже на грани. Когда таких блоков становится 3–4 подряд, код превращается в «почему здесь вообще let?».

Более чистый стиль для нашего Text Analyzer — вынести шаги в функции, а цепочку оставить линейной. Мы это делали в предыдущей лекции: там «магия» исчезает, потому что каждое звено — имя функции, а не безымянный блок.

Симптом: also делает важную работу

also по смыслу — «и ещё вот это». То есть вы делаете основную цепочку, и параллельно выполняете побочное действие: печать, логирование, измерение времени, проверку. Если внутри also вы меняете данные или принимаете решение, вы пишете код, который выглядит как «просто лог», но реально влияет на результат. Это плохая сделка: вы экономите 2 строчки, но платите часом отладки.

Вот плохой пример (важная логика спрятана в also):

fun main() {
    var limit = 2

    val top = mapOf("a" to 1, "b" to 5, "c" to 3).entries
        .sortedByDescending { it.value }
        .also { limit = 1 } // внезапно меняем limit
        .take(limit)

    println(top.map { it.key }) // [b]
}

Это работает, но читается как «кто подменил переменную ночью?». Если нужно менять limit, делайте это явно до цепочки или передайте параметром.

А вот хороший also: только диагностика, результат не меняет.

fun main() {
    val top = mapOf("a" to 1, "b" to 5, "c" to 3).entries
        .sortedByDescending { it.value }
        .also { println("sorted keys = ${it.map { e -> e.key }}") } // sorted keys = [b, c, a]
        .take(2)

    println(top.map { it.key }) // [b, c]
}

Симптом: scope‑функции «на всякий случай»

Иногда можно встретить стиль:

val result = input
    .let { it.trim() }
    .let { it.lowercase() }
    .let { it.split(" ") }

Это не ошибка, но это шум. Здесь let не добавляет смысла: обычные вызовы и так читаются линейно. let нужен, когда вы меняете форму вызова (например, хотите вставить callable reference ::normalizeText), или когда вы работаете с nullable (?.let), или когда вы хотите зафиксировать промежуточный результат именем в блоке.

Мини‑проверка: как читать цепочку и ловить ошибки заранее

Есть приятная новость: большинство ошибок со scope‑функциями можно поймать, если читать код чуть медленнее, чем обычно (да, это звучит как «просто будь внимательным», но тут есть конкретная техника).

Когда видите . и scope‑функцию, задайте себе два вопроса прямо по ходу чтения: «что является контекстным объектом в блоке?» и «что возвращает этот вызов?». Если вы не можете ответить за 2–3 секунды, код уже на грани читаемости, и его лучше упростить.

Дальше полезно смотреть на тип результата после каждого шага. В IntelliJ IDEA это вообще читерство: наведите курсор на выражение — IDE подскажет тип. Если после apply вы ожидали String, а IDE показывает StringBuilder, вы только что поймали ошибку ещё до запуска (и это лучший вид отладки — когда ничего не падало).

И последнее: если вам кажется, что цепочка стала «слишком умной», попробуйте переписать её через 2–3 промежуточных val с нормальными именами (normalized, tokens, freq, top). Если стало читаться легче — значит так и надо. Scope‑функции должны служить читаемости, а не соревнованию «кто короче».

5. Типичные ошибки при работе со scope‑функциями

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

Ошибка №1: использовать apply/also там, где нужен результат вычисления.
Когда вы хотите «получить новое значение», а пишете apply, вы почти гарантированно потеряете результат: sorted(), filter(), map() внутри блока вычислятся, но никуда не денутся, потому что apply вернёт исходный объект. В таких местах обычно нужны let, run или просто прямой вызов операции без scope‑функции.

Ошибка №2: «я сделал sorted()/filter() внутри блока, значит коллекция изменилась».
Многие операции коллекций в Kotlin возвращают новый список, а не меняют исходный. Если вы не присвоили результат (или не вернули его из let/run), значит вы просто «подумали об сортировке» — и на этом всё. В Text Analyzer это особенно заметно на токенах: забыли применить фильтрацию — получили пустые слова в частотах.

Ошибка №3: вложенные it, из‑за которых непонятно, что сейчас обрабатывается.
Когда в одном выражении есть let { it ... filter { it ... } }, вы заставляете читателя держать в голове два разных it. Компилятор справится, а человек — не всегда. Лечится либо явными именами (text, token, entry), либо распрямлением кода на несколько шагов.

Ошибка №4: большие блоки внутри scope‑функций, которые прячут алгоритм.
Scope‑функции лучше всего работают как короткие «переходники»: 1–5 строк. Когда внутри блока уже мини‑программа (ветвления, несколько val, несколько смыслов), код перестаёт быть линейным пайплайном. В таких случаях лучше вынести блок в именованную функцию и оставить в цепочке только её имя — так вы получите читаемость без потери структуры.

Ошибка №5: использование also для важной логики и побочных эффектов, влияющих на результат.
also должен быть «сбоку»: напечатать, залогировать, проверить, но не менять смысл данных. Если внутри also вы меняете переменные, модифицируете коллекции или решаете, сколько элементов брать в take(n), вы создаёте код, который выглядит как «диагностика», но реально меняет поведение. Это почти гарантированный источник сюрпризов.

Ошибка №6: попытка сделать код «более Kotlin‑овым», добавляя let везде подряд.
Иногда scope‑функции начинают использоваться как декоративный стиль: «пусть будет .let { it }». Это делает код длиннее и шумнее. Хороший признак здорового решения: если удалить scope‑функцию и заменить на обычный вызов — код не станет хуже. Если станет лучше — scope‑функция была лишней.

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