4.2 Higher-Order Functions and Composition: Turning Control Flow into Data
Order validation often begins with a series of if. As the rule set grows, each branch evaluates conditions, constructs error messages, and decides whether to proceed. Higher-order functions organize the "rules to be executed" as data; reducing a few lines of loop code is secondary.
Functions Can Be Passed and Returned
A function that accepts another function as a parameter or returns a function is known as a higher-order function:
@FunctionalInterface
interface Rule<T> {
Optional<String> validate(T value);
}
static Rule<Order> minimumAmount(BigDecimal minimum) {
return order -> order.total().compareTo(minimum) >= 0
? Optional.empty()
: Optional.of("Order amount cannot be less than " + minimum);
}minimumAmount returns a function that captures minimum. The captured variable forms a closure. Java requires local variables to be final or effectively final, which prevents a closure from observing a local variable that is modified after its initial binding. However, the object itself that is captured may still be mutable.
Before Lambdas were introduced, Java could pass behavior via anonymous inner classes. Java 8 added lambda expressions, method references, and corresponding functional interfaces, not the idea that functions were absolutely forbidden as parameters.
Put the Control Policy in the Combinator
static <T> Rule<T> all(List<Rule<T>> rules) {
var snapshot = List.copyOf(rules);
return value -> snapshot.stream()
.map(rule -> rule.validate(value))
.flatMap(Optional::stream)
.findFirst();
}
Rule<Order> checkoutRule = all(List.of(
minimumAmount(new BigDecimal("10.00")),
order -> order.items().isEmpty()
? Optional.of("Order cannot be empty")
: Optional.empty(),
order -> order.shippingAddress() == null
? Optional.of("Shipping address is missing")
: Optional.empty()
));The control strategy here is "execute rules in sequence and return the first error." If the business logic requires returning all errors at once, the combination function can be modified instead of rewriting each individual rule:
static <T> Function<T, List<String>> collectAll(List<Rule<T>> rules) {
var snapshot = List.copyOf(rules);
return value -> snapshot.stream()
.map(rule -> rule.validate(value))
.flatMap(Optional::stream)
.toList();
}Each combinator copies the rule list when it is created, so later edits to the caller’s list do not silently change the rule set. Rule objects must still behave consistently and return non-null Optional values. Use collectAll only when each rule can run independently: a rule that dereferences an address cannot blindly follow a failed non-null check.
The Java fragments use JDK 17 or later (Optional.stream needs Java 9 and Stream.toList needs Java 16). Put the methods inside a class and supply the Order record and standard imports before running them.
map, filter, fold Each Answer a Different Question
When processing collections, it's helpful to distinguish among three types of operations:
map: Each input element produces one output element;filter: Retains only elements that meet certain conditions;fold/reduce: Combines multiple elements into a single result.
BigDecimal revenue = orders.stream()
.filter(order -> order.status() == PAID)
.map(Order::total)
.reduce(BigDecimal.ZERO, BigDecimal::add);This pipeline can be read directly as "filter paid orders, extract amounts, then sum them up." However, chained method calls aren't inherently clearer. If each Lambda contains ten or more lines of branching logic, or if complex state needs to be observed between stages, it's better to extract named functions, sometimes even reverting to explicit loops.
Lazy Evaluation Changes Execution Timing
Java Stream intermediate operations are typically lazy, meaning they don't execute until a terminal operation begins:
var pipeline = orders.stream()
.filter(Order::isPaid)
.map(Order::total); // The traversal has not yet been performed
var first = pipeline.findFirst(); // Execute from here and may short-circuitLazy evaluation can avoid unnecessary computations and handle potentially infinite sequences. However, it means that the timing of errors and side effects no longer aligns with the order in which the code is written.
According to the Java documentation, behaviors passed to a Stream should generally be stateless and must not alter the data source. Implementations are allowed to skip certain stages without affecting the result, so you should not rely on side effects like logging or counting in map or filter to guarantee execution:
// Works sequentially; unsafe with parallel writes to this ArrayList
List<String> imperativeNames = new ArrayList<>();
orders.stream()
.filter(Order::isPaid)
.forEach(order -> imperativeNames.add(order.customerName()));
// Clearer: Let the pipeline return a value
List<String> names = orders.stream()
.filter(Order::isPaid)
.map(Order::customerName)
.toList();The terminal forEach above does execute its action for each selected element; it is not an example of an elided map operation. Its weakness is external mutable accumulation, especially if later made parallel. The returned-value version removes that shared mutation, but its toList() result is unmodifiable. Choose a mutable collector explicitly if callers need to modify the list.
Stream is not a collection
Java Stream is a one-time-consumption computation pipeline, not a repeatable data structure:
Stream<Order> paid = orders.stream().filter(Order::isPaid);
long count = paid.count();
// paid.findFirst(); // Should not reuse the same StreamA Stream derived from an I/O source may also hold resources:
try (Stream<String> lines = Files.lines(path)) {
return lines.filter(s -> !s.isBlank()).count();
}Storing a Stream in a field, passing it across methods, or consuming it multiple times makes its lifecycle difficult to track. It should typically be created near the data source and terminated within the same scope.
Parallel Streams Are Not Free Acceleration
Changing .stream() to .parallelStream() only alters the execution strategy, it does not fix the lack of associativity in reduction operations, nor does it automatically provide reasonable isolation for blocking I/O.
Parallel reduction needs an associative combination, a valid identity where required, and an accumulator compatible with the combiner. Mathematical integer addition illustrates associativity:
(a + b) + c = a + (b + c)However, floating-point addition does not strictly adhere to mathematical associativity due to rounding errors, so regrouping can cause small differences or much larger cancellation errors. For example, (1e16 + -1e16) + 1.0 is 1.0 in Java double arithmetic, while 1e16 + (-1e16 + 1.0) is 0.0. In cases involving sequence dependencies, shared state, thread pool contention, or a small number of inexpensive elements, the overhead of parallelization may actually exceed the benefits. Whether to use parallel streams should be determined through benchmarking and evaluation of the actual runtime environment, rather than by assuming that larger data volumes automatically justify parallelism alone.
Completion Check
Write an order-reading, filtering, transformation, and aggregation pipeline as both a loop and a Stream:
- Name the intermediate rules in both versions;
- Ensure neither version modifies the external collection within
maporfilter; - Clearly identify any short-circuiting behavior;
- If parallel execution is considered, verify that the reduction operation satisfies the associative property;
- Choose the version that is easier to maintain and provide a justification.
The goal of functional style is to narrow the scope of reasoning, not to make every piece of code into a long chain of method calls.