JavaRush /Курси /Kotlin SELF /Контракти scope‑функцій: let/run/apply/also/with

Контракти scope‑функцій: let/run/apply/also/with

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

1. Вступ

Scope‑функції — це «маленький договір» між вами та стандартною бібліотекою: «ось обʼєкт, давайте я виконаю блок коду поруч із ним, а ви поверніть мені або результат цього блока, або сам обʼєкт». Kotlin окремо виділяє в стандартній бібліотеці набір таких функцій: let, run, with, apply, also.

Найчастіша причина, чому новачкам спершу вони не до вподоби, проста: може здаватися, ніби це якась особлива конструкція мови. Насправді ж це звичайні функції (так, із трохи хитрими сигнатурами та лямбдами). Тобто компілятор не вмикає «режим чаклунства» — він лише допомагає вам компактніше записати те, що ви й так могли б написати звичайним кодом.

На практиці це корисно в трьох ситуаціях: коли ви хочете «протягнути» значення через ланцюжок обчислень; коли потрібно налаштувати обʼєкт кількома діями поспіль; і коли треба вставити побічний крок (наприклад, друк для налагодження), не ламаючи основної логіки.

2. Дві осі вибору: this або it і що повертається

Щоб scope‑функції не перетворювалися на лотерею «пальцем у небо», їх зручно запамʼятовувати не за назвами, а за двома осями. Це як обирати взуття: спочатку вирішуємо, для чого воно (біг/офіс/по калюжах), а потім — розмір. Тут логіка схожа: спершу вирішуємо, як усередині блока звертатися до обʼєкта, а потім — що має повернутися назовні.

Перша вісь: як обʼєкт доступний усередині блока. Або як it (явний параметр лямбди), або як this (неявний «одержувач», receiver). it зазвичай робить код «чеснішим»: ви бачите, що працюєте з параметром. this робить код компактнішим, але іноді приховує контекст — особливо у вкладених блоках.

Друга вісь: що повертає виклик scope‑функції. Або повертається результат останнього виразу блока, або початковий обʼєкт. Саме це й є ключ до вибору. Адже більшість помилок зі scope‑функціями виглядає так: «я хотів отримати число/рядок/список, а натомість отримав назад початковий обʼєкт» (або навпаки).

3. Шпаргалка за контрактами: this/it і результат

Перш ніж розбирати кожну функцію окремо, корисно один раз побачити таблицю. Далі ми її «оживимо» прикладами.

Функція Як усередині блока видно обʼєкт Що повертається з виклику
let
it
результат блока
also
it
початковий обʼєкт
run (як obj.run { })
this
результат блока
apply
this
початковий обʼєкт
with(obj) { }
this
результат блока

Зверніть увагу: with(obj) { ... } відрізняється тим, що ви передаєте обʼєкт аргументом (не через крапку), але всередині блока він однаково стає this. За змістом with дуже близький до run.

4. Функції за змістом: перетворити, налаштувати, вставити побічний крок

let: обʼєкт як it, назовні — результат обчислення

let зазвичай «заходить» першим, бо він дуже схожий на звичну ідею «взяли значення → перетворили → отримали нове значення». Якщо ви раніше писали щось на кшталт «прочитав рядок, обрізав пробіли, порахував довжину», то let допомагає оформити це як невелике перетворення без зайвого проміжного val, але водночас і без нечитабельного «клубка».

Усередині let обʼєкт доступний як it. Це добре, коли блок короткий: буквально 1–3 вирази. А назовні повертається результат останнього виразу блока — тобто let чудово підходить для обчислень.

Приклад: «взяли рядок → отримали довжину після trim()».

fun main() {
    val raw = "   Kotlin  "
    val len = raw.let { it.trim().length }

    println(len) // 6
}

Зверніть увагу на відчуття: raw не змінюється, а len — це новий результат. Це доволі «функціональна» манера: менше мутацій, більше явних перетворень.

Ще приклад: «взяли рядок → зробили нормалізований варіант». Поки без складної токенізації — просто покажемо саму ідею перетворення.

fun main() {
    val input = "  HeLLo, KoTLiN  "
    val normalized = input.let { it.trim().lowercase() }

    println(normalized) // hello, kotlin
}

Тут let допомагає тримати «взяв → зробив» в одній точці.

run: обʼєкт як this, назовні — результат обчислення

Після let логічний наступний крок — run у формі obj.run { ... }. За змістом він дуже схожий на let: теж повертає результат обчислення, але всередині блока обʼєкт доступний як this. Це зручно, коли ви хочете викликати кілька методів або властивостей обʼєкта поспіль і не хочете щоразу писати it.

Важливо: сьогодні ми говоримо саме про obj.run { ... }, тобто про виклик через крапку. (Є ще run { ... } без обʼєкта — це окрема історія; до неї ми обережно підійдемо в наступній лекції.)

Приклад: візьмемо рядок і отримаємо «останній символ після обрізання пробілів».

fun main() {
    val raw = " Kotlin "
    val lastChar = raw.trim().run { last() }

    println(lastChar) // n
}

Чому тут run приємний: last() виглядає як «рідний» виклик рядка. Так, це дрібниця, але саме з таких дрібниць і складається читабельність ланцюжків.

Ще приклад — зі StringBuilder. Нам потрібно зібрати рядок і повернути саме String, а не StringBuilder. Це класичний кейс «хочу результат блока».

fun main() {
    val text = StringBuilder().run {
        append("Hello")
        append(", ")
        append("Kotlin")
        toString()
    }

    println(text) // Hello, Kotlin
}

Тут важлива думка: run повертає те, що стоїть останнім. Тож якщо останнім стоїть toString(), назовні вийде рядок.

with: майже як run, але без крапки

with часто викликає запитання: «навіщо він потрібен, якщо є run?» Відповідь прагматична: with зручно читати, коли обʼєкт уже має окреме імʼя, а ви хочете виконати над ним набір дій «у контексті», але не оформлювати це як ланцюжок викликів через крапку.

Формально with — це не extension‑виклик (obj.with), а звичайна функція (with(obj)). Але всередині блока обʼєкт доступний як this, і назовні повертається результат блока. Тобто за контрактом він дуже схожий на run.

Приклад: у нас є StringBuilder sb, і ми хочемо в одному місці зібрати текст та отримати рядок.

fun main() {
    val sb = StringBuilder()

    val result = with(sb) {
        append("Text ")
        append("Analyzer ")
        append("v0")
        toString()
    }

    println(result) // Text Analyzer v0
}

Якщо порівняти з sb.run { ... }, різниця здебільшого стилістична. Часто обирають так: якщо вже є obj. ліворуч — беруть run; якщо хочеться «обрамити» дії навколо обʼєкта — беруть with.

apply: обʼєкт як this, назовні — той самий обʼєкт

apply — це про налаштування обʼєкта. Уявіть, що ви купили меблі з IKEA (або, якщо пощастило, просто стілець) й хочете його «зібрати»: прикрутити ніжки, наклеїти наліпку «коту не сідати», поставити на місце. apply — це як «зібрав → повернув той самий стілець назад».

Контракт apply простий: усередині блока обʼєкт — this, а назовні повертається початковий обʼєкт, уже «налаштований». Тому apply ідеальний, коли ви хочете в одному виразі створити обʼєкт і підготувати його до використання.

Класичний приклад — StringBuilder, але тепер нам важливий саме обʼєкт, бо ми хочемо продовжити з ним роботу (або викликати toString() окремо).

fun main() {
    val sb = StringBuilder().apply {
        append("Hello")
        append(", ")
        append("apply")
    }

    println(sb.toString()) // Hello, apply
}

Тонкість, на якій багато хто спотикається: якщо ви зробите val s = StringBuilder().apply { ... }, то s матиме тип StringBuilder, а не String. Це не помилка Kotlin — ви просто обрали контракт «поверни обʼєкт».

Ще приклад: часто потрібно підготувати якийсь «звіт» як багаторядковий рядок. Ми ще не будуємо повний пайплайн, але вже можемо показати, як зручно налаштовувати StringBuilder через apply.

fun main() {
    val report = StringBuilder().apply {
        appendLine("Text Analyzer")
        appendLine("--------------")
        appendLine("Status: OK")
    }.toString()

    println(report)
    // Text Analyzer
    // --------------
    // Status: OK
}

Зверніть увагу: тут ми все-таки хочемо рядок, тому після apply викликаємо toString(). Це хороший стиль: apply — для налаштування, а окремий крок — для отримання фінального представлення.

also: обʼєкт як it, назовні — той самий обʼєкт

Якщо apply — це «налаштувати обʼєкт», то also — «зробити щось додатково дорогою». Найчастіше це побічний ефект: вивести в консоль, записати лог, перевірити інваріант, порахувати щось для налагодження. І при цьому не зламати основне значення, яке «тече» далі.

Контракт also такий: усередині блока обʼєкт доступний як it, а назовні повертається початковий обʼєкт. Чому it, а не this? Бо в also часто хочеться явно бачити, що саме ви «підглядаєте» або друкуєте.

Приклад: сортуємо список і хочемо побачити «до/після», не змінюючи основного результату.

fun main() {
    val xs = listOf(3, 1, 2)
        .also { println("before: $it") }   // before: [3, 1, 2]
        .sorted()
        .also { println("after: $it") }    // after: [1, 2, 3]

    println(xs) // [1, 2, 3]
}

Важливий момент: also не має бути місцем, де живе «головна логіка». Якщо у вас усередині also є обчислення, від якого залежить результат, — це тривожний дзвіночок. also добрий саме як «бічна кишеня»: дістали ліхтарик, підсвітили, поклали назад і пішли далі.

5. Міні‑правило вибору і приклад Text Analyzer v0

Зараз буде корисна «чесна шпаргалка», але сформулюємо її не списком, а як думку: спершу запитайте себе, ви хочете отримати нове значення чи повернути той самий обʼєкт. Якщо вам потрібне нове значення, майже завжди ви у світі let/run/with. Якщо ви хочете повернути той самий обʼєкт, ви у світі apply/also.

Далі уточнюєте стиль усередині блока. Якщо блок короткий і хочеться більшої явності — беріть варіант із it (let для перетворення, also для побічного кроку). Якщо блок схожий на «налаштування» з багатьма зверненнями до методів або властивостей, частіше зручніше this (run для обчислення результату або apply для налаштування обʼєкта).

Давайте обережно почнемо наш навчальний «Text Analyzer v0». Поки без складних розборів, токенів і частот: сьогодні ми лише зробимо невелику заготовку, у якій scope‑функції виглядатимуть природно.

fun main() {
    println("Введіть текст:")
    val input = readln()

    val normalized = input
        .let { it.trim() }
        .also { println("Обрізано: '$it'") } // наприклад: Обрізано: 'Hello'

    val header = StringBuilder().apply {
        appendLine("Text Analyzer v0")
        appendLine("Length: ${normalized.length}")
    }.toString()

    print(header)
    // Text Analyzer v0
    // Length: 5
}

Важливо помітити це саме з погляду контрактів, а не «краси». let повертає результат обчислення, тому ми отримали рядок після trim() і продовжили працювати вже з ним. also не змінює значення, зате дає змогу вставити «налагоджувальний друк» дорогою. apply налаштував StringBuilder, але повернув усе ще StringBuilder, тому ми окремо викликали toString().

Ми не намагалися «впхнути» сюди весь аналіз тексту — це буде трохи пізніше. Сьогодні завдання простіше: навчитися бачити, що повернеться з кожної функції, і не плутати «перетворення» з «налаштуванням».

6. Типові помилки під час вибору let/run/apply/also/with

Помилка №1: переплутали apply і run, а потім здивувалися типу результату.
Зазвичай це виглядає так: ви хотіли отримати рядок, написали налаштування StringBuilder і очікуєте, що назовні вийде String. Але apply за контрактом повертає обʼєкт, тобто StringBuilder. Лікується це не порадою «допишіть as String» (так робити не треба), а правильним вибором: або використовуйте run, якщо хочете повернути обчислений результат, або залиште apply, але додайте явний крок toString().

Помилка №2: спроба робити «основну роботу» всередині also.
also легко перетворюється на смітник: «ну раз уже тут блок, то давайте ще тут порахуємо, тут ще відфільтруємо…». У підсумку код стає нечитабельним: зовні здається, що це побічний крок, а всередині — половина бізнес‑логіки. Добре правило: якщо без also результат програми змінюється, значить, ви використовуєте also не за призначенням. Нехай also лишається для друку, логування та легкої діагностики.

Помилка №3: надто довгі блоки й втрата читабельності.
Scope‑функції мають спрощувати читання, а не створювати «матрьошку з лямбд». Якщо ваш блок у let/run/apply/with/also розрісся до 15 рядків, то майже напевно там зʼявилася логіка, що заслуговує на окрему функцію з нормальним іменем. І так, це той самий випадок, коли «зайвий рядок коду» робить програму зрозумілішою, а не гіршою.

Помилка №4: плутанина через вкладені it.
Коли ви пишете something.let { ... map { ... } ... }, зʼявляється два it, і мозок починає працювати як компілятор: «так, який it зараз де?». Kotlin цього не забороняє, але читати важко. У таких місцях краще чесно назвати параметри: let { text -> ... }, map { token -> ... }. Це не «багатослівність», а страховка від логічних помилок.

Помилка №5: використовувати with і run упереміш без причини.
І with(obj) {}, і obj.run {} дають this і повертають результат блока. Якщо сьогодні ви пишете в одному стилі, а завтра — в іншому, код починає виглядати «рваним». Оберіть звичку: або частіше obj.run {}, або частіше with(obj) {} — і дотримуйтеся її хоча б у межах одного проєкту. Так читачеві не доведеться щоразу згадувати, чому тут раптом with.

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