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 добрий саме своєю «одноопераційністю»: один метод — одна відповідальність.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ