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‑лямбда |
|---|---|---|---|
|
«сделай действие» | |
|
|
«сравни два T» | |
|
|
«проверь T» | |
|
|
«обработай T» | |
|
Важно: 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 до вызова обработчика.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ