JavaRush /Курси /Kotlin SELF /fun interface і SAM‑конверсія

fun interface і SAM‑конверсія

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

1. Навіщо потрібен fun interface

Коли ви вже звикли до лямбд (на кшталт { x -> x > 0 } ) і функціональних типів ( (Int) -> Boolean ), може здатися, що окрема конструкція fun interface — це «надбудова заради надбудови». Але на практиці вона розвʼязує дуже приземлене завдання: як назвати поведінку і як домовитися про неї в API, щоб код читався як звичайний текст, а не як набір дужок.

Уявіть, що ви пишете застосунок, у якому є «обробник команди», «правило валідації», «стратегія форматування» тощо. Формально це все функції. Але якщо всюди тягнути типи на кшталт (AppState, List<String>) -> String, голова швидко починає «перегріватися». fun interface дозволяє сказати: «Ось цей тип — обробник команди», і відразу стає зрозуміліше.

Офіційно в Kotlin функціональним (SAM) інтерфейсом називають інтерфейс з одним абстрактним методом. Для такого інтерфейсу Kotlin уміє «підставляти» лямбду як реалізацію (це і є SAM‑конверсія).

SAM по‑людському: «одна дія» як контракт

Перекладімо SAM з мови специфікацій на звичайну мову. Звичайний інтерфейс часто описує «істоту з багатьма обовʼязками»: наприклад, «уміє малювати», «уміє зберігатися», «уміє повідомляти імʼя», «уміє бути закритим». І це нормально.

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

У Kotlin функціональний (SAM) інтерфейс може мати скільки завгодно неабстрактних функцій, але абстрактний метод має бути рівно один.

Чому не просто тип функції

У цьому місці закономірне запитання: «А навіщо вводити fun interface CommandHandler, якщо можна написати typealias CommandHandler = (AppState, List<String>) -> String

Річ у тім, що функціональний інтерфейс — це не те саме, що typealias для функціонального типу. Інтерфейс задає іменований контракт і може мати додаткові неабстрактні методи, а typealias — це лише псевдонім для вже наявного типу.

З практичного погляду різниця часто така:

Що обираємо Коли зручно Що отримуємо
Тип функції (A) -> B Внутрішня логіка, «швидко передали й забули» Мінімум сутностей, максимум стислості
fun interface Публічне API, «це важлива роль в архітектурі» Промовиста назва типу + можливість розширити інтерфейс методами за замовчуванням

Тобто fun interface — це спосіб зробити поведінку іменованою та доменною. Коли ви читаєте CommandHandler, ви відразу розумієте роль. А коли читаєте (AppState, List<String>) -> String, ви бачите лише форму (і то не завжди одразу).

2. Як оголошувати fun interface і як працює SAM‑конверсія

Ключове слово тут буквально fun перед interface. Це не «функціональний інтерфейс» у сенсі «якось повʼязаний із FP», а технічна мітка: «компіляторе, будь ласка, дозволь SAM‑конверсію і стеж за правилом одного абстрактного методу».

Мінімальний приклад:


fun interface IntPredicate {
    fun accept(x: Int): Boolean
}

Тут accept — єдиний абстрактний метод. Тому Kotlin зможе зробити так:

fun interface IntPredicate {
    fun accept(x: Int): Boolean
}

fun main() {
    val isEven = IntPredicate { x -> x % 2 == 0 }

    println(isEven.accept(7))  // false
    println(isEven.accept(10)) // true
}

Важливо вловити сенс: ми створюємо обʼєкт інтерфейсу, просто Kotlin дозволяє описати його однією лямбдою.

Що саме «конвертується» в SAM‑конверсії

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

Схематично це можна уявити так:

flowchart LR
    A["Лямбда: { x -> x % 2 == 0 }"] --> B["SAM-конверсія компілятором"]
    B --> C["Обʼєкт, що реалізує IntPredicate"]
    C --> D["Метод accept(x) викликає лямбду"]

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

object : ... і SAM‑лямбда: сенс один, синтаксис різний

Зазвичай цінність SAM‑конверсії стає очевидною, коли ви бачите два фрагменти коду поруч: один — «як у Java з епохи анонімних класів», другий — «як у Kotlin».

Один і той самий сенс (перевірка на парність) без SAM‑конверсії:

fun interface IntPredicate {
    fun accept(x: Int): Boolean
}

fun main() {
    val isEven = object : IntPredicate {
        override fun accept(x: Int): Boolean = x % 2 == 0
    }

    println(isEven.accept(7)) // false
}

І з SAM‑конверсією:

fun interface IntPredicate {
    fun accept(x: Int): Boolean
}

fun main() {
    val isEven = IntPredicate { it % 2 == 0 }

    println(isEven.accept(7)) // false
}

Обидва варіанти створюють обʼєкт, який уміє accept. Але в другому варіанті ви не пишете зайвого коду.

3. Приклад: обробники CLI‑команд на fun interface

Тепер привʼяжімо тему до чогось більш життєвого. Припустімо, у нас є консольний застосунок для обліку витрат (назвімо його BudgetBuddy). Раніше ми, найімовірніше, робили великий when (command) і всередині розміщували всю логіку. Це працює, але з часом розростається й починає виглядати як моноліт.

Зробімо крок уперед: винесімо обробники команд у структуру даних (наприклад, Map), але так, щоб обробник був іменованим контрактом, а не «голою» функцією. Для цього ідеально підходить fun interface.

Мінімальні моделі та контракт обробника

Спочатку — прості моделі:

data class Expense(
    val id: Int,
    val title: String,
    val amount: Double
)

class AppState(
    val expenses: MutableList<Expense>
)

Тепер оголосімо контракт обробника команди:

fun interface CommandHandler {
    fun handle(state: AppState, args: List<String>): String
}

Чому ми повертаємо String? Бо поки не хочемо ускладнювати модель результату. Нехай обробник просто повертає текст, який main надрукує. Це зручно для навчання й не вимагає нових сутностей.

Зробімо утиліту для виводу:

fun renderExpenses(expenses: List<Expense>): String {
    if (expenses.isEmpty()) return "Витрат поки немає."

    return expenses.joinToString(separator = "\n") { e ->
        "#${e.id}: ${e.title} — ${e.amount}"
    }
}

Тепер створімо «реєстр команд» через SAM‑конверсію:

fun buildHandlers(): Map<String, CommandHandler> {
    return mapOf(
        "help" to CommandHandler { _, _ ->
            "Команди: help, add, list, exit"
        },
        "list" to CommandHandler { state, _ ->
            renderExpenses(state.expenses)
        }
    )
}

Зверніть увагу на важливий момент: CommandHandler { state, args -> ... } — це створення обʼєкта обробника через лямбду.

Виклик обробників у main

Переходимо до циклу читання команд. Ми не будемо будувати великий парсер — зробимо просту версію: команда — це перше слово, а аргументи — решта.

fun main() {
    val state = AppState(mutableListOf())
    val handlers = buildHandlers()

    while (true) {
        print("> ")
        val parts = readln().trim().split(" ")
        val cmd = parts.firstOrNull()?.lowercase() ?: continue
        val args = parts.drop(1)

        if (cmd == "exit") break

        val handler = handlers[cmd]
        val result = handler?.handle(state, args) ?: "Невідома команда: $cmd"
        println(result)
    }
}

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

Коли «чому не компілюється»: сигнатура має збігатися

Коли SAM‑конверсія «не спрацьовує», причина майже завжди проста: лямбда не збігається за параметрами або типом результату з абстрактним методом.

Наприклад:

fun interface IntTransformer {
    fun apply(x: Int): Int
}

Коректно так:

val plusOne = IntTransformer { it + 1 }
println(plusOne.apply(10)) // 11

А от так — уже ні (повертаємо Boolean, а треба Int):

val wrong = IntTransformer { it > 0 } // не скомпілюється

Для новачка це виглядає як «компілятор прискіпується». Насправді він рятує вас від дивної реальності, де обробник «має повернути Int», а ви повернули «правда/хиба».

fun interface і замикання: зручно, але можна сховати стан

Найпідступніша частина лямбд — не синтаксис, а те, що лямбда може «захопити» зовнішні змінні. Це називається замиканням (closures).

Приклад:

fun interface CounterStep {
    fun next(): Int
}

fun main() {
    var x = 0

    val step = CounterStep {
        x += 1
        x
    }

    println(step.next()) // 1
    println(step.next()) // 2
}

Технічно це працює. Але з погляду архітектури ви створили обробник, який залежить від зовнішнього var x. Якщо такий обробник переїде в інший файл або модуль, його буде складніше розуміти й тестувати: стан заховано не всередині обʼєкта, а «ззовні, але поруч».

Іноді це нормально (наприклад, для маленького лічильника). Але в прикладному коді корисно тримати в голові правило: якщо поведінка стає важливою й довгоживучою, краще, щоб стан був явним (наприклад, усередині класу або структури стану застосунку), а не випадково захопленим.

4. Типові помилки під час роботи з fun interface і SAM‑конверсією

Помилка № 1: роблять fun interface із двома абстрактними методами й дивуються, що Kotlin скаржиться.
SAM‑конверсія можлива лише тоді, коли абстрактний метод рівно один. Щойно ви додаєте другий абстрактний метод, інтерфейс перестає бути функціональним, і лямбда вже не може однозначно «стати реалізацією». Це не шкідливість Kotlin, а захист від неоднозначності.

Помилка № 2: плутають «функціональний інтерфейс» і «тип функції», а потім втрачають сенс API.
Коли всередині функції ви швидко фільтруєте список — (Expense) -> Boolean чудово підходить. Але якщо ви проєктуєте шар, де є ролі на кшталт «обробник команди» або «валідатор», тип функції перетворюється на криптографію для очей. fun interface потрібен якраз для того, щоб поведінка мала імʼя й читалася як частина предметної області.

Помилка № 3: лямбда не збігається за сигнатурою з абстрактним методом, і здається, що SAM не працює.
SAM‑конверсія працює, коли типи збігаються. Якщо метод очікує String, а ви пишете лямбду, що повертає Int, компілятор чесно каже: «я не можу підставити це замість того». Зазвичай допомагає явно підписати параметри лямбди й подивитися, що очікується: { x: Int -> ... } замість { it -> ... }.

Помилка № 4: захоплюють зовнішні var у лямбді й отримують приховану мутабельність.
Саме по собі замикання — нормальний механізм. Але якщо ви починаєте зберігати в замиканнях важливий стан застосунку, код стає складно налагоджувати: неочевидно, хто і коли змінює змінну. Краще тримати «офіційний» стан у AppState або класах, а лямбди використовувати як чисті (або майже чисті) дії.

Помилка № 5: перетворюють fun interface на «звалище методів», втрачаючи ідею «одна дія».
Так, у функціональному інтерфейсі можна мати додаткові методи, але вони мають бути неабстрактними. На практиці це сигнал: якщо ви почали додавати багато логіки й різних обовʼязків, можливо, це вже не SAM‑контракт, а звичайний інтерфейс. SAM добрий саме своєю «одноопераційністю»: один метод — одна відповідальність.

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