Skip to content

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 Object methods (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 { }), return is not allowed; the expression itself is the return value.
  • If the body uses { } (a block), return must 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.
  • this inside 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.