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
intis 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
Integerobjects put pressure on the garbage collector; - an array of
Integerreferences is scattered across the heap, while anint[]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 DoubleToDoubleFunction — IntUnaryOperator 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.
IntBinaryOperatoris not a subtype ofBiFunction, andIntPredicateis not a subtype ofPredicate. They are independent types, so a variable of one cannot be assigned to a variable of the other. IntFunction,ToIntFunctionandToIntBiFunctionhave no default methods. The familiarandThen()andcompose()live onFunction,IntUnaryOperatorandDoubleUnaryOperator, but not on the mixed specializations — you chain those by hand inside a lambda.- There are no specializations for
float,byte,shortorchar. You use theintanddoubleversions and rely on widening conversion. UnaryOperator<T>andFunction<T, T>are interchangeable in one direction only. AUnaryOperator<String>can be passed where aFunction<String, String>is expected, but not the other way around:UnaryOperatorextendsFunction, it does not equal it.- Forgetting
boxed(). To turn anIntStreaminto aStream<Integer>and collect it into aList<Integer>, you needboxed()ormapToObj()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.
Комментарии