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 ви змінюєте дані або ухвалюєте рішення, ви пишете код, який виглядає як «просто логування», але насправді впливає на результат. Це погана домовленість: ви заощаджуєте два рядки, але платите годиною налагодження.

Ось поганий приклад (важлива логіка схована в 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
Опитування
Scope‑функції та розбір Text Analyzer, рівень 28, лекція 4
Недоступний
Scope‑функції та розбір Text Analyzer
Scope‑функції та розбір Text Analyzer
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ