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

Functional Interface Specializations in Java

This loop looks harmless, but over ten million iterations it allocates ten million objects nobody asked for:

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 on every step
}

The abstract method of Function is declared as R apply(T t), and generics only work with objects. So every int is boxed into an Integer and immediately unboxed again. Swap the variable type for IntUnaryOperator, whose method is int applyAsInt(int), and the boxing disappears completely. That is exactly why the JDK ships specialized versions of its functional interfaces.

Functional interface specializations are variants of the core interfaces in the java.util.function package that are built for a different number of arguments (binary specializations) or for the primitive types int, long and double instead of objects (primitive specializations). In total, java.util.function contains 43 interfaces: 9 generic (object-based) and 34 primitive ones.

The core functional interfaces

Everything in java.util.function grows out of five generic interfaces. Each one has a single abstract method, so each can be the target type of a lambda expression or a method reference.

Interface Function descriptor Abstract method Description
Consumer<T> T → void void accept(T) Takes one object and returns nothing.
Function<T, R> T → R R apply(T) Takes one object and returns a result of type R.
Predicate<T> T → boolean boolean test(T) Takes one object and returns a boolean.
Supplier<T> () → T T get() Takes no arguments and produces a value of type T.
UnaryOperator<T> T → T T apply(T) A special case of Function<T, T>: the argument and the result have the same type.

The remaining 38 interfaces in the package are variations on these five, plus BiFunction and BinaryOperator. Once you know the naming rule, you can reconstruct any of them from its name alone.

Why specializations exist: the cost of autoboxing

Java generics cannot be parameterized with primitives: Function<int, int> simply does not compile. That forces every generic functional interface to accept and return objects. When you hand a number to one of them, autoboxing kicks in on the way down and unboxing on the way back.

What that costs in practice:

  • every boxed int is a separate heap object - roughly 16 bytes plus a 4 to 8 byte reference on 64-bit HotSpot, against 4 bytes for the primitive;
  • millions of short-lived Integer objects put pressure on the garbage collector;
  • an array of Integer references is scattered across the heap, while an int[] is one contiguous block - you lose data locality and CPU cache efficiency.

Primitive specializations solve this at the signature level: their abstract methods are declared over primitives directly.

// Boxes on every single call
Function<Integer, Integer> squareBoxed = x -> x * x;

// Zero boxing: int in, int out
IntUnaryOperator square = x -> x * x;

int sum = IntStream.rangeClosed(1, 100)
                   .map(square)   // IntUnaryOperator
                   .sum();        // 338350

Worth knowing

The Integer cache only covers the range from minus 128 to 127 — inside it, Integer.valueOf() hands back a shared instance. Anything outside that range allocates a fresh object on every boxing operation. So «boxing is basically free» holds for small numbers only and does not save you in real numeric workloads.

Binary specializations

Predicate<T>, Consumer<T> and Function<T, R> all take a single argument. For two arguments, java.util.function provides binary specializations carrying the Bi- prefix. The two-argument counterpart of UnaryOperator is not called BiUnaryOperator — it is BinaryOperator<T>, which extends BiFunction<T, T, T>. There is no binary version of Supplier, because a supplier takes no arguments at all.

Interface Function descriptor Abstract method Description
BiConsumer<T, U> (T, U) → void void accept(T, U) Takes two objects and returns nothing.
BiFunction<T, U, R> (T, U) → R R apply(T, U) Takes two objects and returns a result of type R.
BiPredicate<T, U> (T, U) → boolean boolean test(T, U) Takes two objects and returns a boolean.
BinaryOperator<T> (T, T) → T T apply(T, T) A special case of BiFunction<T, T, T>: both arguments and the result share one type.
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() is available from Java 11 onwards. Note that the JDK stops at two arguments: there is no TriFunction or anything with three or more parameters in the standard library, so you declare your own when you need one.

Primitive specializations: the full list

Primitive specializations exist for three types — int, long and double — plus BooleanSupplier for boolean. Here is the complete set, grouped by the generic interface each group derives from.

Predicate specializations

Interface Abstract method Description
IntPredicate boolean test(int) Takes an int and returns a boolean.
LongPredicate boolean test(long) Takes a long and returns a boolean.
DoublePredicate boolean test(double) Takes a double and returns a boolean.

Just like Predicate<T>, these three have the default methods and(), or() and negate() for combining conditions.

Consumer specializations

Interface Abstract method Description
IntConsumer void accept(int) Takes an int and returns nothing.
LongConsumer void accept(long) Takes a long and returns nothing.
DoubleConsumer void accept(double) Takes a double and returns nothing.
@FunctionalInterface
public interface IntConsumer {
    void accept(int value);
    // plus the default method andThen(IntConsumer)
}

IntConsumer ic = i -> System.out.println(i * 2);
ic.accept(8);   // 16 - no Integer is ever created
ic.accept(9);   // 18

Function specializations

With Function<T, R> the primitive can sit on the input side, the output side, or both — hence three subgroups.

Primitive in, object out

Interface Abstract method Description
IntFunction<R> R apply(int) Takes an int and returns an object of type R.
LongFunction<R> R apply(long) Takes a long and returns an object of type R.
DoubleFunction<R> R apply(double) Takes a double and returns an object of type R.

Object in, primitive out

Interface Abstract method Description
ToIntFunction<T> int applyAsInt(T) Takes an object of type T and returns an int.
ToLongFunction<T> long applyAsLong(T) Takes an object of type T and returns a long.
ToDoubleFunction<T> double applyAsDouble(T) Takes an object of type T and returns a double.

Primitive in, primitive out

Interface Abstract method Description
IntToDoubleFunction double applyAsDouble(int) Takes an int and returns a double.
IntToLongFunction long applyAsLong(int) Takes an int and returns a long.
LongToDoubleFunction double applyAsDouble(long) Takes a long and returns a double.
LongToIntFunction int applyAsInt(long) Takes a long and returns an int.
DoubleToIntFunction int applyAsInt(double) Takes a double and returns an int.
DoubleToLongFunction long applyAsLong(double) Takes a double and returns a long.

There is deliberately no IntToIntFunction or DoubleToDoubleFunctionIntUnaryOperator and DoubleUnaryOperator already fill that slot.

Supplier specializations

Interface Abstract method Description
BooleanSupplier boolean getAsBoolean() Takes no arguments and returns a boolean.
IntSupplier int getAsInt() Takes no arguments and returns an int.
LongSupplier long getAsLong() Takes no arguments and returns a long.
DoubleSupplier double getAsDouble() Takes no arguments and returns a double.

Notice the method names: Supplier<T> declares get(), while the primitive versions declare getAsInt(), getAsLong(), getAsDouble() and getAsBoolean(). The names differ on purpose — after type erasure, four methods called get() with different return types could not coexist.

UnaryOperator specializations

Interface Abstract method Description
IntUnaryOperator int applyAsInt(int) Takes an int and returns an int.
LongUnaryOperator long applyAsLong(long) Takes a long and returns a long.
DoubleUnaryOperator double applyAsDouble(double) Takes a double and returns a double.

BinaryOperator specializations

Interface Abstract method Description
IntBinaryOperator int applyAsInt(int, int) Takes two int values and returns an int.
LongBinaryOperator long applyAsLong(long, long) Takes two long values and returns a long.
DoubleBinaryOperator double applyAsDouble(double, double) Takes two double values and returns a double.

BiConsumer specializations

Interface Abstract method Description
ObjIntConsumer<T> void accept(T, int) Takes an object of type T and an int, returns nothing.
ObjLongConsumer<T> void accept(T, long) Takes an object of type T and a long, returns nothing.
ObjDoubleConsumer<T> void accept(T, double) Takes an object of type T and a double, returns nothing.

The argument order is fixed: the object comes first, the primitive second. That shape is what the three-argument IntStream.collect() expects for its accumulator.

ObjIntConsumer<StringBuilder> append = StringBuilder::append;

StringBuilder sb = new StringBuilder("x=");
append.accept(sb, 42);
System.out.println(sb);   // x=42

BiFunction specializations

Interface Abstract method Description
ToIntBiFunction<T, U> int applyAsInt(T, U) Takes two objects and returns an int.
ToLongBiFunction<T, U> long applyAsLong(T, U) Takes two objects and returns a long.
ToDoubleBiFunction<T, U> double applyAsDouble(T, U) Takes two objects and returns a double.

There is no IntBiFunction and no primitive BiPredicate: two primitive arguments are covered only by IntBinaryOperator and its long and double siblings.

How to decode an interface name

Names in java.util.function follow a strict pattern. Learn it once and you can reconstruct the signature of any of the 43 interfaces from its name, without opening the Javadoc.

Name element What it means Example Signature
Int / Long / Double at the start Type of the input parameter IntPredicate boolean test(int)
To plus a type Type of the return value ToIntFunction<T> int applyAsInt(T)
Type plus To plus type Both input and output are primitive IntToDoubleFunction double applyAsDouble(int)
Obj plus a type Object first, primitive second ObjIntConsumer<T> void accept(T, int)
Bi at the start Two object arguments BiFunction<T, U, R> R apply(T, U)
UnaryOperator / BinaryOperator All types are the same IntBinaryOperator int applyAsInt(int, int)

Method names follow the same logic: when the result is a primitive, the verb gets an As plus type suffix — applyAsInt, applyAsLong, applyAsDouble, getAsInt, getAsBoolean. test and accept need no suffix, because their return types (boolean and void) are fixed anyway.

Where these interfaces show up in the Stream API

In everyday code you rarely declare a variable of type ToIntFunction. You meet these interfaces in the signatures of Stream, IntStream, Map and List methods, where they decide which lambda shape the compiler expects from you.

Method Expected interface What happens
Stream<T>.mapToInt() ToIntFunction<T> Moves from a stream of objects to an IntStream
IntStream.map() IntUnaryOperator Transforms int to int with no boxing
IntStream.mapToObj() IntFunction<R> Goes back from IntStream to Stream<R>
IntStream.filter() IntPredicate Selects primitives by a condition
IntStream.forEach() IntConsumer Terminal operation with no result
IntStream.reduce() IntBinaryOperator Folds the stream into a single number
IntStream.collect() Supplier, ObjIntConsumer, BiConsumer Manual result assembly
Map<K, V>.forEach() BiConsumer<K, V> Iterates over key and value pairs
Map<K, V>.merge() BiFunction<V, V, V> Merges the old and the new value
List<E>.replaceAll() UnaryOperator<E> Replaces every element in place
Arrays.setAll(int[], ...) IntUnaryOperator Fills an array based on the index
List<String> words = List.of("Java", "Stream", "API");

// ToIntFunction<String>: object -> primitive
int totalLength = words.stream()
                       .mapToInt(String::length)
                       .sum();                       // 13

// IntPredicate: filtering without boxing
long evenCount = IntStream.rangeClosed(1, 10)
                          .filter(n -> n % 2 == 0)
                          .count();                  // 5

// IntBinaryOperator: reduction
int factorial = IntStream.rangeClosed(1, 5)
                         .reduce(1, (a, b) -> a * b); // 120

// IntFunction<String>: primitive -> object
List<String> labels = IntStream.rangeClosed(1, 3)
                               .mapToObj(n -> "#" + n)
                               .toList();             // [#1, #2, #3]

Easy to get wrong

A method reference such as Integer::parseInt fits several interfaces at once — both ToIntFunction<String> and Function<String, Integer>. The compiler picks by target type, and in the second case you silently get a boxed result. Whenever you write the type yourself, choose the primitive version.

Where developers get tripped up

  • Primitive interfaces do not extend the generic ones. IntBinaryOperator is not a subtype of BiFunction, and IntPredicate is not a subtype of Predicate. They are independent types, so a variable of one cannot be assigned to a variable of the other.
  • IntFunction, ToIntFunction and ToIntBiFunction have no default methods. The familiar andThen() and compose() live on Function, IntUnaryOperator and DoubleUnaryOperator, but not on the mixed specializations — you chain those by hand inside a lambda.
  • There are no specializations for float, byte, short or char. You use the int and double versions and rely on widening conversion.
  • UnaryOperator<T> and Function<T, T> are interchangeable in one direction only. A UnaryOperator<String> can be passed where a Function<String, String> is expected, but not the other way around: UnaryOperator extends Function, it does not equal it.
  • Forgetting boxed(). To turn an IntStream into a Stream<Integer> and collect it into a List<Integer>, you need boxed() or mapToObj() first.

Rule of thumb

Writing ordinary business code where a lambda runs a few dozen times? Reach for the generic interface — it reads better. Processing large numeric collections, arrays or streams of thousands to millions of elements? Reach for the primitive specialization together with IntStream, LongStream or DoubleStream. The full list of signatures is always in the java.util.function Javadoc.

Frequently asked questions

What is the difference between IntFunction and ToIntFunction?

IntFunction<R> takes a primitive and returns an object: R apply(int). ToIntFunction<T> does the opposite — it takes an object and returns a primitive: int applyAsInt(T). The rule is simple: a type prefix in front of the name describes the argument, while the To prefix describes the result.

Why are there no float, byte, short or char specializations?

Covering all eight primitive types would have produced hundreds of interfaces, so the JDK authors stopped at int, long and double. Values of byte, short and char widen to int without losing precision, and float widens to double. The only concession to boolean is BooleanSupplier.

Can I assign an IntBinaryOperator to a BinaryOperator variable?

No. Primitive specializations do not extend the generic interfaces — they are parallel, unrelated types. The compiler will happily infer the lambda (a, b) -> a + b as either one, but an existing variable has to be wrapped by hand: BinaryOperator<Integer> op = (a, b) -> intOp.applyAsInt(a, b);

Why does BooleanSupplier exist when Supplier<Boolean> already does?

BooleanSupplier declares boolean getAsBoolean() and hands back a primitive, while Supplier<Boolean> returns an object that may be null — and unboxing that null throws a NullPointerException. The primitive version is both faster and safer by contract.

How much does autoboxing actually slow code down?

On a handful of calls the difference is invisible and not worth the extra complexity. It shows up at hundreds of thousands to millions of iterations: every boxed int is a separate heap object of about 16 bytes plus a reference, extra work for the garbage collector, and lost data locality. The Integer cache only covers values from minus 128 to 127.

Комментарии

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