Lambda ·
‹ Предыдущий Следующий ›
⏱ 5 минут чтения Обновлено: 2026-09-08

Специализации функциональных интерфейсов в 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.

Видео объяснение

Предпочитаете видеоформат? Посмотрите этот урок с примерами и объяснениями.

Комментарии

Зарегистрируйтесь или войдите, чтобы иметь возможность оставить комментарий.