Специализации функциональных интерфейсов в Java
Этот цикл выглядит безобидно, но за десять миллионов итераций он создаёт десять миллионов лишних объектов:
Function<Integer, Integer> square = x -> x * x;
long sum = 0;
for (int i = 0; i < 10_000_000; i++) {
sum += square.apply(i); // int -> Integer -> int на каждом шаге
} Метод apply объявлен как R apply(T t) и умеет работать только с объектами, поэтому каждый int сначала упаковывается в Integer, а затем тут же распаковывается обратно. Достаточно заменить тип переменной на IntUnaryOperator с методом int applyAsInt(int) — и упаковка исчезает полностью. Именно ради таких случаев в JDK существуют специализации функциональных интерфейсов.
Специализации функциональных интерфейсов (functional interface specializations) — это варианты базовых интерфейсов пакета java.util.function, рассчитанные на другое количество аргументов (бинарные специализации) или на примитивные типы int, long, double вместо объектов (примитивные специализации). Всего пакет java.util.function содержит 43 интерфейса: 9 обобщённых (объектных) и 34 примитивных.
Зачем нужны специализации: цена автоупаковки
Дженерики в Java не работают с примитивными типами: запись Function<int, int> не компилируется. Поэтому любой обобщённый функциональный интерфейс — Predicate, Consumer, Function, Supplier — вынужден принимать и возвращать объекты. Когда в него передают число, включается автоупаковка (autoboxing), а на выходе — автораспаковка (unboxing).
Что это стоит на практике:
- каждый упакованный
int— отдельный объект в куче (в 64-битной HotSpot это порядка 16 байт плюс 4–8 байт на ссылку против 4 байт у примитива); - миллионы короткоживущих
Integerнагружают сборщик мусора; - массив ссылок на
Integerраскидан по куче, тогда какint[]лежит одним непрерывным блоком — теряется локальность данных и эффективность кеша процессора.
Примитивные специализации решают проблему на уровне сигнатуры: их абстрактные методы объявлены сразу над примитивами.
// Упаковка на каждом вызове
Function<Integer, Integer> squareBoxed = x -> x * x;
// Ни одной упаковки: int на входе, int на выходе
IntUnaryOperator square = x -> x * x;
int sum = IntStream.rangeClosed(1, 100)
.map(square) // IntUnaryOperator
.sum(); // 338350 Важно
Кеш Integer покрывает только диапазон от минус 128 до 127 — на этих значениях Integer.valueOf() отдаёт готовый объект. Всё, что выходит за диапазон, создаётся заново при каждой упаковке. Поэтому «упаковка почти бесплатна» работает лишь на маленьких числах и не спасает в реальных вычислениях.
Бинарные специализации функциональных интерфейсов
Базовые интерфейсы Predicate<T>, Consumer<T> и Function<T, R> принимают один аргумент. Для двух аргументов в java.util.function есть бинарные специализации (binary specializations) с приставкой Bi-. Бинарным аналогом UnaryOperator выступает BinaryOperator.
| Интерфейс | Функциональный дескриптор | Абстрактный метод | Описание |
|---|---|---|---|
| BiConsumer<T, U> | (T, U) → void | void accept(T, U) | Принимает на вход два объекта и ничего не возвращает. |
| BiFunction<T, U, R> | (T, U) → R | R apply(T, U) | Принимает на вход два объекта и возвращает результат типа R. |
| BiPredicate<T, U> | (T, U) → boolean | boolean test(T, U) | Принимает на вход два объекта и возвращает значение boolean. |
| BinaryOperator<T> | (T, T) → T | T apply(T, T) | Частный случай BiFunction<T, T, T>: оба аргумента и результат одного типа. |
BiFunction<String, Integer, String> repeat = (s, n) -> s.repeat(n);
System.out.println(repeat.apply("ab", 3)); // ababab
BiPredicate<String, Integer> isLonger = (s, n) -> s.length() > n;
System.out.println(isLonger.test("Java", 3)); // true
BiConsumer<String, Integer> printer = (k, v) -> System.out.println(k + " = " + v);
printer.accept("age", 30); // age = 30
BinaryOperator<Integer> max = BinaryOperator.maxBy(Comparator.naturalOrder());
System.out.println(max.apply(7, 42)); // 42 Метод String.repeat() доступен начиная с Java 11. Интерфейсов на три и более аргумента (TriFunction и подобных) в стандартной библиотеке нет — при необходимости их объявляют самостоятельно.
Примитивные специализации функциональных интерфейсов
Примитивные специализации существуют для трёх типов: int, long и double (плюс BooleanSupplier для boolean). Ниже — полный список по группам.
Специализации Predicate
| Интерфейс | Абстрактный метод | Описание |
|---|---|---|
| IntPredicate | boolean test(int) | Принимает на вход значение типа int и возвращает значение типа boolean. |
| LongPredicate | boolean test(long) | Принимает на вход значение типа long и возвращает значение типа boolean. |
| DoublePredicate | boolean test(double) | Принимает на вход значение типа double и возвращает значение типа boolean. |
Как и Predicate<T>, эти интерфейсы имеют default-методы and(), or() и negate() для комбинирования условий.
Специализации Consumer
| Интерфейс | Абстрактный метод | Описание |
|---|---|---|
| IntConsumer | void accept(int) | Принимает на вход значение типа int и ничего не возвращает. |
| LongConsumer | void accept(long) | Принимает на вход значение типа long и ничего не возвращает. |
| DoubleConsumer | void accept(double) | Принимает на вход значение типа double и ничего не возвращает. |
Специализации Function
У Function<T, R> примитивным может быть вход, выход или и то и другое — отсюда три подгруппы.
Принимающие примитив, возвращающие объект
| Интерфейс | Абстрактный метод | Описание |
|---|---|---|
| IntFunction<R> | R apply(int) | Принимает на вход значение типа int и возвращает объект типа R. |
| LongFunction<R> | R apply(long) | Принимает на вход значение типа long и возвращает объект типа R. |
| DoubleFunction<R> | R apply(double) | Принимает на вход значение типа double и возвращает объект типа R. |
Принимающие объект, возвращающие примитив
| Интерфейс | Абстрактный метод | Описание |
|---|---|---|
| ToIntFunction<T> | int applyAsInt(T) | Принимает на вход объект типа T и возвращает значение типа int. |
| ToLongFunction<T> | long applyAsLong(T) | Принимает на вход объект типа T и возвращает значение типа long. |
| ToDoubleFunction<T> | double applyAsDouble(T) | Принимает на вход объект типа T и возвращает значение типа double. |
Принимающие и возвращающие примитив
| Интерфейс | Абстрактный метод | Описание |
|---|---|---|
| IntToDoubleFunction | double applyAsDouble(int) | Принимает на вход значение типа int и возвращает значение типа double. |
| IntToLongFunction | long applyAsLong(int) | Принимает на вход значение типа int и возвращает значение типа long. |
| LongToDoubleFunction | double applyAsDouble(long) | Принимает на вход значение типа long и возвращает значение типа double. |
| LongToIntFunction | int applyAsInt(long) | Принимает на вход значение типа long и возвращает значение типа int. |
| DoubleToIntFunction | int applyAsInt(double) | Принимает на вход значение типа double и возвращает значение типа int. |
| DoubleToLongFunction | long applyAsLong(double) | Принимает на вход значение типа double и возвращает значение типа long. |
Пар IntToIntFunction или DoubleToDoubleFunction в JDK нет — их роль выполняют IntUnaryOperator и DoubleUnaryOperator.
Специализации Supplier
| Интерфейс | Абстрактный метод | Описание |
|---|---|---|
| BooleanSupplier | boolean getAsBoolean() | Не принимает аргументов и возвращает значение типа boolean. |
| IntSupplier | int getAsInt() | Не принимает аргументов и возвращает значение типа int. |
| LongSupplier | long getAsLong() | Не принимает аргументов и возвращает значение типа long. |
| DoubleSupplier | double getAsDouble() | Не принимает аргументов и возвращает значение типа double. |
Обратите внимание: у Supplier<T> метод называется get(), а у примитивных версий — getAsInt(), getAsLong(), getAsDouble(), getAsBoolean(). Разные имена нужны, чтобы избежать конфликта стирания типов.
Специализации UnaryOperator
| Интерфейс | Абстрактный метод | Описание |
|---|---|---|
| IntUnaryOperator | int applyAsInt(int) | Принимает и возвращает значение типа int. |
| LongUnaryOperator | long applyAsLong(long) | Принимает и возвращает значение типа long. |
| DoubleUnaryOperator | double applyAsDouble(double) | Принимает и возвращает значение типа double. |
Специализации BinaryOperator
| Интерфейс | Абстрактный метод | Описание |
|---|---|---|
| IntBinaryOperator | int applyAsInt(int, int) | Принимает на вход два значения типа int и возвращает значение типа int. |
| LongBinaryOperator | long applyAsLong(long, long) | Принимает на вход два значения типа long и возвращает значение типа long. |
| DoubleBinaryOperator | double applyAsDouble(double, double) | Принимает на вход два значения типа double и возвращает значение типа double. |
Специализации BiConsumer
| Интерфейс | Абстрактный метод | Описание |
|---|---|---|
| ObjIntConsumer<T> | void accept(T, int) | Принимает на вход объект типа T и значение типа int, ничего не возвращает. |
| ObjLongConsumer<T> | void accept(T, long) | Принимает на вход объект типа T и значение типа long, ничего не возвращает. |
| ObjDoubleConsumer<T> | void accept(T, double) | Принимает на вход объект типа T и значение типа double, ничего не возвращает. |
Порядок аргументов фиксирован: сначала объект, затем примитив. Такая сигнатура удобна для аккумуляторов — например, в трёхаргументном IntStream.collect().
ObjIntConsumer<StringBuilder> append = StringBuilder::append;
StringBuilder sb = new StringBuilder("x=");
append.accept(sb, 42);
System.out.println(sb); // x=42 Специализации BiFunction
| Интерфейс | Абстрактный метод | Описание |
|---|---|---|
| ToIntBiFunction<T, U> | int applyAsInt(T, U) | Принимает на вход два объекта и возвращает значение типа int. |
| ToLongBiFunction<T, U> | long applyAsLong(T, U) | Принимает на вход два объекта и возвращает значение типа long. |
| ToDoubleBiFunction<T, U> | double applyAsDouble(T, U) | Принимает на вход два объекта и возвращает значение типа double. |
Симметричных IntBiFunction или BiPredicate над примитивами в JDK нет: два примитивных аргумента покрывают только IntBinaryOperator и его аналоги.
Как расшифровать имя интерфейса по его названию
Имена в java.util.function строятся по строгому шаблону, и, зная его, сигнатуру любого из 43 интерфейсов можно восстановить по названию.
| Элемент имени | Что означает | Пример | Сигнатура |
|---|---|---|---|
Int / Long / Double в начале | Тип входного параметра | IntPredicate | boolean test(int) |
To + тип | Тип возвращаемого значения | ToIntFunction<T> | int applyAsInt(T) |
Тип + To + тип | И вход, и выход примитивные | IntToDoubleFunction | double applyAsDouble(int) |
Obj + тип | Объект плюс примитив | ObjIntConsumer<T> | void accept(T, int) |
Bi в начале | Два объектных аргумента | BiFunction<T, U, R> | R apply(T, U) |
UnaryOperator / BinaryOperator | Все типы совпадают | IntBinaryOperator | int applyAsInt(int, int) |
Имя абстрактного метода тоже подчиняется правилу: если результат примитивный, к глаголу добавляется суффикс As + тип — applyAsInt, applyAsLong, applyAsDouble, getAsInt, getAsBoolean. У методов test и accept суффикса нет: их возвращаемый тип (boolean и void) и так фиксирован.
Где специализации встречаются в Stream API
Чаще всего эти интерфейсы попадаются не в объявлениях переменных, а в сигнатурах методов Stream, IntStream, Map и List. Таблица ниже помогает понять, какую лямбду от вас ждёт компилятор.
| Метод | Ожидаемый интерфейс | Что происходит |
|---|---|---|
Stream<T>.mapToInt() | ToIntFunction<T> | Переход от потока объектов к IntStream |
IntStream.map() | IntUnaryOperator | Преобразование int в int без упаковки |
IntStream.mapToObj() | IntFunction<R> | Возврат от IntStream к Stream<R> |
IntStream.filter() | IntPredicate | Отбор примитивов по условию |
IntStream.forEach() | IntConsumer | Терминальная операция без результата |
IntStream.reduce() | IntBinaryOperator | Свёртка потока в одно число |
IntStream.collect() | Supplier, ObjIntConsumer, BiConsumer | Ручная сборка результата |
Map<K, V>.forEach() | BiConsumer<K, V> | Обход пар «ключ — значение» |
Map<K, V>.merge() | BiFunction<V, V, V> | Слияние старого и нового значения |
List<E>.replaceAll() | UnaryOperator<E> | Замена каждого элемента на месте |
Arrays.setAll(int[], ...) | IntUnaryOperator | Заполнение массива по индексу |
List<String> words = List.of("Java", "Stream", "API");
// ToIntFunction<String>: объект -> примитив
int totalLength = words.stream()
.mapToInt(String::length)
.sum(); // 13
// IntPredicate: фильтрация без упаковки
long evenCount = IntStream.rangeClosed(1, 10)
.filter(n -> n % 2 == 0)
.count(); // 5
// IntBinaryOperator: свёртка
int factorial = IntStream.rangeClosed(1, 5)
.reduce(1, (a, b) -> a * b); // 120
// IntFunction<String>: примитив -> объект
List<String> labels = IntStream.rangeClosed(1, 3)
.mapToObj(n -> "#" + n)
.toList(); // [#1, #2, #3] Полезно знать
Ссылка на метод вроде Integer::parseInt подходит сразу под несколько интерфейсов — и под ToIntFunction<String>, и под Function<String, Integer>. Компилятор выбирает по целевому типу, и во втором случае вы молча получаете упаковку результата. Если тип пишете вы сами, выбирайте примитивную версию.
Где чаще всего спотыкаются
- Примитивные интерфейсы не наследуют обобщённые.
IntBinaryOperatorне является наследникомBiFunction, аIntPredicate— наследникомPredicate. Это независимые типы, и переменную одного нельзя присвоить переменной другого. - У
IntFunction,ToIntFunctionиToIntBiFunctionнет default-методов. ПривычныеandThen()иcompose()есть уFunction,IntUnaryOperatorиDoubleUnaryOperator, но не у «смешанных» специализаций — цепочку придётся собирать лямбдой вручную. - Специализаций для
float,byte,shortиcharне существует. Для них используютint- иdouble-версии, полагаясь на расширяющее преобразование. UnaryOperator<T>иFunction<T, T>взаимозаменяемы не в обе стороны.UnaryOperator<String>можно передать туда, где ждутFunction<String, String>, но не наоборот:UnaryOperatorнаследуетFunction, а не равен ему.- Забытый
boxed(). Чтобы изIntStreamполучитьStream<Integer>и собрать его вList<Integer>, нужен вызовboxed()либоmapToObj().
Правило выбора
Пишете обычный код, где лямбда вызывается десятки раз, — берите обобщённый интерфейс, он читается проще. Обрабатываете большие коллекции чисел, массивы или потоки из тысяч и миллионов элементов — берите примитивную специализацию и IntStream/LongStream/DoubleStream. Полный список сигнатур всегда есть в Javadoc пакета java.util.function.
Часто задаваемые вопросы
Чем IntFunction отличается от ToIntFunction?
IntFunction<R> принимает примитив и возвращает объект: R apply(int). ToIntFunction<T> — наоборот, принимает объект и возвращает примитив: int applyAsInt(T). Правило простое: префикс типа перед именем описывает аргумент, а приставка To — результат.
Почему в java.util.function нет специализаций для float, byte, short и char?
Полное покрытие всех восьми примитивов дало бы сотни интерфейсов, поэтому авторы JDK ограничились int, long и double. Значения byte, short и char расширяются до int без потери точности, а float — до double. Единственное исключение для boolean — интерфейс BooleanSupplier.
Можно ли присвоить переменную IntBinaryOperator переменной BinaryOperator?
Нет. Примитивные специализации не наследуют обобщённые интерфейсы — это параллельные, никак не связанные типы. Саму лямбду (a, b) -> a + b компилятор выведет под любой из них, но готовую переменную придётся обернуть вручную: BinaryOperator<Integer> op = (a, b) -> intOp.applyAsInt(a, b);.
Зачем BooleanSupplier, если есть Supplier<Boolean>?
BooleanSupplier объявляет boolean getAsBoolean() и отдаёт примитив, тогда как Supplier<Boolean> возвращает объект и допускает null — при автораспаковке это обернётся NullPointerException. Примитивная версия и быстрее, и безопаснее по контракту.
Насколько сильно автоупаковка замедляет код?
На единичных вызовах разница незаметна и не стоит усложнения. Она проявляется на сотнях тысяч и миллионах итераций: каждый упакованный int — отдельный объект в куче (около 16 байт плюс ссылка), лишняя работа сборщику мусора и потеря локальности данных. Кеш Integer покрывает лишь значения от минус 128 до 127.
Видео объяснение
Предпочитаете видеоформат? Посмотрите этот урок с примерами и объяснениями.
Комментарии