Functional Programming in Java
An overview of functional programming concepts in Java — first-class functions, immutability, pure functions, and composition.
The previous part — Collections Framework — was about containers: data structures that hold elements and the operations (add, remove, iterate, sort, binarySearch) that work on them. This part is about a different layer of the same problem. Instead of where the data lives, we'll focus on how to express transformations on it — clearly, compositionally, without redundant loops or accumulator variables.
That shift has a name. Functional programming is the style where computation is expressed as the application of functions to values, where functions themselves are first-class values, and where data is usually treated as immutable. Java was not designed as a functional language — classes, mutation, and explicit loops are at its core — but since Java 8 every modern Java program borrows heavily from the functional toolbox. You already wrote some of it in the collections part: list.sort(Comparator.comparing(Person::name)), map.getOrDefault(k, 0), List.copyOf(source). What Part 12 does is name the style explicitly and give you the rest of the tools — lambdas, functional interfaces, the java.util.function types, method references, Optional, and the Stream API — so the patterns you've been imitating become first-class moves.
Four ideas that define the style
Pure functional languages (Haskell, Erlang, F#) push all four to their limits. Java applies them in moderation. The four ideas:
- Functions are first-class values. You can pass a function as an argument, return one from a method, store one in a field, or build one at runtime.
- Pure functions. A pure function depends only on its inputs and changes nothing observable about the world. Given the same input, it returns the same output. No I/O, no field mutation, no time-dependent branching.
- Immutability by default. Data structures are not modified in place; transformations return new values. Old references stay valid.
- Composition. Bigger functions are built by combining smaller ones (
f.andThen(g),pred.and(other),cmp.thenComparing(...)), not by editing them.
None of these are Java-specific. They're a way of thinking that the language now supports through lambdas, method references, the Stream API, and the immutable collections you just met.
1. Functions as values
Before Java 8, you couldn't have a variable whose value was a function. You could pass an object whose class happened to have one method on it — that's what Runnable, Comparator, and ActionListener were — but the syntax was clunky:
list.sort(new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
});The single method was wrapped in an anonymous-class declaration. Java 8 introduced lambda expressions as concise syntax for the same idea:
list.sort((a, b) -> a.length() - b.length());The lambda is the value. It compiles to an instance of whatever functional interface is needed at the call site (here, Comparator<String>). The next chapter is all about the syntax; for now the point is that functions in Java are now values you can name, store, and pass around.
2. Pure functions
A pure function is one whose return value depends only on its arguments and whose execution has no observable side effects. Math.sqrt(2) is pure. System.currentTimeMillis() is not — it returns different values across calls. list.add(x) is not — it mutates list.
Pure functions are valuable because:
- They're easy to test — no setup, no mocks, just
assertEquals(expected, f(input)). - They're easy to parallelise — two pure calls can run on different threads with no synchronisation.
- They're easy to cache — memoise once, return the same answer forever.
- They compose without surprise —
f(g(x))does what reading it suggests.
Most useful real programs are not 100 % pure (somebody has to write to a database). The functional discipline is to make the core computation pure and push the impure parts — I/O, time, randomness, mutation — to the edges. Streams encourage this: a pipeline of pure operations is correct by construction; an impure one (stream().peek(x -> counter++)...) is a bug magnet.
3. Immutability
You met this in the last chapter. List.of(...), Set.of(...), Map.of(...), and List.copyOf(...) produce collections that cannot be modified. Records (covered later) give you immutable data classes:
record Point(double x, double y) {
Point translated(double dx, double dy) {
return new Point(x + dx, y + dy); // returns a NEW Point — does not mutate this
}
}Immutable values are inherently thread-safe. They never present a "torn" intermediate state. They can be shared freely with no defensive copy. And they make pure functions practical — if values can't change, a function that returns one is guaranteed to be deterministic for that part of the world.
4. Composition
Composition is "build a big function out of small ones." In Java, Function, Predicate, and Comparator all provide compositional operators:
Function<String, String> trim = String::trim;
Function<String, String> upper = String::toUpperCase;
Function<String, String> clean = trim.andThen(upper); // trim, then upper
Predicate<Integer> positive = n -> n > 0;
Predicate<Integer> even = n -> n % 2 == 0;
Predicate<Integer> posEven = positive.and(even);
Comparator<String> byLength = Comparator.comparingInt(String::length);
Comparator<String> lengthThenA = byLength.thenComparing(Comparator.naturalOrder());The compositional API is part of the value. You don't write a helper that takes two predicates and &&s them — you write a.and(b). The style scales: a six-step transformation can read top-to-bottom as a single expression instead of six nested loops with intermediate accumulators.
What Java keeps from the imperative side
Java is multi-paradigm. The functional features added in Java 8+ live next to the imperative features that have been there since 1.0. Some things stay imperative on purpose:
- Statements and control flow.
if,for,while,tryare still the basic building blocks; lambdas don't replace them, they replace anonymous-class boilerplate. - Mutable local variables. Inside a method body,
int sum = 0; for (int x : xs) sum += x;is still idiomatic. - Mutable fields where they make sense. Builders, caches, and stateful UI components still mutate.
The principle: use functional style where it makes the code clearer, not as dogma. A pure stream().mapToInt(Integer::intValue).sum() is clearer than a hand-rolled loop. A six-step lambda-composition pipeline that nobody on your team can read is not.
A worked example: imperative vs functional, side by side
The program below computes the average length of the non-blank strings in a list, twice. The first version is imperative — a mutable accumulator, an explicit loop, a guard against division by zero. The second version is functional — a stream pipeline of pure operations that reads top-to-bottom. The third snippet builds compound Predicate and Function values from smaller ones, showing composition in action.
What to take from the run:
- Both versions compute the same average. The imperative one declares two mutable counters and a loop body; the functional one chains five named operations that each describe what, not how.
Predicate.andbuilt a compound test (notNull.and(notBlank)) from two smaller predicates — no new helper method needed. That's composition in action.Function.andThendid the same for a value-producing pipeline:trimthenlength, expressed as one compositeFunction<String, Integer>.- Each operation in the stream is pure:
String::trim, the lambdas -> !s.isEmpty(),String::length— none mutate state. CallingtrimmedLen.apply(\" hi \")twice produced the same answer; that's the determinism guarantee that makes pure functions safe to memoise and parallelise.
What's next
The mental model is in place: functions are values, pure transformations compose, immutability frees you from a class of bugs. The next chapter, Java Lambda Expressions, introduces the concrete syntax — (params) -> body — that makes this style ergonomic in Java, plus the rules around variable capture, target typing, and where a lambda can appear.