JavaRush /Курси /Kotlin SELF /SAM‑конверсія — Kotlin‑лямбди як реалізації Java SAM‑типі...

SAM‑конверсія — Kotlin‑лямбди як реалізації Java SAM‑типів

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

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‑лямбда
Runnable
«зроби дію»
run(): void
() -> Unit
Comparator<T>
«порівняй два T»
compare(a: T, b: T): int
(T, T) -> Int
Predicate<T>
«перевір T»
test(x: T): boolean
(T) -> Boolean
Consumer<T>
«оброби T»
accept(x: T): void
(T) -> Unit

Важливо: 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 до виклику обробника.

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