JavaRush /Курси /Kotlin SELF /Робота з локальними функціями

Робота з локальними функціями

Kotlin SELF
Рівень 10 , Лекція 4
Відкрита

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

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ