Functional Programming
Functional Programming
Functional programming (FP) is a declarative programming paradigm that models software using pure functions, immutable data, and function composition.
The core principles of FP:
Pure Functions. Functions are pure and deterministic β they always return the same output for a given input and produce no side effects.
Recursion. Recursion is used for iteration instead of traditional loops. A recursive function calls itself until it reaches the base case.
Immutability. Once an object is created, it cannot be changed. To change its value, you create a new object with the desired modification.
Higher-Order Functions. Functions can be used like any other value β passed as arguments, returned from other functions, and stored in data structures.
Function Composition. Multiple functions can be combined to create a new function, and chained to perform complex transformations on data without modifying it.
Java 8 introduced functional programming support through the
java.util.function package.
Lambda Expressions
Lambda expressions (lambdas) form the core unit of functional programming in Java. In FP, functions are “first-class citizens” β they can be created, stored, referenced, and passed around just like objects. In other words, a lambda expression is an anonymous function (without a name or access modifiers).
A lambda expression is used to implement a functional interface.
Functional Interfaces
Lambdas in Java can replace certain anonymous inner classes. A lambda expression’s type must be a functional interface β an anonymous class that doesn’t implement a functional interface cannot be written as a lambda.
A functional interface is an interface that has exactly one abstract method (default and static methods don’t count). The optional @FunctionalInterface annotation explicitly marks the interface as functional and causes a compile-time error if the interface doesn’t meet this requirement.
Functional interface examples from the JDK:
A lambda expression is an implementation of the single abstract method in a functional interface.
Lambda Expression Syntax
- Full form:
(...parameters) -> { /* body */ } - Single argument:
parameter -> { /* body */ } - Immediate return:
(...parameters) -> result
// Block body, no return value
Runnable command1 = () -> {
String str = "Runnable Lambda";
IO.println(str);
};
command1.run(); // Runnable Lambda
// Block body, with return value
Callable<String> command2 = () -> { return "Callable Lambda"; };
IO.println(command2.call()); // Callable Lambda
// Expression lambda (immediate return)
Callable<String> command3 = () -> "Callable Lambda (Simplified)";
IO.println(command3.call()); // Callable Lambda (Simplified)
// Multiple arguments
Comparator<Integer> comparator1 = (a, b) -> a.compareTo(b);
IO.println(comparator1.compare(10, 15)); // -1
// Method reference
Comparator<Double> comparator2 = Double::compareTo;
IO.println(comparator2.compare(4.3, 3.44)); // 1Under the hood, a lambda expression is still translated into an object, so it doesn’t provide a significant performance improvement on its own.
Capturing Local Values
Inside a lambda, you can use variables from the enclosing scope, but with some constraints:
- A local variable used inside a lambda must be final or effectively final (i.e., never modified after assignment).
- The
thiskeyword inside a lambda refers to the enclosing class, not the lambda itself.
This is because lambdas can only read variables from the outer scope β they cannot modify them. Reading an outer variable inside a lambda is called capturing. Lambdas capture the value of a variable, not a reference to it.
// Works fine
String title = "Software Design";
Runnable runnable = () -> IO.println("Variable from outside: " + title);
runnable.run(); // Variable from outside: Software Design
// Compile error: Variable used in lambda expression should be final or effectively final
int employeesCount = 5;
Runnable printEmployees = () -> IO.println("Employees Total: " + employeesCount);
printEmployees.run();
employeesCount++; // this modification makes employeesCount non-effectively-finalSerializing Lambdas
Lambda expressions can be stored in data structures and serialized.
// Casting to both Comparator and Serializable tells the compiler to treat
// this lambda as serializable without needing a custom interface.
Comparator<Integer> comparator = (Comparator<Integer> & Serializable) (a, b) -> b - a;
// Serialize to a file
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("comparator.ser"))) {
out.writeObject(comparator);
}
// Deserialize from the file
Comparator<Integer> restored;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("comparator.ser"))) {
restored = (Comparator<Integer>) in.readObject();
}
IO.println(restored.compare(5, 7)); // 2Built-in Functional Interfaces
java.util.function provides a set of standard functional interfaces covering common use cases, particularly in the Collections Framework and the Stream API.
They fall into 4 main categories:
- Supplying (
Supplier<T>) β takes no arguments, returns a value. - Consuming (
Consumer<T>) β takes an argument, returns nothing. - Testing (
Predicate<T>) β takes an argument, returns aboolean. - Mapping (
Function<T, R>) β takes an argument, returns a transformed value.
Supplying (Supplier<T>)
Supplier<T> returns (supplies) a value and takes no arguments. It describes a lambda that produces a value on each call β either a constant or something computed dynamically, such as a random number or a timestamp.
@FunctionalInterface
public interface Supplier<T> {
T get();
}
// Static value
Supplier<String> title = () -> "Software Design";
title.get(); // "Software Design"
title.get(); // "Software Design"
// Dynamic value
Supplier<Instant> timestamp = () -> Instant.now();
timestamp.get(); // 2026-05-05T21:39:27.588543Z
timestamp.get(); // 2026-05-05T21:39:29.228348ZThe key distinction is that instead of passing a value, you pass an instruction for how to obtain it. Common use cases:
- Lazy initialization. Create expensive objects only when they are actually needed.
- Default values. Compute a fallback value only if required.
- Logging. Build a dynamic log message only when the log threshold is met.
- Dependency injection. Inject a provider or factory rather than the value itself.
To avoid unnecessary boxing/unboxing, the JDK provides primitive-specialised variants:
IntSupplier intValue = () -> 999; // similar to Supplier<Integer>
int intV = intValue.getAsInt(); // 999
BooleanSupplier booleanValue = () -> true; // similar to Supplier<Boolean>
boolean boolV = booleanValue.getAsBoolean(); // true
LongSupplier longValue = () -> 1_000_000L; // similar to Supplier<Long>
long longV = longValue.getAsLong(); // 1000000
DoubleSupplier doubleValue = () -> 0.995; // similar to Supplier<Double>
double doubleV = doubleValue.getAsDouble(); // 0.995Consuming (Consumer<T>)
Consumer<T> takes an argument but returns nothing. It is primarily used when you have a value and need to define what to do with it. Common scenarios:
- Printing. Define the output channel, for example
.forEach(System.out::println). - UI rendering. Receive a new state and repaint the UI based on it.
- Event handling. Handle an event by providing a callback.
- Persisting data. Receive a resource and save it to external storage.
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
}
Consumer<String> logger = str -> {
IO.println("[" + Instant.now().getEpochSecond() + "]: " + str);
};
logger.accept("Application started"); // [1778018884]: Application started
logger.accept("DB connected"); // [1778018893]: DB connected
// Specialized Consumers
IntConsumer consumeInt = intValue -> IO.println(intValue);
consumeInt.accept(10); // 10
LongConsumer consumeLong = longValue -> IO.println(longValue);
consumeLong.accept(1_000_000L); // 1000000
DoubleConsumer consumeDouble = doubleValue -> IO.println(doubleValue);
consumeDouble.accept(3.14); // 3.14In the Collections API, Iterable.forEach() accepts a Consumer instance as its argument.
Testing (Predicate<T>)
Predicate<T> tests an object and returns a boolean result. It is most commonly used to filter elements in a collection or stream.
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
}
Predicate<String> isLongString = str -> str.length() > 5;
isLongString.test("short"); // false
isLongString.test("massive"); // true
// Specialized predicates
IntPredicate predicateInt = intValue -> intValue > 10;
predicateInt.test(999); // true
LongPredicate predicateLong = longValue -> longValue == 1L;
predicateLong.test(1); // true
DoublePredicate predicateDouble = doubleValue -> (int) doubleValue == doubleValue;
predicateDouble.test(25.33); // falsePredicate is widely used in the Collections Framework:
List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));
Predicate<Integer> isEven = i -> i % 2 == 0;
// Produce a new filtered list
List<Integer> even = numbers.stream().filter(isEven).toList();
IO.println(even); // [2, 4, 6]
// Modify the existing list in place
numbers.removeIf(isEven);
IO.println(numbers); // [1, 3, 5]Mapping (Function<T, R>)
Function<T, R> takes an object of type T and returns a transformed object of type R. It is the most general of the four categories β Predicate<T> is effectively a Function<T, Boolean>.
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
}
Function<String, Integer> toLength = s -> s.length();
toLength.apply("Software"); // 8When the input and output types are the same, UnaryOperator<T> is a more expressive shorthand:
UnaryOperator<String> toUpperCase = s -> s.toUpperCase();
toUpperCase.apply("hello"); // HELLOTo avoid boxing and unboxing overhead with primitives, the JDK provides specialised variants. The table below maps input (rows) to output (columns):
| T | int | long | double | |
|---|---|---|---|---|
| T | UnaryOperator<T> | IntFunction<T> | LongFunction<T> | DoubleFunction<T> |
| int | ToIntFunction<T> | IntUnaryOperator | LongToIntFunction | DoubleToIntFunction |
| long | ToLongFunction<T> | IntToLongFunction | LongUnaryOperator | DoubleToLongFunction |
| double | ToDoubleFunction<T> | IntToDoubleFunction | LongToDoubleFunction | DoubleUnaryOperator |
Method References
If a lambda just calls an existing method, it can be written as a method reference:
// Lambda expression
Consumer<String> printer = s -> IO.println(s);
// Equivalent method reference
Consumer<String> printer = IO::println;Method references fall into 4 categories:
- Static β reference to a static method (
RefType::staticMethod) - Bound β reference to a method on a specific object instance (
expr::instanceMethod) - Unbound β reference to an instance method, where the instance is the first argument (
RefType::instanceMethod) - Constructor β reference to a class constructor (
ClassName::new)
// Static method reference
DoubleUnaryOperator sqrt = Math::sqrt; // a -> Math.sqrt(a)
IntBinaryOperator max = Integer::max; // (a, b) -> Integer.max(a, b)
// Bound method reference
String prefix = "Hello";
Predicate<String> startsWith = prefix::equals; // s -> prefix.equals(s)
// Unbound method reference
Function<String, Integer> toLength = String::length; // s -> s.length()
Function<User, String> getId = User::getId; // user -> user.getId()
BiFunction<String, String, Integer> indexOf = String::indexOf; // (text, word) -> text.indexOf(word)
// Constructor method reference
Supplier<List<String>> makeList = ArrayList::new; // () -> new ArrayList<>()
Function<Integer, List<String>> makeSizedList = ArrayList::new; // size -> new ArrayList<>(size)Resources
- π Dev Java - Lambda Expressions
- πΉ Implementing Lambda Expressions in Java with Brian Goetz
- π Oracle - java.util.function package