1. Навіщо потрібна SAM‑конверсія в Java‑API
Якщо подивитися на Java API очима новачка, може здатися, що розробники Java дуже люблять «загортати прості речі в інтерфейси». Іноді це справді так. Але історично це був основний спосіб передавати поведінку в метод: не лямбдою (як у Kotlin), а об’єктом, у якого є один метод «зроби дію». Kotlin на JVM живе поруч із Java, тож має вміти природно працювати з такими API. І головне — не змушувати вас щоразу писати багатослівні конструкції.
SAM розшифровується як Single Abstract Method — «один абстрактний метод». Такий інтерфейс називають SAM‑типом або функціональним інтерфейсом. Сенс простий: якщо в інтерфейсу рівно один абстрактний метод, то замість явного створення об’єкта‑реалізації можна взяти лямбду й вважати, що саме вона і є реалізацією цього методу. Це й називається SAM‑конверсією.
Уявіть, що Java‑методу потрібно «щось виконати». У Java він часто просить Runnable. У Kotlin ви б передали { ... }. SAM‑конверсія — це механізм, який дозволяє Kotlin сказати: «Гаразд, я бачу, що тут потрібен Runnable. Я візьму лямбду й на льоту зроблю з неї об’єкт Runnable».
Невелика схема (насправді все трохи складніше, але для розуміння цього вистачить):
flowchart LR
A["Kotlin-лямбда { ... }"] --> B["SAM-конверсія (адаптер)"]
B --> C["Java-інтерфейс Runnable/Comparator/Predicate"]
C --> D["Java-метод викликає єдиний абстрактний метод"]
Які SAM‑типи найчастіше трапляються в Java
Kotlin зіставляє «форму» лямбди з «формою» єдиного абстрактного методу інтерфейсу: кількість параметрів і тип результату.
Щоб орієнтуватися швидше, корисно тримати в голові кілька популярних SAM‑типів із Java і те, який вигляд вони мають для Kotlin‑розробника:
| Java SAM‑тип | Сенс | Абстрактний метод (за змістом) | Як виглядає відповідна Kotlin‑лямбда |
|---|---|---|---|
|
«зроби дію» | |
|
|
«порівняй два T» | |
|
|
«перевір T» | |
|
|
«оброби T» | |
|
Важливо: void у Java за змістом відповідає Unit у Kotlin. Тобто якщо Java очікує «функцію без результату», Kotlin‑лямбда не має повертати значущого значення.
2. Runnable: «запусти ось це»
Коли ви вперше зустрічаєте Runnable, хочеться запитати: «Навіщо мені тип, щоб просто виконати код?» Відповідь проста: так влаштовано багато Java‑API. А хороша новина в тому, що Kotlin не змушує вас писати окремий клас або анонімний об’єкт. Достатньо лямбди — якщо тип очікуваного параметра відомий.
SAM‑конверсія під час присвоювання у змінну
Тут Kotlin має створити об’єкт Runnable, тому ми явно вказуємо тип змінної:
fun main() {
val task: Runnable = Runnable {
println("Запуск задачі") // Запуск задачі
}
task.run() // Запуск задачі
}
Зверніть увагу на форму Runnable { ... }. Це виглядає як «виклик конструктора», хоча Runnable — інтерфейс. У Kotlin це звичний синтаксис SAM‑обгортки: «візьми лямбду й зроби з неї об’єкт потрібного SAM‑типу».
SAM‑конверсія прямо в аргументі методу
Коли Kotlin бачить, що параметр методу — Runnable, він може застосувати SAM‑конверсію автоматично:
fun runNow(task: Runnable) {
task.run()
}
fun main() {
runNow {
println("Виконую просто зараз") // Виконую просто зараз
}
}
Тут приємно те, що запис виглядає «по‑котлінівськи»: ми передали лямбду як останній аргумент (trailing lambda), а Kotlin сам зрозумів, що це Runnable.
3. Колекції: Predicate/removeIf і Comparator
Щоб приклади не «висіли в повітрі», розвиватимемо маленький консольний застосунок (без ООП — лише функції й колекції, як ви вже вмієте). Нехай це буде чернетковий «BudgetBuddy»: зберігаємо витрати як Triple(id, category, amount), щоб можна було сортувати, фільтрувати й видаляти.
Міні‑приклад даних для «BudgetBuddy»
Зробімо заготовку даних:
import java.util.ArrayList
typealias Expense = Triple<Int, String, Int>
fun main() {
val expenses = ArrayList<Expense>()
expenses.add(Triple(1, "food", 800))
expenses.add(Triple(2, "taxi", 450))
expenses.add(Triple(3, "food", 120))
println(expenses) // [(1, food, 800), (2, taxi, 450), (3, food, 120)]
}
Так, ми взяли саме java.util.ArrayList, а не mutableListOf(...). Не тому, що так потрібно завжди, а щоб побачити саме Java‑методи, які використовують SAM‑типи.
Predicate і removeIf: видалення елементів через лямбду
Багато Java‑колекцій мають метод removeIf(...), який приймає Predicate<T>: «видали елемент, якщо предикат повернув true». Це ідеальний приклад SAM‑конверсії: замість Predicate ми хочемо передати лямбду.
Зробімо в застосунку команду «видалити все, що дешевше за задану суму». Для навчальних цілей просто викличемо функцію:
import java.util.ArrayList
typealias Expense = Triple<Int, String, Int>
fun removeCheaperThan(expenses: ArrayList<Expense>, minAmount: Int) {
expenses.removeIf { expense ->
val amount = expense.third
amount < minAmount
}
}
fun main() {
val expenses = arrayListOf(
Triple(1, "food", 800),
Triple(2, "taxi", 450),
Triple(3, "food", 120),
)
removeCheaperThan(expenses, 500)
println(expenses) // [(1, food, 800)]
}
Ключовий момент: removeIf очікує Predicate<Expense>. Ми передали { expense -> ... }. Kotlin зробив SAM‑конверсію й створив об’єкт Predicate, усередині якого метод test(expense) викликає нашу лямбду.
Якщо ви колись писали filter { ... } у Kotlin, за змістом це схоже. Тільки filter повертає новий список, а removeIf змінює наявний (типова Java‑манера).
Comparator як SAM‑тип: сортування
Сортування — місце, де SAM‑конверсія трапляється постійно. У Kotlin ви вже робили sortedBy, sortedWith(compareBy(...)) і, можливо, бачили, що Comparator — центральна ідея.
Java‑інтерфейс Comparator<T> — це SAM‑тип: у нього один абстрактний метод compare(a, b), який повертає Int. Отже, компаратор можна створити лямбдою.
Приклад: сортування витрат за amount (третій компонент Triple) за спаданням:
import java.util.Comparator
typealias Expense = Triple<Int, String, Int>
fun main() {
val expenses: List<Expense> = listOf(
Triple(1, "food", 800),
Triple(2, "taxi", 450),
Triple(3, "food", 120),
)
val byAmountDesc: Comparator<Expense> = Comparator { a, b ->
b.third - a.third
}
println(expenses.sortedWith(byAmountDesc))
// [(1, food, 800), (2, taxi, 450), (3, food, 120)]
}
Тут важливо не лише «щоб працювало», а й щоб ви розуміли контракт: компаратор має повернути від’ємне число, нуль або додатне число — залежно від порядку. Знак результату визначає «хто більший».
Обережно з b.third - a.third
Новачки часто пишуть b - a (як ми вище), і це зазвичай працює на малих числах. Але якщо значення можуть бути великими, різниця може переповнитися. Безпечніший стиль — compareTo:
import java.util.Comparator
typealias Expense = Triple<Int, String, Int>
fun main() {
val byAmountDesc: Comparator<Expense> = Comparator { a, b ->
b.third.compareTo(a.third)
}
val xs = listOf(Triple(1, "food", 2_000_000_000), Triple(2, "taxi", 1))
println(xs.sortedWith(byAmountDesc))
// [(1, food, 2000000000), (2, taxi, 1)]
}
Цей варіант трохи довший, зате не перетворює сортування на лотерею «вгадай, чи переповнився Int».
4. Посилання на функції та неоднозначні перевантаження
:: дозволяє передавати посилання на функцію замість лямбди. Іноді це робить код не лише коротшим, а й «чеснішим»: «я не вигадую нову логіку, я просто використовую готову функцію».
Є одна тонкість: щоб :: підійшов, сигнатура функції має збігатися з методом SAM‑типу.
SAM‑конверсія + callable reference
Зробімо іменовану функцію порівняння двох витрат:
typealias Expense = Triple<Int, String, Int>
fun compareByCategoryThenAmount(a: Expense, b: Expense): Int {
val byCategory = a.second.compareTo(b.second)
return if (byCategory != 0) byCategory else a.third.compareTo(b.third)
}
А тепер використаємо її як основу Comparator:
import java.util.Comparator
typealias Expense = Triple<Int, String, Int>
fun compareByCategoryThenAmount(a: Expense, b: Expense): Int {
val byCategory = a.second.compareTo(b.second)
return if (byCategory != 0) byCategory else a.third.compareTo(b.third)
}
fun main() {
val cmp: Comparator<Expense> = Comparator(::compareByCategoryThenAmount)
val xs = listOf(
Triple(1, "taxi", 450),
Triple(2, "food", 800),
Triple(3, "food", 120),
)
println(xs.sortedWith(cmp))
// [(3, food, 120), (2, food, 800), (1, taxi, 450)]
}
Тут Comparator(::compareByCategoryThenAmount) — це SAM‑конверсія, тільки джерелом поведінки виступає не лямбда, а callable reference.
Коли компілятор не розуміє, який SAM ви мали на увазі
У реальному коді SAM‑конверсія інколи «ламається» не тому, що Kotlin «поганий», а тому, що ми дали йому надто мало інформації. Найчастіший симптом — ви пишете { ... }, а компілятор відповідає щось на кшталт «не можу вибрати перевантаження» або «не можу вивести типи параметрів».
Щоб побачити проблему на мінімальному прикладі, зробімо дві функції з однаковим іменем, але різними SAM‑типами:
import java.util.concurrent.Callable
fun runAction(action: Runnable) {
println("Runnable")
action.run()
}
fun runAction(action: Callable<String>) {
println("Callable")
println(action.call())
}
А тепер спробуймо викликати:
fun main() {
runAction { "hello" }
}
З погляду людини це «поверни рядок hello». Але з погляду компілятора такий код потенційно підходить під Callable<String> (там якраз є результат) і не підходить під Runnable (там Unit). В іншій ситуації може бути навпаки — і ви несподівано потрапите «не в те» перевантаження.
Розв’язання в таких випадках просте й дуже практичне: зробити намір явним.
Ось так ми явно обираємо Runnable:
fun main() {
runAction(Runnable {
println("Просто виконую") // Просто виконую
})
}
А ось так — явно обираємо Callable<String>:
import java.util.concurrent.Callable
fun main() {
runAction(Callable {
"hello"
})
}
Так, це виглядає трохи більш багатослівно. Зате ви більше не граєте з компілятором у «вгадай, що я мав на увазі». У промисловому коді такий «додатковий шум» часто економить години налагодження.
5. Замикання в SAM‑лямбдах і захоплення стану
Лямбди в Kotlin уміють захоплювати зовнішні змінні — ви це вже бачили. SAM‑конверсія нічого не змінює: усередині Predicate { ... } або Runnable { ... } ви так само можете використовувати зовнішні val/var.
Це зручно, але є ризик: ви створюєте обробник, який виглядає як «чиста функція», а насправді залежить від змінюваного стану.
Подивімося на приклад із removeIf, який використовує зовнішній var:
import java.util.ArrayList
typealias Expense = Triple<Int, String, Int>
fun main() {
val expenses = ArrayList<Expense>()
expenses.add(Triple(1, "food", 800))
expenses.add(Triple(2, "taxi", 450))
var minAmount = 500
expenses.removeIf { it.third < minAmount }
println(expenses) // [(1, food, 800)]
minAmount = 900
expenses.removeIf { it.third < minAmount }
println(expenses) // []
}
Технічно все коректно. Але якщо такий Predicate живе довго (наприклад, зберігається десь у змінній і використовується пізніше), то «зовнішній var» перетворюється на несподіваний важіль керування. Новачкам це особливо боляче, бо в коді не видно, що обробник залежить від стану.
Корисна звичка, яка допомагає: якщо значення справді має бути фіксованим для предиката, збережіть його у val перед створенням лямбди (тобто зробіть «знімок»). Не використовуйте «живий» var.
6. Типові помилки під час SAM‑конверсії
Помилка №1: думати, що «будь‑яка лямбда автоматично підходить куди завгодно».
SAM‑конверсія працює лише тоді, коли очікуваний тип — саме SAM‑інтерфейс, а сигнатура лямбди збігається з єдиним абстрактним методом. Якщо метод очікує звичайний об’єкт або в інтерфейсу більше ніж один абстрактний метод, лямбда не врятує — доведеться шукати інший спосіб.
Помилка №2: не помічати різницю між Kotlin‑функціональним типом і Java‑функціональним інтерфейсом.
Тип (Int) -> Boolean і тип Predicate<Int> схожі за змістом, але це різні сутності. У Kotlin‑коді ви можете зберігати предикат як (Int) -> Boolean, а Java‑метод може вимагати саме Predicate<Int>. Саме в таких місцях і потрібна SAM‑конверсія — інакше типи не «стикуються».
Помилка №3: писати компаратор як b - a і випадково отримати переповнення.
На малих числах різниця працює, але за великих значень може переповнитися Int, і сортування починає поводитися дивно. Надійніше використовувати compareTo, навіть якщо здається, що «і так нормально».
Помилка №4: натрапляти на неоднозначність перевантажень і намагатися «вмовити» компілятор натяками.
Якщо у вас кілька перевантажених методів або функцій і лямбда підходить більш ніж під один варіант, компілятор зобов’язаний запитати: «який саме?». «Лікується» це не чарівними дужками й не перестановкою пробілів, а явним зазначенням типу: або через змінну потрібного SAM‑типу, або через обгортку на кшталт Runnable { ... }, Comparator { ... }.
Помилка №5: непомітно захоплювати зовнішній var і отримувати «прихований стан».
SAM‑лямбда легко перетворюється на обробник із неочевидними залежностями, якщо всередині використовується зовнішня змінювана змінна. У невеликих прикладах це не страшно. Але в реальному застосунку така поведінка ускладнює налагодження: результат залежить не лише від вхідних параметрів, а й від того, що хтось десь змінив var до виклику обробника.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ