1. Вступ
Коли ви пишете програму-скрипт, зазвичай усе починається невинно: main(), пара readln(), пара println() — і ви відчуваєте себе володарем консолі. Але минає 20 хвилин, і в main зʼявляється «ще трішки логіки»: trim(), перевірки, indexOf, substring, повторювані фрагменти «якщо порожньо — помилка». І ось уже функція виглядає як дипломна робота на тему «як загубити нитку міркувань за 30 рядків».
Проблема не в тому, що 30 рядків — це багато. Проблема в іншому: ці рядки часто одночасно про різні рівні абстракції. Десь ви розвʼязуєте задачу («розпарсити покупку»), а десь воюєте з деталями («перевірити межі підрядка»). У результаті ви читаєте код як детектив без перших десяти сторінок: багато всього відбувається, але чому — незрозуміло.
Згадуємо локальні функції
Локальна функція — це функція, оголошена всередині іншої функції. Її видно лише в межах зовнішньої функції, тобто ззовні вона «ніби не існує». У Kotlin це нормальна, офіційна можливість мови: локальна функція може приймати параметри, повертати значення й викликати інші функції — тобто бути повноцінною функцією, просто з обмеженою областю видимості.
Головна ідея локальної функції дуже практична: якщо «помічник» потрібен лише в одному місці, логічно тримати його поряд із цим місцем, а не перетворювати файл на музей допоміжних утиліт.
Мінімальний приклад «за формою»:
fun main() {
fun hello(name: String): String {
return "Привіт, $name!"
}
println(hello("Kotlin")) // Привіт, Kotlin!
}
Так, виглядає трохи дивно, адже main() — не найтиповіше місце для локальних функцій. Але технічно це працює так само, як у будь-якій іншій функції.
Чому не завжди підходить private fun на рівні файлу
Коли ви навчилися писати функції, зʼявляється спокуса будь-яку повторювану логіку винести «вгору» файлу як private fun. Це часто гарне рішення, і ми його не забороняємо — навпаки, private допомагає тримати поверхню API чистою, не показуючи зовнішньому світові те, що йому не потрібно.
Але є тонкість: private fun на початку файлу все одно стає «сусідом» для всього іншого коду. Якщо таких утиліт багато, файл перетворюється на комірчину: наче й корисно, але знайти щось важко.
Локальна функція доречна, коли виконуються дві умови.
Перша: функція-помічник справді стосується лише одного сценарію й більше ніде не потрібна.
Друга: імʼя помічника саме по собі не має цінності на рівні всього проєкту. Наприклад, safePart, norm, parseLeft — чудові імена в контексті однієї функції, але як «глобальні» імена файлу вони можуть звучати надто загально.
Де оголошувати локальні функції?
Це одне з тих питань, які насправді про читабельність. Якщо оголосити локальну функцію прямо перед використанням, ви читаєте код лінійно: «ось помічник — ось застосування». Але якщо помічників кілька, ви ризикуєте отримати «драбинку» з оголошень, розкиданих по всій функції.
На практиці найчастіше зручно оголошувати локальні функції на початку зовнішньої функції — одразу після створення ключових змінних. Тоді структура читання стає схожою на хорошу статтю: спочатку «словничок термінів», потім основний текст.
Порівняймо два стилі на маленькому прикладі форматування імені користувача.
Варіант «помічник угорі»:
fun formatUser(first: String, last: String): String {
fun norm(s: String): String {
val t = s.trim()
if (t.isEmpty()) return "-"
return t
}
return norm(first) + " " + norm(last)
}
Варіант «помічник поруч» (теж нормальний — просто інший):
fun formatUser(first: String, last: String): String {
val a = first.trim()
val b = last.trim()
fun show(s: String) = if (s.isEmpty()) "-" else s
return show(a) + " " + show(b)
}
Обидва варіанти працюють. У першому norm виглядає як частина контракту функції formatUser: «усередині все нормалізуємо». У другому show виглядає як дрібна деталь форматування прямо поряд із даними. З часом ви виробите свій стиль, але на старті краще триматися правила: якщо помічник важливий для розуміння всієї функції — оголошуйте його ближче до початку.
Локальні функції: параметри та повернення
Є дуже важливий момент, який підвищує читабельність: локальна функція не зобовʼязана використовувати змінні зовнішньої функції. Так, вона може так робити (і це інколи зручно), але сьогодні ми не будемо занурюватися в цю тему глибоко — далі за планом буде окрема лекція про замикання та ризики мутабельності.
Поки тримайте думку: якщо локальній функції потрібен вхід — дайте параметр. Якщо потрібен результат — поверніть значення. Тоді залежності видно прямо в сигнатурі, і ви не перетворюєте код на квест «звідки взялося це значення».
Невеличкий приклад: є число, і ми хочемо друкувати його «гарно», але правило форматування — локальна деталь.
fun printScore(score: Int) {
fun label(x: Int): String {
if (x >= 100) return "легенда"
if (x >= 50) return "профі"
return "новачок"
}
println("score=$score (${label(score)})") // score=42 (новачок)
}
Локальна функція label отримує x параметром. Ми могли б використовувати score напряму, але тоді функція стала б менш універсальною навіть у межах цієї маленької задачі. І читати її було б складніше: складається враження, ніби вона залежить від зовнішнього стану.
2. Практичний приклад: парсинг MiniReceipt
Базова версія: Pair<String, Int>?
Зараз зберемо невеликий, але цілісний приклад, який можна розвивати далі. Нехай наша мініпрограма «MiniReceipt» читає рядок виду:
Coffee, 199
І друкує «чек»:
Покупка: Coffee
Ціна: 199
Ми навмисно беремо простіший формат: одна назва й одна ціна, розділені комою. Наша мета сьогодні — не розібрати всі формати введення у світі, а показати, як локальні функції допомагають зробити розбір рядка читабельним і стійким до зайвих пробілів.
Почнемо з функції, яка повертає Pair<String, Int>?: або «назва + ціна», або null, якщо рядок некоректний.
fun parseTitleAndPrice(line: String): Pair<String, Int>? {
fun norm(s: String): String = s.trim()
val comma = line.indexOf(',')
if (comma == -1) return null
val title = norm(line.substring(0, comma))
val priceText = norm(line.substring(comma + 1))
val price = priceText.toIntOrNull() ?: return null
if (title.isEmpty() || price < 0) return null
return title to price
}
Тепер main, який використовує цю функцію та деконструює Pair:
fun main() {
val line = readln()
val parsed = parseTitleAndPrice(line)
if (parsed == null) {
println("Помилка: очікую на рядок формату \"Coffee, 199\"") // наприклад
return
}
val (title, price) = parsed
println("Покупка: $title") // Покупка: Coffee
println("Ціна: $price") // Ціна: 199
}
Поки що все виглядає непогано. Але давайте додамо «шкідливого користувача» (тобто реального). Він уведе щось на кшталт:
, 199
Або:
Coffee,
Або:
Coffee, 199, extra
Або ще веселіше: рядок може бути порожнім, або кома може стояти в самому кінці.
І ось тут ви зазвичай починаєте додавати перевірки меж, перевірки на порожнечу, знову trim(), знову trim() — і програма перетворюється на кашу. Саме час написати локальну функцію, яка ізолює «небезпечні» деталі.
Безпечні деталі: safeSubstringOrNull
Зробімо локального помічника safeSubstringOrNull. Він перевірятиме межі й повертатиме null, якщо щось не так.
fun parseTitleAndPrice(line: String): Pair<String, Int>? {
fun norm(s: String) = s.trim()
fun safeSubstringOrNull(s: String, from: Int, to: Int): String? {
if (from < 0) return null
if (to < 0) return null
if (from > to) return null
if (to > s.length) return null
return s.substring(from, to)
}
val comma = line.indexOf(',')
if (comma == -1) return null
val rawTitle = safeSubstringOrNull(line, 0, comma) ?: return null
val rawPrice = safeSubstringOrNull(line, comma + 1, line.length) ?: return null
val title = norm(rawTitle)
val priceText = norm(rawPrice)
val price = priceText.toIntOrNull() ?: return null
if (title.isEmpty() || price < 0) return null
return title to price
}
Так, коду стало більше. Але тепер він став «шаруватим»: угорі — сценарій («знайшли кому, вирізали частини»), нижче — механізм безпеки (safeSubstringOrNull). Якщо завтра ви вирішите ще десь усередині цієї функції безпечно різати рядок, ви вже не копіюватимете перевірки, а просто використаєте помічника.
Як не перетворити код на «матрьошку»
Локальні функції легко полюбити, а потім… трохи зловживати. Є небезпека зробити «функцію всередині функції всередині функції» й гордо сказати: «Дивіться, яка інкапсуляція!» — а потім самому не зрозуміти, звідки що викликається.
Є гарне практичне правило: локальна функція має бути маленьким помічником, а не другим сценарієм. Якщо локальна функція починає містити багато розгалужень, кілька рівнів перевірок і ще пару локальних функцій усередині — це сигнал, що ви або неправильно вибрали межу, або настав час винести частину логіки в окрему private fun на рівні файлу.
Ще один важливий момент: локальна функція найчастіше має мати одне зрозуміле завдання. У нашому прикладі safeSubstringOrNull робить тільки «перевірити межі та вирізати». Вона не форматує результат, не друкує помилки й не намагається вгадати, що ви хотіли ввести. Чим простіший обовʼязок, тим легше довіряти помічнику.
Невеличка таблиця, щоб закріпити різницю відчуттів під час читання:
| Ситуація | Як зазвичай виглядає «погано» | Як зазвичай виглядає «краще» |
|---|---|---|
| Повторюються перевірки | У трьох місцях по 4 if підряд | Один локальний помічник, основний код читається як сценарій |
| Помічник потрібен тільки тут | private fun на рівні файлу із «загальним» іменем | Локальна функція всередині зовнішньої, імʼя доречне в контексті |
| Помічник розростається | Локальна функція на 40 рядків, половина логіки в ній | Винести назовні в окрему private fun або розділити на два помічники |
Область видимості та імена: затінення
З локальними функціями приходить новий різновид «ой»: ви можете випадково назвати параметр локальної функції так само, як змінну ззовні. Це називається затіненням (shadowing): усередині локальної функції імʼя посилатиметься на параметр, а не на зовнішню змінну. Код компілюється, але читається гірше, бо мозок постійно запитує: «Так, а x зараз який?»
Покажу на маленькому прикладі. Це не помилка компіляції — це помилка читабельності:
fun demo() {
val price = 100
fun addTax(price: Int): Int {
return price + 20
}
println(addTax(50)) // 70
println(price) // 100
}
Формально все гаразд. Але коли ви бачите addTax(price), доводиться додатково вчитуватися: це зовнішній price чи параметр? Тому краще обирати різні імена. Наприклад, зовнішній price, а параметр basePrice; або зовнішній line, а параметр text.
І ще один нюанс: локальна функція видна лише всередині зовнішньої, а отже ви не зможете «випадково» використати її десь в іншому місці файлу. Це добре: менше шансів, що код почне залежати від утиліти, яку взагалі-то створили «на один раз».
Схема: як сценарій викликає локальні кроки
Іноді корисно не тільки читати код, а й бачити його як послідовність кроків. Зовнішня функція — це сценарій, локальні функції — це підкроки, які ховають деталі. Якщо ви колись писали рецепт їжі, це схоже на ситуацію: «у рецепті написано “приготуйте соус”, а деталі соусу — окремим блоком трохи нижче».
Ось як виглядає наш розбір рядка покупки у вигляді простої схеми:
flowchart TD
A["parseTitleAndPrice(line)"] --> B["Знайти кому indexOf(',')"]
B -->|немає коми| X[return null]
B -->|є кома| C[Вирізати rawTitle / rawPrice]
C --> D[local safeSubstringOrNull: перевірки меж + substring]
D -->|помилка меж| X
C --> E["local norm: trim()"]
E --> F["priceText.toIntOrNull()"]
F -->|null| X
F --> G[Перевірити: title не порожній, price >= 0]
G -->|не ок| X
G --> H[return title to price]
Якщо схема читається легше, ніж код, — це нормальна стадія. З часом ви почнете бачити такі «кроки» прямо під час читання, і локальні функції якраз допомагають тримати ці кроки відокремленими від низькорівневої метушні.
3. Типові помилки під час роботи з локальними функціями
Помилка №1: локальна функція стає «другим main».
Іноді ви починаєте з хорошого помічника на 5 рядків, а потім «трішки допишу» — і ось у локальній функції вже розгалуження, форматування, перевірки, і ще пара локальних функцій усередині. Зовнішня функція в підсумку перетворюється на тонку оболонку, а вся логіка — всередині помічника. Це ламає ідею: помічник має допомагати, а не «переїжджати жити» замість господаря.
Помилка №2: локальна функція не має чіткої відповідальності.
Коли помічник робить і «відрізати підрядок», і «нормалізувати», і «спробувати розпарсити», і «сформувати повідомлення про помилку», код починає поводитися як швейцарський ніж: наче корисно, але незрозуміло, де яка грань. Набагато приємніше читати, коли помічник робить одну річ: safeSubstringOrNull — лише про межі, norm — лише про trim().
Помилка №3: приховані залежності від зовнішніх змінних.
Kotlin дозволяє локальній функції використовувати змінні зовнішньої функції (це легально і часто зручно). Але якщо помічник починає залежати від кількох зовнішніх var, поведінка стає неочевидною: за сигнатурою не видно, від чого залежить результат. На цьому етапі краще тримати дисципліну: якщо значення важливе — передайте його параметром. А тонші випадки (коли локальна функція захоплює зовнішні змінні й що з цього випливає) ми розберемо в наступній лекції.
Помилка №4: затінення імен і плутанина «яка змінна зараз використовується».
Коли ззовні val line, а всередині fun parse(line: String), мозок починає працювати як компілятор, але без оптимізацій. Код не стає неправильним — він стає важким. Просте правило рятує: не повторюйте імена зовнішніх змінних у параметрах і локальних змінних, особливо у вкладених функціях.
Помилка №5: локальні функції розкидані по тілу та ламають лінійність читання.
Якщо помічника оголошено в середині функції, потім ще одного — ближче до кінця, а тоді ви повертаєтеся вгору й намагаєтеся згадати, де що оголошено, читання перетворюється на «скрольний спорт». Найчастіше простіше оголошувати локальні функції на початку зовнішньої функції, щоб далі читати сценарій зверху вниз — без сюрпризів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ