Behavior Parameterization, Lambdas, and Method References in Java
Part 2 of 14 in Functional Java Unleashed
Java’s introduction of lambda expressions and the Stream API (Java 8, over a decade ago) didn’t add new computational power — it changed how you express computation. The key insight is treating behavior itself as data: pass it to methods, store it in variables, compose it from pieces.
This post walks through that idea in three stages. First, the pattern of behavior parameterization (which existed long before lambdas) and how much cleaner it becomes with lambda syntax. Second, method references — the syntactic shortcut when a lambda would just delegate to an existing method. Third, functional stream patterns for data processing pipelines.
Behavior Parameterization
Before lambdas, Java engineers expressed “do something different here” by defining a one-method interface (a functional interface) and passing an anonymous class implementing it:
interface DecidingCriterion<T> {
boolean test(T item);
}
The filter method doesn’t care what criterion it receives — only that the criterion knows how to say true or false. This lets you write the iteration logic once and swap behavior at the call site.
// Anonymous class — verbose but worked since Java 1.1
static List<Book> filterOldBooksAnon(List<Book> books) {
return filter(books, new DecidingCriterion<Book>() {
@Override
public boolean test(Book b) {
return b.year() < 2010;
}
});
}
The lambda form strips away the class declaration, the type annotation, and the method boilerplate:
// Lambda — same logic, one line
static List<Book> filterOldBooksLambda(List<Book> books) {
return filter(books, b -> b.year() < 2010);
}
The compiler infers that b is a Book from the functional interface’s parameter type. The -> operator separates the parameter list from the body, and a single expression implicitly becomes the return value.
Different criteria reuse the exact same filter method — no branching on flags, no factory of specialized filter methods:
The output shows that the anonymous class and lambda produce identical results — both find four books published before 2010 (Clean Code, The Pragmatic Programmer, Design Patterns, Java Concurrency in Practice). The lambda version does this with half a dozen tokens instead of eight lines.
The same filter method picks out the single recent book (Effective Java, 2017) when you pass a different criterion, and finds exactly one match for “Brian Goetz” by author. One method, zero conditional logic on the type of comparison — just swap in a different predicate at the call site.
Method References
When your lambda body is a single call to an existing method, Java 8 gave you :: syntax as a shortcut:
// Lambda form
.map(e -> Employee.computeBonus(e.salary()))
// Method reference — two forms that mean the same thing
.map(Employee::salary) // extract first
.map(Employee::salary).map(Employee::computeBonus) // extract + apply
or equivalently:
.map(e -> Employee.computeBonus(e.salary())) // one-liner lambda
The static method reference Employee::computeBonus works here because the stream element (double, from the prior salary extraction) matches the method’s parameter exactly. Method references resolve by signature matching, not by name alone.
There are four forms of method references:
- Static method:
Class::staticMethod— the stream element becomes an argument - Instance method on a specific object:
object::instanceMethod - Instance method on an arbitrary object:
Class::instanceMethod— the stream element becomesthis - Constructor reference:
ClassName::new
// (a) Static method ref — parameter matches stream element type
map(Employee::salary).map(Employee::computeBonus)
// (b) Instance method on a specific object
items.forEach(System.out::println); // not item -> System.out.println(item)
// (c) Instance method on any object of the class
.map(String::length) // stream element becomes 'this'
// (d) Predicate with static method ref
.filter(Employee::isSenior) // same as e -> Employee.isSenior(e)
The key distinction from lambdas: a method reference is not a function call. It’s a handle on an existing method that the compiler wires into the functional interface at compile time. This means no variable capture (you can’t accidentally close over a loop variable), and it compiles to a invokedynamic instruction — effectively zero runtime overhead compared to the equivalent lambda.
Two observations from the output:
The bonus calculation produces identical results through both paths ([14250.0, 19500.0, 11700.0, 21750.0, 13800.0]), confirming that map(Employee::salary).map(Employee::computeBonus) is semantically equivalent to map(e -> Employee.computeBonus(e.salary())). The two-step version is actually preferable when Employee::computeBonus exists as a standalone static utility — it keeps each transformation focused and reusable.
Senior filtering finds Bob (145,000) through both approaches. Name length extraction correctly yields [5, 3, 5, 4, 3] (Alice, Bob, Carol, Dave, Eve after uppercasing). Salary-sorted order is correct: Dave → Bob → Alice → Eve → Carol.
Functional Stream Patterns
Lambdas and method references shine when composed into data-processing pipelines. Streams let you chain operations declaratively:
// Filter → transform → aggregate in one fluent chain
Order.SAMPLE.stream()
.filter(o -> "COMPLETED".equals(o.status()))
.mapToDouble(Order::amount)
.sum(); // → $1610.00
// Group by key, then reduce within each group
Map<String, Double> revenueByCustomer = Order.SAMPLE.stream()
.filter(o -> "COMPLETED".equals(o.status()))
.collect(Collectors.groupingBy(
Order::customer,
Collectors.summingDouble(Order::amount)
));
The Collectors utility class provides ready-made reduction operations: summarizingDouble, joining, mapping, partitioningBy. You can also nest them — grouping by customer, then averaging amounts within each group:
order.stream()
.filter(o -> "COMPLETED".equals(o.status()))
.collect(Collectors.groupingBy(
Order::customer,
Collectors.averagingDouble(Order::amount)
));
For cases where the result might not exist (empty stream, no entries), Optional prevents null pointer bugs:
.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("N/A"); // safe fallback — never returns null
The stream run covers eight patterns in one pipeline demonstration. Filtering yields four completed orders (Alice, Carol, Dave, Eve). The two reduction approaches — mapToDouble(...).sum() and collect(Collectors.summingDouble(...)) — both produce $1610.00 for total revenue from completed orders, confirming that primitive-stream shortcuts and the Collectors API are interchangeable for basic aggregation.
Grouping by status splits eight orders into four groups: CANCELLED (1), COMPLETED (4), PENDING (2), SHIPPED (1). Revenue per customer isolates only completed orders — Alice (420), Dave (310). Partitioning splits the full set into true/false based on a predicate: 4 completed vs. 4 not-completed.
The top-spending customer (Dave at $630) demonstrates chaining groupBy → entrySet stream → max → Optional mapping — a pattern that would require multiple temporary variables in pre-Java-8 code.
Takeaway
A lambda is an inline function; a method reference is a shorthand for calling an existing method. Neither adds capability the language didn’t already have — they remove boilerplate so you can express what you want to do instead of how to construct the machinery to do it. The filter/map/collect pattern works because Java 8 standardized a set of functional interfaces (Predicate, Function, Consumer) that serve as contracts between caller and callee: one abstract method, type inference from context, and no interface to declare.
Reach for lambdas when the behavior is unique to the call site. Reach for method references when a named static or instance method already does what you need. The mental model isn’t “functional programming” — it’s polymorphism without inheritance: passing executable logic as data.