Composition and higher-order functions

Passing behaviour instead of flags, and the ordering trap: andThen gave 21 and compose gave 17 from the same two functions.

3 min read Modern Java: 8 to 25

A higher-order function takes a function or returns one. Composition builds a new function out of existing ones. Together they are how the functional style replaces a long method with small pieces you can recombine — and they have one ordering trap that catches almost everyone.

Higher-order functions, which you use every day

You have been writing them since the first lambda:

java
list.sort(Comparator.comparing(Order::total));      // takes a function
orders.stream().map(Order::id)                      // takes a function
Comparator.comparing(Order::total)                  // RETURNS a function

sort, map and filter take behaviour as an argument. Comparator.comparing goes further: it takes a function and returns a new one — a comparator built from an extractor. That second kind is where composition lives.

The reason this is worth naming is that it changes what "reusing code" means. Instead of a method with a flag parameter that switches behaviour, you pass the behaviour in:

java
// a flag
List<Order> filter(List<Order> xs, boolean onlyPaid, boolean onlyLarge) { ... }
 
// behaviour
List<Order> filter(List<Order> xs, Predicate<Order> keep) { ... }
filter(orders, Order::isPaid.and(o -> o.total() > 1000));

The flag version needs a new parameter for every new rule and a combinatorial set of branches. The predicate version needs neither — which is the strategy pattern from the design patterns course, arriving from the other direction.

Composition, and the order that trips people

Function has two composition methods that read almost the same and do opposite things. Two functions, starting from 5:

java
Function<Integer, Integer> plus2  = x -> x + 2;
Function<Integer, Integer> times3 = x -> x * 3;
plaintext
###   plus2.andThen(times3).apply(5) = 21
###   plus2.compose(times3).apply(5) = 17

Same two functions, different answers:

  • andThen runs plus2 first, then times3: (5 + 2) × 3 = 21. It reads left to right, the way the code is written.
  • compose runs times3 first, then plus2: (5 × 3) + 2 = 17. It reads right to left, the way mathematics writes f ∘ g.

The rule that removes the confusion: andThen means "and then", in the order you read it. compose follows the mathematical convention and is the one to be careful with. If a chain is surprising, it is almost always a compose somebody read left to right.

Building behaviour out of pieces

The payoff is combining small, named, tested functions into larger ones without writing a new method:

java
Predicate<Order> paid    = Order::isPaid;
Predicate<Order> large   = o -> o.total() > 1000;
Predicate<Order> flagged = Order::isFlagged;
 
Predicate<Order> needsReview = paid.and(large).and(flagged.negate());

needsReview is readable as a sentence, each piece is independently testable, and changing the rule is editing one line rather than restructuring an if. Comparator does the same for ordering:

java
Comparator<Order> byPriority = comparing(Order::isUrgent).reversed()
    .thenComparing(Order::total, reverseOrder())
    .thenComparing(Order::id);

That last thenComparing(Order::id) is the pagination lesson's unique tiebreaker, written as composition.

Returning a function

A function that builds and returns a function lets you configure behaviour once and apply it many times:

java
static Function<Double, Double> applyDiscount(double percent) {
    return price -> price * (1 - percent / 100);
}
 
Function<Double, Double> festival = applyDiscount(20);
festival.apply(1000.0);    // 800.0

percent is captured when applyDiscount runs and remembered by the returned lambda. This is a closure, and it is subject to the rule the lambdas lesson covered: a captured local must be effectively final, because it is copied into the lambda rather than shared.

Where it stops paying

The functional style is a tool, and the paradigms lesson's warning applies: it is not a ranking.

  • A long chain is harder to debug than a loop. There is no line to put a breakpoint on inside a.andThen(b).andThen(c).andThen(d), and the stack trace is a list of lambda frames.
  • Point-free style can hide meaning. map(Order::total).reduce(0L, Long::sum) is clear; four composed method references with no names is a puzzle.
  • Checked exceptions do not compose. A Function cannot throw one, so a pipeline that touches I/O fills with try/catch wrappers.

The guideline that holds up: compose when each piece has a name and a meaning. When you find yourself composing anonymous pieces to avoid writing a method, write the method.

Progress is saved on this device and to your account when signed in.