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 до вызова обработчика.

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