20. Functional Programming in Java
Table of Contents
Functional programming is a programming paradigm that focuses on describing what should be done rather than how it should be done.
Starting from Java 8, the language added several features that enable functional-style programming: lambda expressions, functional interfaces, and method references.
These features allow developers to write more expressive, concise, and reusable code, especially when working with collections, concurrency APIs, and event-driven systems.
20.1 Functional Interfaces
In Java, a functional interface is an interface that contains exactly one abstract method.
Functional interfaces enable Lambda Expressions and Method References, forming the core of Java’s functional programming model.
Note
Java automatically treats any interface with a single abstract method as a functional interface. The @FunctionalInterface annotation is optional but recommended.
20.1.1 Rules for Functional Interfaces
- Exactly one abstract method (SAM = Single Abstract Method).
- Interfaces may declare any number of default, static or private methods.
- They may override
Objectmethods (toString(),equals(Object),hashCode()) without affecting SAM count. - The functional method may come from a superinterface.
Example:
@FunctionalInterface
interface Adder {
int add(int a, int b); // single abstract method
static void info() {}
default void log() {}
}
20.1.2 Common Functional Interfaces (java.util.function)
Below is a summary of the most important functional interfaces.
| Functional Interface | Returns | Method | Parameters |
|---|---|---|---|
Supplier<T> |
T | get() | 0 |
Consumer<T> |
void | accept(T) | 1 |
BiConsumer<T,U> |
void | accept(T,U) | 2 |
Function<T,R> |
R | apply(T) | 1 |
BiFunction<T,U,R> |
R | apply(T,U) | 2 |
UnaryOperator<T> |
T | apply(T) | 1 (same types) |
BinaryOperator<T> |
T | apply(T,T) | 2 (same types) |
Predicate<T> |
boolean | test(T) | 1 |
BiPredicate<T,U> |
boolean | test(T,U) | 2 |
- Examples
Supplier<String> sup = () -> "Hello!";
Consumer<String> printer = s -> System.out.println(s);
Function<String, Integer> length = s -> s.length();
UnaryOperator<Integer> square = x -> x * x;
Predicate<Integer> positive = x -> x > 0;
20.1.3 Convenience Methods on Functional Interfaces
Many functional interfaces come with helper methods that allow chaining and composition.
| Interface | Method | Description |
|---|---|---|
| Function | andThen() | applies the function, then the other one specified in this additional method |
| Function | compose() | applies the other function specified in the additional method, then the function |
| Function | identity() | returns a function x -> x |
| Predicate | and() | logical AND |
| Predicate | or() | logical OR |
| Predicate | negate() | logical NOT |
| Consumer | andThen() | chains consumers |
| BinaryOperator | minBy() | comparator-based minimum |
| BinaryOperator | maxBy() | comparator-based maximum |
- Examples
Function<Integer, Integer> times2 = x -> x * 2;
Function<Integer, Integer> plus3 = x -> x + 3;
var result1 = times2.andThen(plus3).apply(5); // (5*2)+3 = 13
var result2 = times2.compose(plus3).apply(5); // (5+3)*2 = 16
Predicate<String> longString = s -> s.length() > 5;
Predicate<String> startsWithA = s -> s.startsWith("A");
boolean ok = longString.and(startsWithA).test("Amazing"); // true
20.1.4 Primitive Functional Interfaces
Java provides specialized versions of functional interfaces for primitives to avoid boxing/unboxing overhead.
| Functional Interface | Return Type | Single Abstract Method | # Parameters |
|---|---|---|---|
| IntSupplier | int | getAsInt() | 0 |
| LongSupplier | long | getAsLong() | 0 |
| DoubleSupplier | double | getAsDouble() | 0 |
| BooleanSupplier | boolean | getAsBoolean() | 0 |
| IntConsumer | void | accept(int) | 1 (int) |
| LongConsumer | void | accept(long) | 1 (long) |
| DoubleConsumer | void | accept(double) | 1 (double) |
| IntPredicate | boolean | test(int) | 1 (int) |
| LongPredicate | boolean | test(long) | 1 (long) |
| DoublePredicate | boolean | test(double) | 1 (double) |
| IntUnaryOperator | int | applyAsInt(int) | 1 (int) |
| LongUnaryOperator | long | applyAsLong(long) | 1 (long) |
| DoubleUnaryOperator | double | applyAsDouble(double) | 1 (double) |
| IntBinaryOperator | int | applyAsInt(int, int) | 2 (int,int) |
| LongBinaryOperator | long | applyAsLong(long, long) | 2 (long,long) |
| DoubleBinaryOperator | double | applyAsDouble(double,double) | 2 |
| IntFunction |
R | apply(int) | 1 (int) |
| LongFunction |
R | apply(long) | 1 (long) |
| DoubleFunction |
R | apply(double) | 1 (double) |
| ToIntFunction |
int | applyAsInt(T) | 1 (T) |
| ToLongFunction |
long | applyAsLong(T) | 1 (T) |
| ToDoubleFunction |
double | applyAsDouble(T) | 1 (T) |
| ToIntBiFunction |
int | applyAsInt(T,U) | 2 (T,U) |
| ToLongBiFunction |
long | applyAsLong(T,U) | 2 (T,U) |
| ToDoubleBiFunction |
double | applyAsDouble(T,U) | 2 (T,U) |
| ObjIntConsumer |
void | accept(T,int) | 2 (T,int) |
| ObjLongConsumer |
void | accept(T,long) | 2 (T,long) |
| ObjDoubleConsumer |
void | accept(T,double) | 2 (T,double) |
| DoubleToIntFunction | int | applyAsInt(double) | 1 |
| DoubleToLongFunction | long | applyAsLong(double) | 1 |
| IntToDoubleFunction | double | applyAsDouble(int) | 1 |
| IntToLongFunction | long | applyAsLong(int) | 1 |
| LongToDoubleFunction | double | applyAsDouble(long) | 1 |
| LongToIntFunction | int | applyAsInt(long) | 1 |
- Example
IntSupplier dice = () -> (int)(Math.random() * 6) + 1;
IntPredicate even = x -> x % 2 == 0;
IntUnaryOperator doubleIt = x -> x * 2;
20.1.5 Summary
- Functional interfaces contain exactly one abstract method (SAM).
- They power Lambdas and Method References.
- Java offers many built-in FIs in java.util.function.
- Primitive variants improve performance by removing boxing.
20.2 Lambda Expressions
A lambda expression is a compact way of writing a function.
Lambda expressions provide a concise way to define implementations of functional interfaces.
A lambda is essentially a short block of code that takes parameters and returns a value, without requiring a full method declaration.
They represent behavior as data and are a key element of Java’s functional programming model.
20.2.1 Syntax of Lambda Expressions
The general syntax is:
(parameters) -> expression
or
(parameters) -> { statements }
Important
A lambda expression does not introduce a new variable scope. As a result, variable names that already exist in the surrounding context cannot be redeclared as parameters in the lambda expression.
20.2.2 Examples of Lambda Syntax
Zero parameters
Runnable r = () -> System.out.println("Hello");
One parameter (parentheses optional)
Consumer<String> c = s -> System.out.println(s);
Multiple parameters
BinaryOperator<Integer> add = (a, b) -> a + b;
With a block body
Function<Integer, String> f = (x) -> {
int doubled = x * 2;
return "Value: " + doubled;
};
20.2.3 Rules for Lambda Expressions
- Parameter types may be omitted (type inference).
- If a parameter has a type, all parameters must specify the type.
- A single parameter does not require parentheses.
- Multiple parameters require parentheses.
- If the body is a single expression (no
{ }),returnis not allowed; the expression itself is the return value. - If the body uses
{ }(a block),returnmust appear if a value is returned. - Lambda expressions can only be assigned to functional interfaces (SAM types).
20.2.4 Type Inference
The compiler infers the lambda's type from the target functional interface context.
Predicate<String> p = s -> s.isEmpty(); // s inferred as String
If the compiler cannot infer the type, you must specify it explicitly.
BiFunction<Integer, Integer, Integer> f = (Integer a, Integer b) -> a * b;
20.2.5 Restrictions in Lambda Bodies
Lambdas can only capture local variables that are final or effectively final (not reassigned).
int x = 10;
Runnable r = () -> {
// x++; // ❌ compile error — x must be effectively final
System.out.println(x);
};
They CAN modify object state (only references must be effectively final).
var list = new ArrayList<>();
Runnable r2 = () -> list.add("OK"); // allowed
20.2.6 Return Type Rules
If the body is an expression: the expression is the return value.
Function<Integer, Integer> f = x -> x * 2;
If the body is a block: you must include return.
Function<Integer, Integer> g = x -> {
return x * 2;
};
20.2.7 Lambdas vs Anonymous Classes
- Lambdas do NOT create a new scope — they share the enclosing scope.
thisinside a lambda refers to the enclosing object, not the lambda.
class Test {
void run() {
Runnable r = () -> System.out.println(this.toString());
}
}
In anonymous classes, this refers to the anonymous class instance.
20.2.8 Common Lambda Errors
Inconsistent return types
x -> { if (x > 0) return 1; } // ❌ missing return for negative case
Mixing typed and untyped parameters
(a, int b) -> a + b // ❌ illegal
Returning a value from a void-target lambda
Runnable r = () -> 5; // ❌ Runnable.run() returns void
Ambiguous overload resolution
void m(IntFunction<Integer> f) {}
void m(Function<Integer, Integer> f) {}
m(x -> x + 1); // ❌ ambiguous
20.3 Method References
Method references provide a shorthand syntax for using an existing method as a functional interface implementation.
They are equivalent to lambda expressions, but more concise, readable, and often preferred when the target method already exists.
There are four categories of method references in Java:
- Reference to a static method (
ClassName::staticMethod) - Reference to an instance method of a particular object (
instance::method) - Reference to an instance method of an arbitrary object of a given type (
ClassName::instanceMethod) - Reference to a constructor (
ClassName::new)
20.3.1 Reference to a Static Method
A static method reference replaces a lambda that calls a static method.
class Utils {
static int square(int x) { return x * x; }
}
Function<Integer, Integer> f1 = x -> Utils.square(x);
Function<Integer, Integer> f2 = Utils::square; // method reference
Both f1 and f2 behave identically.
20.3.2 Reference to an Instance Method of a Particular Object
Used when you already have an object instance, and want to refer to one of its methods.
String prefix = "Hello, ";
UnaryOperator<String> op1 = s -> prefix.concat(s);
UnaryOperator<String> op2 = prefix::concat; // method reference
System.out.println(op2.apply("World"));
The reference prefix::concat binds concat to that specific object.
20.3.3 Reference to an Instance Method of an Arbitrary Object of a Given Type
This is the trickiest form.
The functional interface’s first parameter becomes the method’s receiver (this).
BiPredicate<String, String> p1 = (s1, s2) -> s1.equals(s2);
BiPredicate<String, String> p2 = String::equals; // method reference
System.out.println(p2.test("abc", "abc")); // true
Note
This form applies the method to the first argument of the lambda.
20.3.4 Reference to a Constructor
Constructor references replace lambdas that call new.
Supplier<ArrayList<String>> sup1 = () -> new ArrayList<>();
Supplier<ArrayList<String>> sup2 = ArrayList::new; // method reference
Function<Integer, ArrayList<String>> sup3 = ArrayList::new;
// calls the constructor ArrayList(int capacity)
20.3.5 Summary Table of Method Reference Types
The table below summarizes all method reference categories.
| Type | Syntax Example | Equivalent Lambda |
|---|---|---|
| Static method | Class::staticMethod | x -> Class.staticMethod(x) |
| Instance method of specific object | instance::method | x -> instance.method(x) |
| Instance method of arbitrary object | Class::method | (obj, x) -> obj.method(x) |
| Constructor | Class::new | () -> new Class() |
20.3.6 Common Pitfalls
- A method reference must match exactly the functional interface signature.
- Method overloads can make method references ambiguous.
- Instance-method reference (
Class::method) shifts the receiver to parameter 1. - Constructor reference fails if there is no matching constructor.
// ❌ Ambiguous: which println()? (println(int), println(String)...)
Consumer<String> c = System.out::println; // OK only because FI parameter is String
// ❌ No matching constructor: wrong functional interface
Supplier<Integer> s = Integer::new; // ✔ OK: calls Integer()
Function<String, Long> f = Integer::new; // ❌ ERROR: constructor returns Integer, not Long
When in doubt, rewrite the method reference as a lambda — if the lambda works but the method reference does not, the problem is usually signature matching.