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 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 хорош именно своей «однооперационностью»: один метод — одна ответственность.

1
Задача
Kotlin SELF, 37 уровень, 2 лекция
Недоступна
Датчики числа
Датчики числа
1
Задача
Kotlin SELF, 37 уровень, 2 лекция
Недоступна
Формат вывода
Формат вывода
1
Задача
Kotlin SELF, 37 уровень, 2 лекция
Недоступна
Правило цены
Правило цены
1
Задача
Kotlin SELF, 37 уровень, 2 лекция
Недоступна
Консольные команды
Консольные команды
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ