1. Навіщо потрібні nullable‑ланцюжки
Коли ви тільки починаєте працювати з null, найприродніший крок — писати перевірки if (x != null) і всередині виконувати потрібну роботу. Це нормально: так думали мільйони програмістів до нас, і дехто досі так робить (інколи навіть успішно). Але щойно зʼявляється другий крок обробки, третій, четвертий — if починають «рости вниз», і код перетворюється на драбину, яку так і хочеться підперти табуреткою.
Уявіть типовий сценарій введення: користувач увів рядок → ми його обрізали → спробували розпарсити число → перевірили діапазон → порахували результат. Якщо писати це через вкладені if, отримаємо багато рівнів відступів і постійні «а що, як тут null?». Nullable‑ланцюжки дають змогу записати ту саму логіку лінійно: «ось крок, ось крок, ось крок, а якщо на якомусь кроці значення немає — ось fallback».
Наша мета сьогодні — навчитися двох базових звʼязок:
- x?.let { ... } — виконати блок лише якщо x не null, причому всередині блоку it уже не nullable.
- x?.let { ... } ?: ... — лінійний запис «якщо вийшло — беремо результат, інакше — запасний сценарій».
І так, Kotlin прямо позиціонує let як одну з функцій, що допомагають «спростити вирази» та тримати обробку поруч із даними.
2. ?.let { ... }: обробляємо лише якщо є значення
let — це scope‑функція, у якій обʼєкт доступний як it, а результатом є останній вираз блоку. Важлива деталь саме для роботи з nullable: коли ви пишете x?.let { ... }, блок виконається лише якщо x != null. А всередині let Kotlin уже розуміє, що it — не null, і дозволяє викликати методи та властивості без додаткових перевірок.
Почнімо з дуже простого прикладу: читаємо число; якщо вийшло — друкуємо квадрат. Якщо не вийшло — узагалі нічого не робимо.
fun main() {
val raw = readln()
val n: Int? = raw.toIntOrNull()
n?.let { value ->
val sq = value * value
println("квадрат = $sq") // якщо ввели 7 -> квадрат = 49
}
}
Зверніть увагу: тут ми не вирішуємо, що робити у випадку null. Це може бути нормально, коли дія справді необовʼязкова (наприклад, налагоджувальний друк: «спробуємо — не вийде, ну й гаразд»). Але в реальних сценаріях частіше потрібно обовʼязково мати результат. І тоді однієї ?.let замало — потрібна звʼязка з Elvis ?:.
Ще один приклад: є рядок, і ми хочемо привести його до «нормального вигляду» (trim + lowercase), але лише якщо рядок узагалі існує.
fun main() {
val maybeText: String? = readln().takeIf { it.isNotBlank() }
val normalized: String? = maybeText?.let { it.trim().lowercase() }
println(normalized) // якщо ввели " HeLLo " -> hello
// якщо ввели порожньо/пробіли -> null
}
Тут normalized теж nullable. І це логічно: якщо входу немає, то й нормалізувати нічого. Але якщо далі в коді вам потрібен не nullable текст (наприклад, для звіту), час «закривати» null конкретною поведінкою.
3. Шаблон ?.let { ... } ?: fallback
У звичайному if-else ви явно прописуєте дві гілки. Elvis ?: робить те саме, але у вигляді виразу: зліва — «основний варіант», справа — «запасний». А ?.let зручно ставити зліва, щоб основний варіант обчислювався лише за наявності значення.
Сенс шаблону:
- зліва: «якщо x не null — обчислити результат»
- справа: «інакше — повернути/обчислити fallback»
Приклад: читаємо число. Якщо ввели коректно — подвоюємо. Якщо ні — беремо 0.
fun main() {
val raw = readln()
val n: Int? = raw.toIntOrNull()
val doubled: Int = n?.let { it * 2 } ?: 0
println("подвоєне = $doubled") // ввели 21 -> подвоєне = 42
// ввели "котик" -> подвоєне = 0
}
Ключовий момент: doubled — не nullable, бо справа від ?: ми дали конкретне значення 0. Тобто ми «закрили» невизначеність.
Дуже поширена практична ситуація: ви хочете вважати порожній рядок «відсутністю», а не реальним введенням. Тоді добре працює звʼязка takeIf → ?.let → ?:.
fun main() {
val token: String? = readln()
.trim()
.takeIf { it.isNotEmpty() }
val lower: String = token?.let { it.lowercase() } ?: "<порожньо>"
println(lower) // ввели " Kotlin " -> kotlin
// ввели "" -> <порожньо>
}
Тут takeIf перетворює «погане значення» на null, а ?: перетворює null на зрозуміле значення за замовчуванням.
Щоб не тримати все це в голові як «магічне заклинання», зафіксуймо мінітаблицю:
| Запис | Коли виконується блок | Що повертається | Типовий сенс |
|---|---|---|---|
|
лише якщо x != null | результат блоку, але nullable‑ланцюжок може продовжитися | «спробувати обробити, якщо є» |
|
блок — якщо є x, інакше fallback | завжди значення (зазвичай non‑null) | «нормальний результат або запасний» |
4. Elvis ?: як «контракт»: результат обовʼязковий
Elvis‑оператор ?: багато хто спочатку сприймає як «це просто замінити null на 0». І так, це один із варіантів. Але зріліший погляд інший: це інструмент, який допомагає зафіксувати контракт: «у цьому місці програма зобовʼязана отримати значення».
Наприклад, ви пишете функцію readTopN(): Int. Вона має повертати звичайне число (не nullable), бо далі ви хочете використовувати topN без перевірок. Тому всередині можна працювати з Int?, але на виході — закрити null значенням за замовчуванням.
fun readTopN(): Int {
val raw = readln().trim()
val n: Int? = raw.toIntOrNull()
return n?.takeIf { it > 0 } ?: 5
}
fun main() {
val topN = readTopN()
println("topN = $topN") // ввели 3 -> topN = 3
// ввели -10 -> topN = 5
// ввели "abc" -> topN = 5
}
У цьому прикладі важлива деталь: takeIf { it > 0 } теж може повернути null, якщо число не підходить. І це добре: ми чесно кажемо «не підходить — отже, значення ніби немає», а потім Elvis обирає значення за замовчуванням.
Невеликий, але корисний факт: Elvis у Kotlin часто використовують не лише для значень, а й для раннього return або throw — адже справа може стояти будь‑який вираз. Ми глибоко у винятки зараз не заглиблюватимемося, але «ранній вихід» нам сьогодні знадобиться.
5. run { ... } як багаторядковий fallback
Іноді справа від ?: недостатньо написати 0 або "<empty>". Іноді ви хочете: вивести повідомлення користувачеві, вибрати значення за замовчуванням, можливо, щось підготувати — і лише потім повернути результат. І ось тут багато новачків намагаються написати щось дивне на кшталт:
// так не треба, це приклад "як можна заплутатися"
val x = n ?: println("погано") // println повертає Unit, і все починає ламатися
Щоб справа від Elvis виконати кілька дій, використовуйте run { ... }. Це звичайна функція, яка виконує блок і повертає останній вираз. Тобто вона ідеально підходить як «контейнер» для багаторядкового fallback.
fun main() {
val n: Int? = readln().toIntOrNull()
val value: Int = n ?: run {
println("Це не число, візьму значення за замовчуванням") // повідомлення
10 // останній вираз = результат
}
println("value = $value") // ввели 5 -> value = 5
// ввели "hi" -> повідомлення + value = 10
}
Зверніть увагу: run { ... } тут — не «scope‑функція обʼєкта», а просто блок‑вираз. Ми не звертаємося до this, бо нам це не потрібно. Нам потрібні «кілька рядків коду, які обчислюють значення».
Цей самий прийом чудово працює і для «раннього виходу» з main, якщо користувач не дав обовʼязкових даних.
fun main() {
val textOrNull: String? = readln().trim().takeIf { it.isNotBlank() }
val text: String = textOrNull ?: run {
println("Порожній ввід — аналізувати нічого. Завершуюся.")
return
}
println("Будемо аналізувати: $text") // якщо дійшли сюди, text точно не порожній
}
Зауважте, як читабельно виходить: зліва ми описали «нормальний шлях», справа — «що робимо, якщо даних немає». Без вкладених if, без додаткового рівня відступів.
6. Заготовка для консольного Text Analyzer
Зараз ми зробимо маленький, але дуже практичний крок у бік майбутнього Text Analyzer: навчимося читати обовʼязковий текст і опційні налаштування так, щоб main був схожий на сценарій, а не на «комбінат перевірок». Ми поки не рахуємо частоти й не будуємо top‑N — це буде в наступній лекції. Але підготуємо введення так, щоб потім було легко додати пайплайн.
Спочатку зробімо утиліту: прочитати непорожній рядок або повернути null. Вона маленька, але вже знімає половину болю.
fun readNonBlankLineOrNull(prompt: String): String? {
print(prompt)
return readln().trim().takeIf { it.isNotBlank() }
}
fun main() {
val text: String = readNonBlankLineOrNull("Введіть текст для аналізу: ") ?: run {
println("Порожньо. Отже, сьогодні без аналізу.")
return
}
println("Ок, текст прийнято: \"$text\"")
}
Тепер додамо «налаштування»: користувач може ввести topN, а може лишити порожнім. Якщо порожньо або «сміття» — беремо значення за замовчуванням, наприклад 5. Тут нам знадобляться ?.let і Elvis.
fun readIntOrNull(prompt: String): Int? {
print(prompt)
return readln().trim().toIntOrNull()
}
fun main() {
val text: String = readNonBlankLineOrNull("Введіть текст для аналізу: ") ?: run {
println("Порожньо. Отже, сьогодні без аналізу.")
return
}
val topN: Int = readIntOrNull("Скільки слів показати (Enter = 5): ")
?.takeIf { it > 0 }
?: 5
println("Текст: \"$text\"") // приклад: Текст: "kotlin is great"
println("topN = $topN") // приклад: topN = 5
}
І ось тут важливо відчути «смак» підходу: topN на виході не nullable. Отже, далі, у наступних лекціях, ви спокійно використовуватимете topN без if (topN != null).
Якщо ви зараз подумали «а можна коротше?», то так, можна. Але наша мета — не мінімальна довжина рядка, а читабельний сценарій.
7. Де закривати null, а де поліпшувати читабельність
Дуже типова помилка новачка — випадково протягнути nullable надто далеко, а потім дивуватися, що Kotlin «скрізь просить ?.». Насправді Kotlin не шкідничає: він чесно нагадує, що ви самі не визначилися, що означає «значення немає».
Є два здорові варіанти.
- Варіант 1. Ви свідомо зберігаєте T? далі, бо «відсутність значення» — нормальний стан. Наприклад, у налаштуваннях: користувач може не вказати фільтр — і тоді фільтра немає. У цьому випадку nullable — частина контракту, і це нормально.
- Варіант 2. Ви «закриваєте» nullable конкретною поведінкою: значенням за замовчуванням, повідомленням + return, або «падаєте» (але це вже ближче до require/check і винятків — сьогодні ми обережні).
Практична підказка: якщо функція за змістом має «дати результат, щоб сценарій міг продовжитися», намагайтеся повертати не nullable, а всі nullable‑штуки ховати всередині. Наприклад:
fun readPositiveIntOrDefault(prompt: String, default: Int): Int {
print(prompt)
val n: Int? = readln().trim().toIntOrNull()
return n?.takeIf { it > 0 } ?: default
}
fun main() {
val topN = readPositiveIntOrDefault("Top N (Enter = 5): ", default = 5)
println("topN = $topN") // Enter -> topN = 5
}
Nullable‑ланцюжки — класні, доки вони залишаються ланцюжками з 2–4 кроків. Але Kotlin дозволяє написати й 14 кроків в один рядок. І ось тоді починаються «криптограми»: код формально правильний, але його страшно чіпати.
Є простий критерій: якщо ви всередині let починаєте писати мініалгоритм або зʼявляється другий вкладений let — краще зупинитися й зробити код трохи більш «пласким».
Наприклад, так читати важко (особливо новачку):
fun main() {
val result = readln().trim().takeIf { it.isNotBlank() }
?.let { it.toIntOrNull() }
?.let { it * 10 }
?: 0
println(result)
}
Так, це працює. Але з першого погляду не дуже зрозуміло, що відбувається на кожному кроці. Краще дати значенням імена:
fun main() {
val raw: String = readln()
val token: String? = raw.trim().takeIf { it.isNotBlank() }
val n: Int? = token?.toIntOrNull()
val result: Int = n?.let { value -> value * 10 } ?: 0
println(result) // ввели "7" -> 70
}
Цей варіант довший на кілька рядків, зате його можна пояснювати людині вголос, не запинаючись.
Ще один маленький лайфхак: якщо в ланцюжку є it, і ви відчуваєте, що «it уже не той» — просто назвіть параметр.
val message: String = readIntOrNull("Введіть вік: ")
?.let { age -> "Вам $age років" }
?: "Вік не розпізнано"
println(message)
З age читати легше, ніж із «пʼятим it за день».
8. Типові помилки
Помилка №1: написати x?.let { ... } і забути, що результат усе ще може бути null.
Це трапляється постійно: людина радіє, що прибрала if, а потім дивується, що далі знову потрібні ?. і ?:. Ліки прості: якщо вам далі потрібен не nullable результат, одразу закривайте ланцюжок Elvis‑оператором і обирайте конкретну поведінку.
Помилка №2: використовувати !! там, де потрібен Elvis.
!! — це «я присягаюся, тут точно не null, чесне програмістське». Іноді це виправдано, але в користувацькому введенні — майже ніколи. Якщо значення може бути відсутнім, краще використати ?: зі значенням за замовчуванням або ?: run { ...; return }. Тоді програма поводиться передбачувано, а не падає при першому ж «котик» замість числа.
Помилка №3: намагатися зробити fallback багаторядковим без run { ... } і випадково отримати Unit замість значення.
Коли ви справа від ?: викликаєте println(...), треба памʼятати, що println повертає Unit. Тому вираз на кшталт val x = n ?: println(...) або не скомпілюється, або призведе до дивних типів. Якщо потрібно і надрукувати, і повернути значення — обгортайте в run { ... }.
Помилка №4: перетворювати ланцюжки на «магічний конвеєр», якого ніхто не розуміє.
Scope‑функції та nullable‑ланцюжки — інструмент читабельності, а не змагання з мініфікації. Якщо ланцюжок став довгим, якщо it повторюється й плутається, якщо ви самі не впевнені, який тип зараз «тече» трубою — зупиніться: введіть проміжні val або винесіть крок в іменовану функцію. Код має бути зрозумілий не лише компілятору, а й вам завтра зранку.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ