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, бо вони повертають сам обʼєкт, а не результат останнього виразу в блоці. Це чудово для налаштування обʼєкта, але погано для обчислень.
Щоб не гадати навмання, тримайте в голові мінітабличку (ми її вже обговорювали, але зараз вона потрібна як аптечка):
| Функція | Усередині блоку | Повертає |
|---|---|---|
|
|
результат блоку |
(як obj.run { }) |
|
результат блоку |
|
|
результат блоку |
|
|
сам обʼєкт |
|
|
сам обʼєкт |
Класика жанру: 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‑функція була зайвою.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ