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 functional/SAM interface может иметь сколько угодно неабстрактных функций, но абстрактный метод должен быть ровно один.
Почему не просто тип функции
На этом месте закономерный вопрос: «А зачем вводить fun interface CommandHandler, если можно написать typealias CommandHandler = (AppState, List<String>) -> String?»
Смысл в том, что функциональный интерфейс — это не то же самое, что typealias функционального типа. Интерфейс задаёт именованный контракт и может иметь дополнительные неабстрактные методы, а typealias — это просто псевдоним для существующего типа.
С практической стороны разница часто такая:
| Что выбираем | Когда удобно | Что получаем |
|---|---|---|
| Тип функции (A) -> B | Внутренняя логика, «быстро передали и забыли» | Минимум сущностей, максимум краткости |
| fun interface | Публичное API, «это важная роль в архитектуре» | Говорящее имя типа + возможность расширить интерфейс default‑методами |
То есть 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 хорош именно своей «однооперационностью»: один метод — одна ответственность.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ