1. Коли лямбда перетворюється на «обгортку заради обгортки»
Коли ви щойно опанували лямбди, може настати небезпечний період: хочеться загорнути лямбдою взагалі все. Це як купити шурупокрут і почати закручувати ним кришку пляшки. Формально працює, але на вас починають дивно дивитися навіть кіт і компілятор. Найчастіше проблема зʼявляється тоді, коли лямбда робить рівно одну дію — просто викликає іншу функцію.
Подивімося на типову ситуацію. У нас є функція-предикат isShort, і ми хочемо передати її у функцію вищого порядку, яка очікує (String) -> Boolean.
fun isShort(s: String): Boolean = s.length <= 3
fun findFirstOrNull(items: List<String>, predicate: (String) -> Boolean): String? {
for (s in items) if (predicate(s)) return s
return null
}
fun main() {
val words = listOf("kotlin", "go", "java")
val result = findFirstOrNull(words) { s -> isShort(s) } // лямбда-обгортка
println(result) // go
}
Тут лямбда { s -> isShort(s) } узагалі нічого нового не робить: вона просто «пересилає» аргумент у isShort. У таких випадках Kotlin дозволяє написати коротше і, як правило, читабельніше.
2. Що таке callable reference :: і як його читати
Callable reference — це спосіб перетворити наявну функцію (або інше «викличне», наприклад конструктор) на значення, яке можна передати як параметр. По суті, це «посилання на функцію» у вигляді значення, сумісного з типом функції. Kotlin розглядає callable references як один зі способів «отримати екземпляр типу функції» — поруч із лямбдами.
Синтаксис має такий вигляд:
- ::isShort — посилання на функцію isShort
- String::toInt — посилання на функцію/метод, який можна викликати в String (поки сприймайте це як «готове перетворення рядка на число»)
- ::Regex — посилання на конструктор (згадаємо побіжно, без заглиблення)
Нас сьогодні цікавить насамперед перша форма: ::імʼяФункції.
Як читати ::
Зручний мисленнєвий переклад:
- { x -> f(x) } читається як «лямбда, яка викликає f»
- ::f читається як «сама функція f як значення»
Тобто ми не викликаємо f — ми передаємо її.
Схематично (проста «мапа в голові»):
flowchart LR
A["Лямбда-обгортка
{ x -> f(x) }"] --> B["Callable reference
::f"]
B --> C["Функція вищого порядку
очікує (T)->R"]
3. Заміна { x -> f(x) } на ::f
У цьому розділі ми акуратно перейдемо від «ну так, ніби коротше» до розуміння: коли це справді те саме, а коли — ні. Важливо не потрапити в пастку: :: — не «магічне скорочення», а сувора штука, яку компілятор перевіряє типами.
Повернімося до прикладу й перепишімо його на callable reference:
fun isShort(s: String): Boolean = s.length <= 3
fun findFirstOrNull(items: List<String>, predicate: (String) -> Boolean): String? {
for (s in items) if (predicate(s)) return s
return null
}
fun main() {
val words = listOf("kotlin", "go", "java")
val result = findFirstOrNull(words, ::isShort)
println(result) // go
}
Ключовий момент: ::isShort підходить, бо сигнатура збігається:
- очікується (String) -> Boolean
- isShort має вигляд (String) -> Boolean
Саме тому компілятор спокійно «підставляє» функцію туди, де потрібна лямбда.
4. Збіг сигнатури: чому :: іноді не компілюється
Із callable references є одна хороша новина і одна… теж хороша. Просто спершу вона може трохи дратувати.
Хороша новина: якщо ::f компілюється, значить за типами все збіглося.
Друга хороша новина: якщо не компілюється — компілятор вас урятував, бо ви намагалися передати «не ту поведінку». І краще дізнатися про це зараз, ніж через дві години налагодження та філософські запитання до життя.
Не збіглася кількість параметрів
Уявімо, що ми хочемо передати функцію, яка приймає два аргументи, туди, де очікується один.
fun startsWithPrefix(s: String, prefix: String): Boolean = s.startsWith(prefix)
fun usePredicate(items: List<String>, predicate: (String) -> Boolean): Int {
var count = 0
for (x in items) if (predicate(x)) count++
return count
}
fun main() {
val words = listOf("kotlin", "koala", "java")
usePredicate(words, ::startsWithPrefix) // не скомпілюється: потрібно 1 параметр, а у функції 2
}
Тут ::startsWithPrefix має тип (String, String) -> Boolean, а очікується (String) -> Boolean. Компілятор чесно каже: «не збіглося».
Що робити? У цьому випадку callable reference не замінює лямбду, бо лямбда тут справді додає логіку: «зафіксувати prefix».
fun main() {
val words = listOf("kotlin", "koala", "java")
val prefix = "ko"
val count = usePredicate(words) { s -> startsWithPrefix(s, prefix) }
println(count) // 2
}
І це вже нормально: лямбда робить корисну роботу (підмішує зовнішній параметр).
Не збігся тип повертаного значення
Якщо очікується (String) -> Boolean, а ви намагаєтеся передати функцію (String) -> Int, Kotlin теж не дасть вам влаштувати хаос.
fun lengthOf(s: String): Int = s.length
fun usePredicate(items: List<String>, predicate: (String) -> Boolean): Int {
var count = 0
for (x in items) if (predicate(x)) count++
return count
}
fun main() {
val words = listOf("a", "bbb", "cc")
usePredicate(words, ::lengthOf) // не скомпілюється: Int не перетворюється на Boolean
}
Це не «прискіпливість мови», а базова безпека: предикат зобовʼязаний повертати true/false, а не «2, бо довжина 2». Хоча, зізнаймося, звучить як логіка зі сну програміста.
5. Callable reference як значення: можна покласти в val і викликати
Щоб відчути, що ::f — це саме значення, корисно один раз зробити «наочно»: зберегти посилання на функцію в змінну, а потім викликати.
Це ще й чудовий спосіб перестати плутати «передати» й «викликати».
fun normalize(s: String): String = s.trim().lowercase()
fun main() {
val f: (String) -> String = ::normalize
println(f(" HeLLo ")) // hello
}
Ми не викликаємо normalize напряму. Ми викликаємо f(...), а всередині f лежить посилання на normalize.
До речі, Kotlin у документації про лямбди й типи функцій перелічує callable references як окремий спосіб створити значення типу функції. Це рівно воно і є: «функція як значення».
6. Де :: покращує читабельність, а де заважає
З callable references легко впасти у дві крайнощі.
Перша крайність — писати лямбди-обгортки всюди, навіть якщо там один рядок f(x).
Друга крайність — замінювати на :: узагалі все підряд, навіть коли є додаткова логіка. Тоді код стає «надто розумним»: компактно, але не зовсім зрозуміло.
Щоб ухвалювати рішення, корисно тримати в голові просту табличку:
| Ситуація | Краще | Чому |
|---|---|---|
| Лямбда рівно викликає одну функцію без змін | ::f | Прибираємо шум { x -> f(x) } |
| Потрібно зафіксувати зовнішній параметр або додати перевірку | лямбда { x -> ... } | Це вже не «просто виклик», а логіка |
| Імʼя функції добре пояснює крок | ::f | Код самодокументується |
| Імʼя функції невдале або абстрактне (doIt, process) | частіше лямбда або перейменувати | Інакше ::doIt нічого не пояснює |
І так: якщо ви бачите в коді ::doIt, це майже завжди сигнал «потрібно перейменувати функцію», а не «потрібно зрозуміти філософію doIt».
7. Міні-приклад: робимо «псевдо-map» з ::
Ми ще не використовуємо стандартні операції колекцій map/filter/find як основний інструмент (це буде пізніше за планом). Тож зробімо навчальний аналог: функцію, яка застосовує перетворення до кожного елемента списку через звичайний for.
Спочатку напишемо функцію вищого порядку:
fun transformAll(items: List<String>, transform: (String) -> String): List<String> {
val result = mutableListOf<String>()
for (x in items) result.add(transform(x))
return result
}
Тепер уявімо, що ми хочемо нормалізувати введення користувача: обрізати пробіли й привести все до нижнього регістру.
Варіант 1: лямбда-обгортка (працює, але додає шум)
fun normalize(s: String): String = s.trim().lowercase()
fun main() {
val raw = listOf(" ADD ", " LiSt", " remove ")
val normalized = transformAll(raw) { x -> normalize(x) }
println(normalized) // [add, list, remove]
}
Варіант 2: callable reference (коротше й чесніше)
fun normalize(s: String): String = s.trim().lowercase()
fun main() {
val raw = listOf(" ADD ", " LiSt", " remove ")
val normalized = transformAll(raw, ::normalize)
println(normalized) // [add, list, remove]
}
Тут ::normalize — просто ідеальний варіант: він рівно описує крок і не містить «прихованої магії».
8. Вбудовуємо :: у CLI-застосунок витрат
Зараз ми зробимо маленьке, але реалістичне поліпшення того консольного застосунку, який ви будували днями, — про колекції та команди add/list/remove. У нас поки немає класів та обʼєктів, тому зберігатимемо витрати як список пар: Pair<String, Int>, де first — назва, а second — сума.
Ідея сьогоднішнього поліпшення проста: ми хочемо, щоб код «кроків обробки» був читабельним. Callable references — чудовий спосіб зробити кроки іменованими.
Базові функції застосунку
Спочатку накидаємо мінімальну основу (дуже коротко). Тут немає нічого нового — це просто контекст:
fun addExpense(expenses: MutableList<Pair<String, Int>>, title: String, amount: Int) {
expenses.add(title to amount)
}
fun printExpenses(expenses: List<Pair<String, Int>>) {
if (expenses.isEmpty()) {
println("Витрат поки немає") // Витрат поки немає
return
}
for ((title, amount) in expenses) println("$title: $amount")
}
Крок «нормалізація команди» як окрема функція
Раніше багато хто пише нормалізацію прямо в main, приблизно так: val cmd = readln().trim().lowercase(). Це нормально, але коли логіка зростає, хочеться винести цей крок в окрему функцію.
fun normalizeCommandLine(line: String): String = line.trim().lowercase()
І ось тут callable reference починає «грати»: ми можемо передавати цей крок як параметр, не розмазуючи логіку по main.
Функція вищого порядку «прочитай рядок і підготуй»
Зробімо просту утиліту: вона читає рядок, застосовує до нього перетворення й повертає результат. Це схоже на шаблон «прочитав → підготував → розпарсив», який ви вже використовували в темах про введення.
fun readPreparedLine(prepare: (String) -> String): String {
val raw = readln()
return prepare(raw)
}
Тепер у main ми можемо зробити так:
fun main() {
println("Введіть команду:")
val line = readPreparedLine(::normalizeCommandLine)
println("Команда: $line")
// Якщо ввели " LiSt ", то буде: Команда: list
}
Тут ::normalizeCommandLine підходить ідеально: функція вже існує, її імʼя зрозуміле, а сигнатура (String) -> String збігається з тим, що потрібно.
9. :: у предикатах: код стає «розмовним»
Callable references особливо приємно працюють із предикатами, бо предикат часто можна виразити однією зрозумілою функцією з хорошою назвою.
Уявімо, що ми зберігаємо назви витрат і хочемо знайти першу витрату, в якої назва коротка (наприклад, «чай»), або навпаки — «довга й підозріла» (наприклад, «незрозуміла підписка на щось»).
Зробімо предикат як іменовану функцію:
fun isShortTitle(title: String): Boolean = title.length <= 3
І використаємо нашу ж функцію пошуку з минулої лекції (я наведу її ще раз повністю, щоб не стрибати між файлами):
fun findFirstOrNull(items: List<String>, predicate: (String) -> Boolean): String? {
for (s in items) {
if (predicate(s)) return s
}
return null
}
Тепер читабельний виклик:
fun main() {
val titles = listOf("кава", "чай", "підписка")
val firstShort = findFirstOrNull(titles, ::isShortTitle)
println(firstShort) // чай
}
Якщо ви прочитаєте це вголос, вийде майже звичайна фраза: «знайди перший або null за правилом isShortTitle». І це якраз те, заради чого ми сьогодні вивчаємо ::: менше шуму — більше сенсу.
Імʼя функції як частина документації
Callable reference підсилює вплив неймінгу: імʼя функції стає «ярликом поведінки». Тому з :: особливо добре виглядають функції, що звучать як дія або правило: normalizeCommandLine, isShortTitle, parseAmountOrNull.
Якщо ж імʼя функції невдале, :: лише підкреслить проблему. Наприклад:
fun doIt(s: String): String = s.trim()
// transformAll(items, ::doIt) // технічно ок, але читабельність… ну, ви зрозуміли
Якщо ви бачите, що код із :: виглядає дивно, це часто знак, що потрібно не «повернути лямбду назад», а просто перейменувати функцію.
10. Типові помилки під час використання callable references ::
Помилка №1: плутати «передавання функції» і «виклик функції».
Одна з найчастіших плутанин виглядає так: студент пише findFirstOrNull(words, isShort(words[0])) і дивується, чому все зламалося. Бо isShort(words[0]) — це вже Boolean, а потрібно передати (String) -> Boolean. У таких місцях корисно проговорювати: ::isShort — передаю, isShort("go") — викликаю.
Помилка №2: намагатися використати :: там, де лямбда справді робить додаткову роботу.
Коли вам потрібно «підмішати» зовнішній параметр, callable reference найчастіше не підходить — і це нормально. Лямбда { s -> startsWithPrefix(s, prefix) } не є обгорткою: вона фіксує prefix. У цей момент :: не «гірший» — він просто про іншу ситуацію.
Помилка №3: дивуватися, що компілятор не приймає ::f, хоча «за змістом ніби підходить».
Callable reference живе у світі строгих типів: якщо очікується (T) -> R, то передана функція має мати рівно таку сигнатуру. Якщо параметрів більше або повертаний тип інший, Kotlin не буде «здогадуватися». Це не занудство мови, а захист від багів, які інакше проявляться надто пізно.
Помилка №4: використовувати :: із функціями з поганими іменами й отримати нечитабельний код.
:: майже завжди покращує код лише тоді, коли імʼя функції говорить саме за себе. Якщо імʼя на кшталт process, handle, doStuff, то ::process не пояснює, що відбувається. У підсумку код стає коротшим, але не яснішим. Тут правильне лікування — неймінг, а не ще одна лямбда.
Помилка №5: намагатися «впихнути :: всюди» як стиль програмування.
Callable reference — це інструмент точкового спрощення. Якщо ви починаєте замінювати будь-які лямбди на :: і при цьому втрачаєте можливість швидко зрозуміти логіку, значить ви перейшли з інженерії у спорт «хто напише коротше». У реальних проєктах переможців у цьому спорті не нагороджують, зате часто призначають відповідальними за підтримку.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ