Skip to content

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:

java
@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.

Use Composition Instead of Branching to Avoid Spread

java
static <T> Rule<T> all(List<Rule<T>> rules) {
    return value -> rules.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:

java
static <T> Function<T, List<String>> collectAll(List<Rule<T>> rules) {
    return value -> rules.stream()
        .map(rule -> rule.validate(value))
        .flatMap(Optional::stream)
        .toList();
}

A higher-order function moves the point of variation from within the branches up to the composition layer: individual rules focus on facts, while the combinator focuses on the control strategy.

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.
java
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:

java
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-circuit

Lazy 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:

java
// Error example: using side effects to collect results
List<String> names = new ArrayList<>();
orders.stream()
    .filter(Order::isPaid)
    .forEach(order -> names.add(order.customerName()));

// Clearer: Let the pipeline return a value
List<String> names = orders.stream()
    .filter(Order::isPaid)
    .map(Order::customerName)
    .toList();

Stream is not a collection

Java Stream is a one-time-consumption computation pipeline, not a repeatable data structure:

java
Stream<Order> paid = orders.stream().filter(Order::isPaid);
long count = paid.count();
// paid.findFirst(); // Should not reuse the same Stream

A Stream derived from an I/O source may also hold resources:

java
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 requires that the combination operation be safely divisible into chunks and then recombined. For example, addition satisfies the associative property:

text
(a + b) + c = a + (b + c)

However, floating-point addition does not strictly adhere to mathematical associativity due to rounding errors, meaning parallel grouping can produce results that differ in the least significant bits. 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 the same "read order (filter) transform, aggregate" code using both a traditional loop and a Stream:

  1. Name the intermediate rules in both versions;
  2. Ensure neither version modifies the external collection within map or filter;
  3. Clearly identify any short-circuiting behavior;
  4. If parallel execution is considered, verify that the reduction operation satisfies the associative property;
  5. 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.

References

Built with VitePress | Software Systems Atlas