8. Functional Programming in an Object-Oriented Language
Java 8 added a second way of expressing computation: instead of saying how to
loop, you say what should happen to each element and let the library run the
loop. This chapter covers lambdas and the functional interfaces they implement,
method references, the Stream API that chains them into pipelines, and
Optional as a disciplined answer to null. None of it replaces objects;
these are tools layered on top of the type system from chapters 4 and 6, and
they work because a lambda is just a compact way to supply one method.
<a id="81-lambda-expressions"></a>
8.1 Lambda Expressions
This subchapter covers the syntax of lambdas, how the compiler decides what type they have, and the scoping rules that make them behave unlike the anonymous classes they replace.
Lambda Syntax and Target Typing
Before Java 8, passing behaviour to a method meant wrapping it in an object. Sorting a list by name required a whole class definition just to carry one comparison:
Collections.sort(people, new Comparator<Person>() {
@Override
public int compare(Person a, Person b) {
return a.getName().compareTo(b.getName()); // the only line that matters
}
});
Five of those six lines are ceremony. The compiler already knows the interface,
the method name, and the parameter types, because sort declared them. A
lambda expression removes everything the compiler can work out for itself:
people.sort((a, b) -> a.getName().compareTo(b.getName()));
The syntax has three parts: a parameter list, the arrow ->, and a body.
(String a, String b) -> a.length() - b.length()
|__________________| |____________________|
parameters body
Each part has shorthands:
// Zero parameters: empty parentheses are required
Runnable r = () -> System.out.println("running");
// One parameter: parentheses optional, types inferred
Consumer<String> c1 = (String s) -> System.out.println(s);
Consumer<String> c2 = (s) -> System.out.println(s);
Consumer<String> c3 = s -> System.out.println(s); // most idiomatic
// Expression body: the value is returned implicitly, no return, no semicolon
Comparator<String> byLength = (a, b) -> a.length() - b.length();
// Block body: braces require explicit return and semicolons
Comparator<String> verbose = (a, b) -> {
int diff = a.length() - b.length();
return diff != 0 ? diff : a.compareTo(b);
};
Types are all-or-nothing: write them for every parameter or for none.
(String a, b) -> ... does not compile. The var form, (var a, var b) ->,
is allowed since Java 11 and is mainly useful when you need to attach an
annotation to a parameter.
The deeper question is what type a lambda has. The answer is that it has no type of its own. A lambda is only legal where the compiler can see a target type: a functional interface, meaning an interface with exactly one abstract method.
Runnable r = () -> System.out.println("hi");
// The compiler reasons: Runnable has one abstract method, run().
// It takes no arguments and returns void. This lambda matches. Accept it.
The same lambda text can mean different things depending on the target:
interface Validator { boolean check(String s); }
interface Length { int measure(String s); }
Validator v = s -> s.isEmpty(); // becomes a Validator
Predicate<String> p = s -> s.isEmpty(); // the SAME text, a different type
// var bad = s -> s.isEmpty(); // ERROR: no target type to infer from
That last line is the practical consequence. A lambda cannot stand alone; it needs a variable declaration, a parameter, a return type, or a cast to give it meaning.
Where a target type is available, lambdas appear in four places:
Predicate<String> assigned = s -> s.isBlank(); // assignment
list.removeIf(s -> s.isBlank()); // method argument
Supplier<String> returned = makeSupplier(); // return value
Object o = (Runnable) () -> System.out.println("x"); // cast
@FunctionalInterface is an optional annotation that makes the compiler verify
the single-abstract-method rule. It changes nothing at runtime but turns a
later accidental second method into a compile error rather than a puzzle:
@FunctionalInterface
interface Transformer {
String apply(String input); // exactly one abstract method
default Transformer twice() { // default methods do not count
return s -> apply(apply(s));
}
// String other(String s); // would break the annotation's check
}
Key Takeaways
- A lambda is a compact expression supplying the body of a single method.
- The syntax is parameters,
->, then an expression body or a braced block.- An expression body returns its value implicitly; a block body needs explicit
return.- Parameter types are written for all parameters or for none.
- A lambda has no standalone type; it requires a functional interface as its target type.
- A functional interface has exactly one abstract method, and
defaultandstaticmethods do not count.@FunctionalInterfaceasks the compiler to enforce that rule.
🧪 Practice
- Write lambdas for
Runnable,Comparator<String>, and a custominterface Greeter { String greet(String name); }.- Convert a three-line block-bodied lambda into an expression-bodied one.
- Try
var f = x -> x + 1;and explain the compiler error in terms of target typing.- Interview: Why can a lambda not be assigned to
Object? (Hint: what does the compiler need in order to know which method the body implements?)
Lambdas versus Anonymous Classes
Lambdas look like shorthand for anonymous inner classes, and for everyday use
they are. Underneath they differ in what the compiler generates, what this
means, and what they are allowed to do. The differences explain several
otherwise baffling behaviours.
Start with what they have in common, since both can supply an implementation inline:
// Anonymous class
Runnable a = new Runnable() {
@Override public void run() { System.out.println("anon"); }
};
// Lambda: same effect, one line
Runnable b = () -> System.out.println("lambda");
The first real difference is what each can implement. An anonymous class can implement an interface with any number of methods, or extend a class. A lambda is restricted to functional interfaces:
// Two abstract methods: only an anonymous class can do this
MouseListener ml = new MouseListener() {
@Override public void mousePressed(MouseEvent e) { }
@Override public void mouseReleased(MouseEvent e) { }
// ...
};
// MouseListener ml2 = e -> { }; // ERROR: not a functional interface
The second is state. An anonymous class is a class, so it can hold fields and a constructor-like initialiser. A lambda is a single expression with no place to put fields:
Supplier<Integer> counter = new Supplier<>() {
private int count = 0; // a lambda cannot do this
@Override public Integer get() { return ++count; }
};
The third is this, covered in detail two topics further on. In an
anonymous class this refers to the anonymous instance; in a lambda it refers
to the enclosing object.
The fourth is compilation strategy, which is where the performance
difference comes from. An anonymous class produces a separate class file,
Outer$1.class, loaded and instantiated like any other class. A lambda
produces no class file. The compiler emits a private method holding the body
plus an invokedynamic instruction, and the JVM builds an implementation at
first execution.
ANONYMOUS CLASS LAMBDA
javac -> Outer$1.class javac -> private static method
| + invokedynamic call site
new Outer$1() on every use |
| first execution: JVM links it once
a fresh object each time |
stateless lambdas are CACHED and reused
That caching is observable. A lambda that captures nothing can return the same
instance on every evaluation, whereas new always produces a fresh object:
Supplier<Runnable> anon = () -> new Runnable() {
@Override public void run() { }
};
System.out.println(anon.get() == anon.get()); // false: always new
Supplier<Runnable> lam = () -> () -> { };
System.out.println(lam.get() == lam.get()); // often true: cached instance
Never rely on lambda identity for that reason. Using a lambda as a map key, or
comparing two with ==, is undefined behaviour in practice. This also bites
when removing a listener: the lambda you pass to removeListener is a
different object from the one you passed to addListener, so the removal
silently fails. Keep a reference to the original if you need to remove it.
| Aspect | Anonymous class | Lambda |
|---|---|---|
| Target | Any interface or class | Functional interface only |
| Can hold fields | Yes | No |
this refers to | The anonymous instance | The enclosing instance |
| Shadowing locals | Allowed | Compile error |
| Generated artifact | A .class file | invokedynamic, no class file |
| Instance identity | Always new | May be cached and reused |
| Class loading cost | Per class, at load time | Deferred to first use |
Use a lambda by default. Reach for an anonymous class when you need state, multiple methods, a class rather than an interface, or a reference to the instance itself.
Key Takeaways
- Lambdas implement functional interfaces only; anonymous classes have no such limit.
- Anonymous classes can hold fields and state; lambdas cannot.
- An anonymous class generates a
.classfile; a lambda usesinvokedynamicand generates none.- Stateless lambdas may be cached, so two evaluations can yield the same instance.
- Never depend on lambda identity, and keep a reference if you need to unregister a listener.
- Prefer lambdas; use anonymous classes when you need state or multiple methods.
🧪 Practice
- Write the same
Comparatoras an anonymous class and as a lambda, and compare the line counts.- Compile a class containing both, list the generated
.classfiles, and explain what you see.- Register a lambda as a listener, then try to remove it by passing an identical lambda, and observe the result.
- Interview: When must you use an anonymous class instead of a lambda? (Hint: think about instance fields and about interfaces with two abstract methods.)
Variable Capture Rules
A lambda often needs data from around it. Java allows that, but only under a restriction that surprises newcomers: captured local variables must be final or effectively final. Understanding why removes the frustration and explains a family of compile errors.
The reason is lifetime. A local variable lives on the stack frame of the method that declared it, and that frame disappears when the method returns. A lambda can outlive its method, so it cannot point at the frame. Instead the compiler copies the value into the lambda at creation time.
makeGreeter() runs after it returns
+---------------------+ +---------------------+
| stack frame | | (frame is gone) |
| greeting = "Hi" | +---------------------+
+---------------------+
| copied at creation
v
+---------------------+ +---------------------+
| lambda object | | lambda object |
| greeting = "Hi" | ---------> | greeting = "Hi" |
+---------------------+ +---------------------+
still valid: it owns a copy
Once the lambda holds a copy, letting the original change would be incoherent: the two would disagree, and nobody could say which one the lambda should see. Java forbids the question rather than picking a confusing answer.
Effectively final means the variable is never reassigned after
initialisation, whether or not you wrote the keyword final:
String greeting = "Hello"; // never reassigned
Runnable ok = () -> System.out.println(greeting); // effectively final: fine
String name = "Ada";
name = "Grace"; // reassigned...
// Runnable bad = () -> System.out.println(name);
// ERROR: local variables referenced from a lambda must be final or
// effectively final
Note that the error appears even if the reassignment happens after the lambda. The rule is about the whole method body, not about ordering.
The classic loop cases follow directly:
// WORKS: each iteration of a for-each gets a FRESH variable
for (String s : List.of("a", "b")) {
runnables.add(() -> System.out.println(s));
}
// FAILS: one variable i, mutated by the loop, so not effectively final
for (int i = 0; i < 3; i++) {
// runnables.add(() -> System.out.println(i)); // ERROR
}
// FIX: copy into a fresh local per iteration
for (int i = 0; i < 3; i++) {
int captured = i; // a new variable each time
runnables.add(() -> System.out.println(captured));
}
The restriction applies only to local variables. Fields are reached through a reference to the object, not copied, so they may be reassigned freely:
public class Counter {
private int count = 0; // an instance FIELD
public Runnable increment() {
return () -> count++; // legal: captures `this`, not a copy
}
}
That distinction is worth stating precisely: a lambda that touches an instance
field captures this, which keeps the whole enclosing object alive. In a
long-lived lambda stored in a listener list, that can hold far more memory than
you expect.
Finally, "effectively final" constrains the reference, not the object it points to. A captured mutable object can still be mutated:
List<String> log = new ArrayList<>(); // reference never reassigned
Runnable r = () -> log.add("event"); // legal: we mutate, not reassign
r.run();
System.out.println(log); // [event]
// log = new ArrayList<>(); // this WOULD break the capture
That is the standard escape hatch when a lambda must accumulate something: use
a one-element array, an AtomicInteger, or a collection rather than a plain
local. Do so sparingly, since needing it usually means a reduce or a
Collector would express the intent better.
int[] total = {0}; // the array reference is final
List.of(1, 2, 3).forEach(n -> total[0] += n);
System.out.println(total[0]); // 6
Key Takeaways
- Captured local variables must be final or effectively final.
- The reason is that locals are copied into the lambda, because the stack frame may be gone.
- Effectively final means never reassigned anywhere in the method.
- A classic
forloop variable is not effectively final; copy it into a fresh local.- Instance and static fields are not restricted, because they are reached through a reference.
- Capturing a field captures
this, which can keep the enclosing object alive.- The restriction applies to the reference, not to the referenced object's contents.
🧪 Practice
- Write a method returning a
Supplier<String>that captures a local variable, and call it after the method returns.- Reproduce the "must be final or effectively final" error with a
forloop counter, then fix it.- Accumulate a sum inside a
forEachusing anAtomicInteger, then rewrite it without one.- Interview: Why are captured locals restricted when fields are not? (Hint: compare how long a stack frame lives with how long a heap object lives.)
Scope and this Semantics in Lambdas
An anonymous class introduces a new scope; a lambda does not. This single
difference explains how this resolves, why you cannot shadow a local inside a
lambda, and a well-known bug class in code migrated from anonymous classes.
A lambda is lexically scoped: it sees the enclosing method's scope as if its body had been written inline. There is no new "instance" for names to resolve against.
public class Widget {
private String name = "widget";
public void anonymousVersion() {
Runnable r = new Runnable() {
private String name = "anon"; // a NEW scope, own field
@Override public void run() {
System.out.println(this.name); // "anon"
System.out.println(Widget.this.name); // "widget"
}
};
r.run();
}
public void lambdaVersion() {
Runnable r = () -> {
System.out.println(this.name); // "widget": `this` is the Widget
};
r.run();
}
}
ANONYMOUS CLASS LAMBDA
Widget instance Widget instance
| ^
| encloses | this
v |
Runnable$1 instance <- this lambda body (no new scope)
|
| Widget.this reaches out
v
Widget instance
Inside a lambda, this, super, and unqualified field names all refer to the
enclosing instance. That is almost always what you want, and it removes the
need for the Outer.this qualifier.
The bug this prevents, and the one it introduces, are two sides of the same coin. With anonymous classes, a method named the same in both scopes silently resolved to the inner one:
public class Task {
public void run() { System.out.println("Task.run"); }
public void schedule() {
Runnable anon = new Runnable() {
@Override public void run() {
// this.run() would be infinite recursion on the ANON object
Task.this.run(); // qualification required
}
};
Runnable lam = () -> run(); // unambiguous: Task.run
}
}
Conversely, a lambda registered as a callback keeps a strong reference to the enclosing object whenever it touches an instance member. If the callback lives in a static registry, the enclosing object cannot be collected:
public class Screen {
private byte[] pixels = new byte[10_000_000];
public void register() {
// Captures `this`, so the whole Screen (and its 10 MB) stays alive
EventBus.subscribe(e -> System.out.println(pixels.length));
// Captures only the int, so Screen can be collected
int size = pixels.length;
EventBus.subscribe(e -> System.out.println(size));
}
}
The second scope consequence is shadowing. Because a lambda adds no scope, its parameters and locals collide with the enclosing method's:
public void process() {
String value = "outer";
// Consumer<String> c = value -> System.out.println(value);
// ERROR: variable value is already defined in method process()
Consumer<String> ok = v -> System.out.println(v + value); // different name
}
An anonymous class would have allowed the shadow, silently hiding the outer variable. The lambda's compile error is the better outcome, and the fix is to pick a distinct parameter name.
| Behaviour | Anonymous class | Lambda |
|---|---|---|
| Introduces a scope | Yes | No |
this | The anonymous instance | The enclosing instance |
Reaching the outer this | Outer.this | Plain this |
| Shadowing an outer local | Allowed | Compile error |
| Unqualified method call | Resolves to the inner one | Resolves to the enclosing one |
Key Takeaways
- A lambda body shares the enclosing method's scope and creates none of its own.
thisinside a lambda is the enclosing instance, not the lambda.- Anonymous classes need
Outer.thisto reach the enclosing object; lambdas do not.- A lambda parameter cannot shadow an enclosing local, and the compiler says so.
- Touching an instance member captures
thisand keeps the whole object reachable.- Capture a local copy instead when a long-lived lambda only needs one field.
🧪 Practice
this.getClass().getName()from inside an anonymousRunnableand from inside a lambda, and compare.- Try to name a lambda parameter the same as an enclosing local and read the error.
- Write a class whose lambda captures
this, then rewrite it to capture only the field value it needs.- Interview: How can a lambda passed to a long-lived registry cause a memory leak? (Hint: what does the lambda hold onto when its body mentions an instance field?)
<a id="82-built-in-functional-interfaces"></a>
8.2 Built-In Functional Interfaces
This subchapter covers the java.util.function package: the handful of generic
interfaces that almost every lambda in the standard library targets.
Function and BiFunction
If every library defined its own functional interface, the ecosystem would
fracture: your Transformer and my Converter would describe the same shape
but be incompatible types. The java.util.function package solves this with a
small set of general-purpose interfaces that everything targets, so a lambda
written for one API composes with another.
Function<T, R> is the most general of them: take one value, return another,
possibly of a different type. Its single abstract method is apply.
Function<String, Integer> length = s -> s.length(); // String in, Integer out
System.out.println(length.apply("hello")); // 5
Function<Integer, Integer> square = n -> n * n;
System.out.println(square.apply(4)); // 16
Function<T, R>
T ------> [ apply ] ------> R
input output
The type parameters read in order: the first is the input, the last is the result. That convention holds across the whole package.
Function carries three non-abstract methods, which is what makes it more than
a naming convention:
Function<String, Integer> parse = Integer::parseInt;
Function<Integer, Integer> doubler = n -> n * 2;
// andThen: run this first, then the argument
Function<String, Integer> parseThenDouble = parse.andThen(doubler);
System.out.println(parseThenDouble.apply("21")); // 42
// compose: run the argument first, then this. The order is REVERSED
Function<String, Integer> same = doubler.compose(parse);
System.out.println(same.apply("21")); // 42
// identity: returns its input unchanged
Function<String, String> id = Function.identity();
The distinction between andThen and compose is a reliable exam question:
f.andThen(g) is g(f(x)), while f.compose(g) is f(g(x)). Read andThen
left to right and compose right to left.
f.andThen(g): x --> [ f ] --> [ g ] --> result
f.compose(g): x --> [ g ] --> [ f ] --> result
Function.identity() looks useless until you meet Collectors.toMap, where
one of the two arguments is often "the element itself":
Map<String, String> byItself = Stream.of("a", "bb")
.collect(Collectors.toMap(Function.identity(), String::toUpperCase));
BiFunction<T, U, R> is the two-argument version. Java stops at two: there is
no TriFunction, and if you need three inputs you either define your own
interface or pass an object that groups them.
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
System.out.println(add.apply(3, 4)); // 7
// Different types are fine, including a different result type
BiFunction<String, Integer, String> repeat = (s, n) -> s.repeat(n);
System.out.println(repeat.apply("ab", 3)); // "ababab"
BiFunction has andThen but no compose, because composing into two
arguments is not well defined:
BiFunction<Integer, Integer, Integer> sum = (a, b) -> a + b;
BiFunction<Integer, Integer, String> describe =
sum.andThen(total -> "total=" + total); // takes the single result
System.out.println(describe.apply(2, 3)); // "total=5"
These interfaces turn up throughout the library, which is the real reason to learn them:
| Method | Functional parameter |
|---|---|
Stream.map | Function<T, R> |
Map.computeIfAbsent | Function<K, V> |
Map.merge, Map.compute | BiFunction |
Optional.map | Function<T, R> |
Collectors.toMap | Function for key and value |
Key Takeaways
java.util.functionsupplies shared interfaces so lambdas are portable between APIs.Function<T, R>takes one argument and returns a result viaapply.- The last type parameter is always the return type.
f.andThen(g)runsffirst;f.compose(g)runsgfirst.Function.identity()returns its input and is common inCollectors.toMap.BiFunction<T, U, R>takes two arguments and hasandThenbut nocompose.- There is no three-argument version; group the inputs into an object instead.
🧪 Practice
- Write a
Function<String, String>that trims and uppercases, usingandThen.- Build the same behaviour with
composeand confirm the argument order differs.- Use a
BiFunction<Double, Double, Double>to compute a discounted price, then chainandThento format it as a string.- Interview: Why does
BiFunctionhaveandThenbut notcompose? (Hint: how many values would the composed function have to produce?)
Predicate and BiPredicate
A Function<T, Boolean> would technically express a test, but it would box
every result into a Boolean object and read poorly. Predicate<T> is the
dedicated interface for "does this value satisfy a condition?", returning a
primitive boolean from its test method.
Predicate<String> isEmpty = s -> s.isEmpty();
Predicate<Integer> isEven = n -> n % 2 == 0;
System.out.println(isEmpty.test("")); // true
System.out.println(isEven.test(7)); // false
Its value comes from the combinators, which let you build complex conditions from simple named pieces instead of one unreadable boolean expression:
Predicate<String> notBlank = s -> !s.isBlank();
Predicate<String> shortEnough = s -> s.length() <= 20;
Predicate<String> noDigits = s -> s.chars().noneMatch(Character::isDigit);
// Combine with and / or / negate
Predicate<String> validName = notBlank.and(shortEnough).and(noDigits);
System.out.println(validName.test("Ada Lovelace")); // true
System.out.println(validName.test("Ada2")); // false
| Method | Meaning |
|---|---|
test(t) | The abstract method; returns boolean |
and(other) | Both must hold; short-circuits |
or(other) | Either may hold; short-circuits |
negate() | Logical NOT |
Predicate.not(p) | Static NOT, useful on method references |
Predicate.isEqual(o) | Tests with Objects.equals |
and and or short-circuit exactly like && and ||, so an expensive test
placed second may never run. Order your predicates cheapest-first.
Predicate.not, added in Java 11, exists because you cannot call .negate()
directly on a method reference:
// list.removeIf(String::isBlank.negate()); // does not compile
list.removeIf(Predicate.not(String::isBlank)); // works
BiPredicate<T, U> tests two values together:
BiPredicate<String, Integer> lengthIs = (s, n) -> s.length() == n;
System.out.println(lengthIs.test("hello", 5)); // true
BiPredicate<Order, Customer> canShip =
(order, customer) -> order.total() <= customer.creditLimit();
Predicates appear wherever the library filters or tests:
List<String> names = new ArrayList<>(List.of("Ada", "", "Grace", " "));
names.removeIf(String::isBlank); // Collection.removeIf
List<String> longNames = names.stream()
.filter(s -> s.length() > 3) // Stream.filter
.toList();
Optional<String> maybe = Optional.of("Ada").filter(s -> s.length() > 2);
One design note. Naming a predicate and reusing it beats inlining the same condition in five places, and it makes the intent testable on its own:
// Harder to read, impossible to unit test in isolation
orders.stream().filter(o -> o.total() > 100 && !o.cancelled() && o.paid());
// Named, reusable, testable
Predicate<Order> isBillable = Order::isPaid;
Predicate<Order> isLarge = o -> o.total() > 100;
orders.stream().filter(isBillable.and(isLarge));
Key Takeaways
Predicate<T>tests a value and returns a primitivebooleanfromtest.- It exists rather than
Function<T, Boolean>to avoid boxing and to read better.and,or, andnegatebuild compound conditions from named pieces.andandorshort-circuit, so put cheap tests first.Predicate.not(...)negates a method reference, which.negate()cannot.BiPredicate<T, U>tests two values at once.- Naming predicates makes filters readable and independently testable.
🧪 Practice
- Build a
Predicate<Integer>for "positive and even" by combining two simple predicates.- Filter a list of strings with
Predicate.not(String::isBlank).- Write a
BiPredicate<String, String>that tests whether one string is an anagram of another.- Interview: Why does
Predicateexist whenFunction<T, Boolean>would compile? (Hint: what happens to a primitivebooleanwhen it is used as a type argument?)
Consumer and Supplier
Function and Predicate both take input and produce output.
The remaining two shapes cover the cases where one side is missing: a
Consumer takes a value and returns nothing, and a Supplier takes nothing
and returns a value.
Consumer<T>: T ---> [ accept ] ---> (nothing)
Supplier<T>: (nothing) ---> [ get ] ---> T
A Consumer<T> exists for side effects: printing, logging, saving,
mutating. Because it returns nothing, a consumer that has no side effect does
nothing at all.
Consumer<String> print = s -> System.out.println(s);
print.accept("hello");
// andThen chains consumers: both run, in order, on the SAME input
Consumer<String> log = s -> logger.info(s);
Consumer<String> both = print.andThen(log);
both.accept("event"); // printed, then logged
Note that Consumer.andThen differs from Function.andThen: it does not feed
one result into the next, because there is no result. It simply runs both on
the original argument.
BiConsumer<T, U> takes two arguments, and is the shape used by
Map.forEach:
Map<String, Integer> ages = Map.of("Ada", 36, "Grace", 45);
BiConsumer<String, Integer> show = (name, age) ->
System.out.println(name + " is " + age);
ages.forEach(show);
A Supplier<T> produces a value on demand. Its purpose is deferral:
the value is not computed until, and unless, get() is called.
Supplier<String> lazy = () -> expensiveComputation();
// Nothing has run yet.
String value = lazy.get(); // now it runs
That deferral is the whole point, and it shows up in three recurring places:
// 1. Avoid building a message that is usually discarded
logger.debug(() -> "state: " + expensiveToString());
// 2. Provide a fallback only when it is actually needed
String name = optional.orElseGet(() -> lookupDefaultName()); // not orElse
// 3. Construct an exception lazily, including its stack trace
Order order = repo.find(id)
.orElseThrow(() -> new OrderNotFoundException(id));
The orElse versus orElseGet distinction returns in 8.5, and it is the
clearest illustration of why suppliers exist: orElse(expensive()) evaluates
its argument every time, even when the value is present.
Suppliers also power factories and lazy caches:
public class Lazy<T> {
private final Supplier<T> factory;
private T value; // computed at most once
public Lazy(Supplier<T> factory) { this.factory = factory; }
public T get() {
if (value == null) {
value = factory.get(); // deferred until first access
}
return value;
}
}
The four core shapes, side by side:
| Interface | Method | Takes | Returns | Typical use |
|---|---|---|---|---|
Function<T,R> | apply | 1 | Yes | Transform |
Predicate<T> | test | 1 | boolean | Filter |
Consumer<T> | accept | 1 | No | Side effect |
Supplier<T> | get | 0 | Yes | Deferred production |
Key Takeaways
Consumer<T>accepts a value and returns nothing, so it exists purely for side effects.Consumer.andThenruns both consumers on the same input rather than chaining results.BiConsumer<T, U>is the shape ofMap.forEach.Supplier<T>takes nothing and produces a value only whengetis called.- Suppliers exist for deferral: lazy logging, lazy defaults, lazy exceptions.
orElseGettakes a supplier and skips the work when the value is present;orElsedoes not.
🧪 Practice
- Chain two
Consumer<String>instances withandThenand confirm both run.- Write a method that logs only when a level is enabled, taking a
Supplier<String>instead of aString.- Implement a
Lazy<T>class and prove the factory runs exactly once.- Interview: When does passing a
Suppliermeasurably beat passing the value? (Hint: consider a default that requires a database call but is rarely needed.)
UnaryOperator and BinaryOperator
Some transformations return the same type they consume: uppercasing a string,
negating a number, merging two totals. Writing Function<String, String>
repeats the type and hides the intent. The operator interfaces are named
shorthands for exactly that case.
UnaryOperator<T> extends Function<T, T>, adding no new abstract method.
BinaryOperator<T> extends BiFunction<T, T, T> the same way.
// These two declarations are interchangeable in behaviour
Function<String, String> f = s -> s.toUpperCase();
UnaryOperator<String> u = s -> s.toUpperCase(); // clearer intent
BinaryOperator<Integer> add = (a, b) -> a + b; // Integer, Integer -> Integer
System.out.println(add.apply(2, 3)); // 5
UnaryOperator<T> T ---> [ apply ] ---> T (same type in and out)
BinaryOperator<T> T ---+
|--> [ apply ] ---> T
T ---+
Because they are subtypes, an UnaryOperator<String> can be passed anywhere a
Function<String, String> is expected. The reverse is not true, which is the
one practical gotcha:
Function<String, String> fn = s -> s.trim();
// UnaryOperator<String> op = fn; // ERROR: Function is the supertype
UnaryOperator<String> op = fn::apply; // wrap it if you really need to
UnaryOperator is the shape used by in-place transformation:
List<String> names = new ArrayList<>(List.of("ada", "grace"));
names.replaceAll(String::toUpperCase); // replaceAll takes a UnaryOperator
System.out.println(names); // [ADA, GRACE]
Stream.iterate(1, n -> n * 2) // the generator is a UnaryOperator
.limit(5)
.forEach(System.out::println); // 1 2 4 8 16
BinaryOperator is the shape of reduction and of map merging, which is where
you will meet it most:
// Reduction: fold a stream down to one value
int total = Stream.of(1, 2, 3).reduce(0, Integer::sum); // BinaryOperator
// Map merge conflicts: decide what to keep when two keys collide
Map<String, Integer> merged = Stream.of("a", "bb", "a")
.collect(Collectors.toMap(s -> s, String::length,
(existing, replacement) -> existing));
BinaryOperator also provides two static factories that build an operator out
of a comparator, which is a neat way to express "keep the larger of the two":
BinaryOperator<String> longest =
BinaryOperator.maxBy(Comparator.comparingInt(String::length));
System.out.println(longest.apply("hi", "hello")); // "hello"
BinaryOperator<Integer> smaller = BinaryOperator.minBy(Comparator.naturalOrder());
| Interface | Extends | Shape | Common use |
|---|---|---|---|
UnaryOperator<T> | Function<T, T> | T -> T | List.replaceAll, Stream.iterate |
BinaryOperator<T> | BiFunction<T,T,T> | (T, T) -> T | Stream.reduce, merge functions |
An important constraint for reduction: a BinaryOperator used with reduce
must be associative, meaning (a op b) op c equals a op (b op c).
Addition and max qualify; subtraction does not. Non-associative operators
give correct results sequentially and wrong ones in parallel, which is a
genuinely hard bug to find.
Key Takeaways
UnaryOperator<T>extendsFunction<T, T>;BinaryOperator<T>extendsBiFunction<T, T, T>.- They add no methods and exist to state intent and shorten declarations.
- An operator can be used where a function is expected, but not the reverse.
List.replaceAllandStream.iteratetakeUnaryOperator.Stream.reduceandCollectors.toMapmerge functions takeBinaryOperator.BinaryOperator.maxByandminBybuild operators from a comparator.- Reduction operators must be associative or parallel results will be wrong.
🧪 Practice
- Use
List.replaceAllwith aUnaryOperator<String>that strips whitespace.- Reduce a list of integers to their product with a
BinaryOperator.- Use
BinaryOperator.maxByto find the longest string in a list viareduce.- Interview: Why must a
reduceoperator be associative? (Hint: imagine splitting the input across four threads and combining the partial results.)
Primitive Specializations
Generics cannot hold primitives, so Function<Integer, Integer> really means
"object in, object out". Every call boxes an int into an Integer and
unboxes it again. For a loop over ten million elements that overhead dominates.
The primitive specializations exist to remove it.
The cost, made concrete:
// Boxed: each apply allocates an Integer for the argument and the result
Function<Integer, Integer> boxed = n -> n * 2;
// Primitive: no allocation at all
IntUnaryOperator unboxed = n -> n * 2;
BOXED PATH PRIMITIVE PATH
int 5
| box -> new Integer(5) int 5
v |
apply(Integer) -> Integer v
| applyAsInt(int) -> int
| unbox -> int |
v v
int 10 int 10
two allocations per call zero allocations
The naming follows two rules. A prefix names the input type, and a suffix
of ToX names the output type.
| Interface | Signature | Note |
|---|---|---|
IntPredicate | int -> boolean | Input is int |
IntFunction<R> | int -> R | Primitive in, object out |
ToIntFunction<T> | T -> int | Object in, primitive out |
IntToDoubleFunction | int -> double | Both primitive |
IntUnaryOperator | int -> int | |
IntBinaryOperator | (int, int) -> int | |
IntConsumer | int -> void | |
IntSupplier | () -> int |
The same family exists for Long and Double. Only those three primitives are
covered, plus BooleanSupplier, because int, long, and double can
represent the others without loss.
Their abstract methods are named after the return type, which trips people up when implementing them by hand:
IntPredicate isEven = n -> n % 2 == 0;
System.out.println(isEven.test(4)); // test
ToIntFunction<String> len = String::length;
System.out.println(len.applyAsInt("hello")); // applyAsInt, not apply
IntUnaryOperator twice = n -> n * 2;
System.out.println(twice.applyAsInt(21)); // applyAsInt
IntSupplier roll = () -> 6;
System.out.println(roll.getAsInt()); // getAsInt, not get
In practice you meet them through the primitive streams, where they are the natural parameter types:
int total = IntStream.rangeClosed(1, 100)
.filter(n -> n % 3 == 0) // IntPredicate
.map(n -> n * n) // IntUnaryOperator
.sum(); // no boxing anywhere in this pipeline
// mapToInt takes a ToIntFunction and leaves the object world behind
int chars = Stream.of("a", "bb", "ccc")
.mapToInt(String::length)
.sum();
A word on when this matters. For a collection of a few hundred elements, the difference is unmeasurable, and clarity should win. The specializations earn their awkward names in hot loops over large data, and in APIs where you want to guarantee no allocation. The compiler will not warn you either way, so the usual advice applies: write the clear version, measure, and specialise the part that shows up in the profile.
Key Takeaways
- Generic functional interfaces box primitives on every call, costing allocations.
- Primitive specializations take and return primitives directly.
- A prefix such as
Intnames the input; aToIntsuffix names the output.- Only
int,long, anddoublehave families, since they cover the rest.- Their methods are named for the return type:
applyAsInt,getAsInt,test.mapToIntand the primitive streams are where you meet them in practice.- Reach for them in hot loops over large data, not everywhere.
🧪 Practice
- Rewrite a
Function<Integer, Integer>that triples a number as anIntUnaryOperator.- Sum the lengths of a list of strings using
mapToIntand no boxing.- Time summing one million integers with
Stream<Integer>againstIntStream.- Interview: Why is there no
CharFunctionorShortPredicate? (Hint: which primitive can hold everycharandshortvalue without loss?)
Composing Functions and Predicates
The default methods introduced across the previous topics share one purpose: building complex behaviour from small named pieces at runtime. Gathering them in one place makes the pattern visible, and the pattern is what distinguishes functional code from lambdas sprinkled through a loop.
The complete set of combinators:
| Interface | Combinators |
|---|---|
Function | andThen, compose, identity |
Predicate | and, or, negate, not, isEqual |
Consumer | andThen |
Comparator | thenComparing, reversed, comparing |
BinaryOperator | minBy, maxBy |
Composition gives you a pipeline assembled from parts that are individually testable:
Function<String, String> trim = String::trim;
Function<String, String> lower = String::toLowerCase;
Function<String, String> collapse = s -> s.replaceAll("\\s+", " ");
// Build the whole normalisation from three named steps
Function<String, String> normalise = trim.andThen(lower).andThen(collapse);
System.out.println(normalise.apply(" Hello WORLD ")); // "hello world"
The real gain appears when the pipeline is assembled at runtime from data,
which a hand-written if chain cannot do:
// Build a filter from whatever criteria the user supplied
Predicate<Product> filter = p -> true; // start permissive
if (params.containsKey("minPrice")) {
double min = Double.parseDouble(params.get("minPrice"));
filter = filter.and(p -> p.price() >= min); // narrow it
}
if (params.containsKey("category")) {
String category = params.get("category");
filter = filter.and(p -> p.category().equals(category));
}
if (params.containsKey("inStock")) {
filter = filter.and(Product::inStock);
}
List<Product> results = products.stream().filter(filter).toList();
Note the p -> true starting point. It is the identity element for and,
the same role 0 plays for addition, and it lets the loop treat every criterion
uniformly. For an or chain the identity is p -> false.
Reducing a collection of predicates makes that explicit:
List<Predicate<String>> rules = List.of(
s -> s.length() >= 8,
s -> s.chars().anyMatch(Character::isDigit),
s -> s.chars().anyMatch(Character::isUpperCase));
// All rules must hold
Predicate<String> allRules = rules.stream()
.reduce(s -> true, Predicate::and);
System.out.println(allRules.test("Passw0rdX")); // true
Higher-order functions take the idea one step further: a method that returns a function, parameterised by ordinary values.
// A factory for predicates, not a predicate
public static Predicate<String> longerThan(int n) {
return s -> s.length() > n; // captures n
}
public static <T> Function<T, T> twice(UnaryOperator<T> op) {
return t -> op.apply(op.apply(t));
}
System.out.println(twice((String s) -> s + "!").apply("hi")); // "hi!!"
Two cautions. Composition chains are readable up to about four steps; beyond
that, name the intermediate results. And a composed function is only as
debuggable as its parts, since a stack trace through andThen shows lambda
frames with synthetic names. Naming each step as a local variable, rather than
inlining all of them, makes both problems manageable.
// Hard to read and to debug
Function<String, Integer> f = ((Function<String, String>) String::trim)
.andThen(String::toLowerCase).andThen(s -> s.replace(" ", ""))
.andThen(String::length);
// Same behaviour, each step nameable in a stack trace and testable alone
Function<String, String> clean = trim.andThen(lower).andThen(collapse);
Function<String, Integer> f2 = clean.andThen(String::length);
Key Takeaways
- Combinators build complex behaviour from small, independently testable pieces.
andThenandcomposechain functions;and,or, andnegatechain predicates.- Composition can be assembled at runtime from data, which an
ifchain cannot.p -> trueis the identity forand, andp -> falseforor.reduce(s -> true, Predicate::and)folds a collection of rules into one.- Higher-order methods return functions parameterised by ordinary arguments.
- Keep chains short and name the steps, since stack traces through lambdas are opaque.
🧪 Practice
- Compose three
UnaryOperator<String>steps into one normalisation function.- Write
Predicate<Integer> inRange(int lo, int hi)returning a predicate built from two smaller ones.- Build a search filter that combines an arbitrary list of predicates with
reduce.- Interview: Why is
p -> truethe right starting value when combining predicates withand? (Hint: what is the equivalent starting value when summing numbers?)
<a id="83-method-and-constructor-references"></a>
8.3 Method and Constructor References
This subchapter covers the :: shorthand: what each of its four forms means,
and when it reads better than the equivalent lambda.
Static Method References
Many lambdas do nothing but forward their arguments to an existing method:
Function<String, Integer> parse = s -> Integer.parseInt(s);
The parameter s is named, passed along, and never used for anything else. A
method reference removes that ceremony by naming the method directly:
Function<String, Integer> parse = Integer::parseInt;
The syntax is ClassName::staticMethodName, with no parentheses and no
arguments. You are naming the method, not calling it. The compiler matches the
method's signature against the functional interface's abstract method and wires
the arguments across positionally.
Function<String, Integer> abstract method: Integer apply(String)
Integer::parseInt actual method: static int parseInt(String)
String -----------> String (argument passes through)
Integer <---------- int (autoboxed result)
Common static references:
Function<String, Integer> toInt = Integer::parseInt;
BinaryOperator<Integer> max = Integer::max; // (a, b) -> Integer.max(a, b)
Function<Object, String> str = String::valueOf;
Predicate<String> blank = String::isBlank; // see the next topics
DoubleUnaryOperator sqrt = Math::sqrt;
// Your own static methods work identically
public static boolean isValid(String s) { return s != null && !s.isBlank(); }
Predicate<String> valid = MyUtils::isValid;
Arity must match, and this is the main source of "incompatible types" errors:
// Math.max takes two arguments, so it needs a two-argument interface
BinaryOperator<Integer> ok = Math::max;
// Function<Integer, Integer> bad = Math::max; // ERROR: wrong arity
An overloaded method is resolved by the target type, which is why the same reference text can mean different methods in different contexts:
// Math.abs is overloaded for int, long, float, double
IntUnaryOperator absInt = Math::abs; // picks abs(int)
DoubleUnaryOperator absDouble = Math::abs; // picks abs(double)
Where no unique overload matches, the compiler reports ambiguity and you fall back to an explicit lambda with typed parameters.
A genuinely useful case is converting a static utility into a reusable function object:
List<String> raw = List.of("1", "2", "3");
List<Integer> numbers = raw.stream()
.map(Integer::parseInt) // clearer than s -> Integer.parseInt(s)
.toList();
One limitation worth naming early: a method reference cannot do anything extra. The moment you need to adjust an argument, add a null check, or call two methods, you need a lambda.
// A reference cannot express this: the argument is transformed first
Function<String, Integer> f = s -> Integer.parseInt(s.trim());
Key Takeaways
- A method reference is shorthand for a lambda that only forwards its arguments.
- The static form is
ClassName::staticMethod, with no parentheses.- The compiler matches parameters positionally against the functional interface.
- Argument counts must match exactly, so
Math::maxneeds a two-argument interface.- Overloads are resolved from the target type.
- Any extra logic, even a
trim(), forces you back to a lambda.
🧪 Practice
- Replace
s -> Integer.parseInt(s)andd -> Math.sqrt(d)with method references.- Write a static
isPalindrome(String)and use it as aPredicatevia a reference.- Try assigning
Math::maxtoFunction<Integer, Integer>and read the error.- Interview: How does the compiler pick between overloads of
Math.absin a method reference? (Hint: what information does the target functional interface supply?)
Instance Method References on a Specific Object
The second form references a method on one particular object that already
exists. The syntax is objectReference::methodName, and the object is captured
when the reference is created, exactly as a lambda captures a variable.
String prefix = "Hello, ";
// Lambda: calls concat on the captured `prefix`
Function<String, String> greetLambda = s -> prefix.concat(s);
// Method reference on that specific object
Function<String, String> greet = prefix::concat;
System.out.println(greet.apply("Ada")); // "Hello, Ada"
The receiver is fixed; the functional interface's argument becomes the method's argument.
prefix::concat prefix is BOUND at creation
apply("Ada") ----> prefix.concat("Ada")
^ ^
| |
captured object the interface's argument
The most common example in all of Java is printing:
List<String> names = List.of("Ada", "Grace");
names.forEach(System.out::println);
// System.out is a specific PrintStream object;
// println is an instance method on it
That line is worth dissecting, because it is often typed without being
understood. System.out is a static field holding a PrintStream instance,
and ::println references an instance method on that instance. It is a bound
reference, not a static one.
Other everyday uses:
Logger logger = LoggerFactory.getLogger(MyClass.class);
Consumer<String> log = logger::info; // bound to this logger
List<String> collected = new ArrayList<>();
Consumer<String> collect = collected::add; // bound to this list
String target = "hello world";
Predicate<String> contained = target::contains; // target.contains(arg)
References to methods on this and super are the same form, used inside a
class:
public class OrderService {
private boolean isValid(Order o) { return o.total() > 0; }
public List<Order> filterValid(List<Order> orders) {
return orders.stream()
.filter(this::isValid) // instance method on this object
.toList();
}
}
One subtlety matters for correctness. The receiver is evaluated once, when the reference is created, not on each call. If the variable is later reassigned, the reference keeps pointing at the original object:
StringBuilder sb = new StringBuilder("first");
Supplier<String> get = sb::toString; // sb evaluated NOW
sb = new StringBuilder("second"); // rebinding the variable
System.out.println(get.get()); // "first": still the original object
Mutating the captured object is a different matter, and is visible:
StringBuilder sb2 = new StringBuilder("a");
Supplier<String> g = sb2::toString;
sb2.append("b");
System.out.println(g.get()); // "ab": same object, new contents
This form also explains a common NullPointerException. Creating the reference
dereferences the receiver immediately, so a null receiver throws at the
declaration, not at the first call:
String s = null;
// Supplier<Integer> len = s::length; // NPE right here, before any call
Key Takeaways
- The bound form is
object::instanceMethod, referencing a method on one existing object.- The interface's arguments become the method's arguments; the receiver is fixed.
System.out::printlnis this form, sinceSystem.outis a specific object.this::methodandsuper::methodare the same form inside a class.- The receiver is evaluated once, at creation, so later reassignment has no effect.
- Mutations to the captured object are still visible through the reference.
- A null receiver throws immediately when the reference is created.
🧪 Practice
- Replace
s -> System.out.println(s)with a bound method reference in aforEach.- Create a
Consumer<String>bound to a specific list'saddmethod and fill the list.- Demonstrate that reassigning the receiver variable does not change an existing reference.
- Interview: Why does
s::lengththrow whensis null, even before the function is called? (Hint: what must the runtime capture at the moment the reference is built?)
References to Arbitrary Instance Methods
The third form is the one that genuinely confuses people, because it looks
identical to a static reference but behaves differently. String::toUpperCase
names an instance method using a class name.
Function<String, String> upper = String::toUpperCase;
System.out.println(upper.apply("hello")); // "HELLO"
toUpperCase is not static, so which string does it run on? The answer: the
first argument becomes the receiver. The lambda it replaces is:
Function<String, String> upper = s -> s.toUpperCase();
BOUND (previous topic) UNBOUND (this topic)
prefix::concat String::toUpperCase
arg -> prefix.concat(arg) arg -> arg.toUpperCase()
^ ^
fixed receiver the ARGUMENT is the receiver
The shift generalises. With an unbound reference, argument one is the receiver and the remaining arguments are passed to the method:
// One interface argument, zero method arguments
Function<String, Integer> len = String::length; // s -> s.length()
// Two interface arguments: first is the receiver, second is the parameter
BiFunction<String, String, Boolean> startsWith = String::startsWith;
System.out.println(startsWith.apply("hello", "he")); // true
// equivalent to: (a, b) -> a.startsWith(b)
// A comparator built this way
Comparator<String> cmp = String::compareTo; // (a, b) -> a.compareTo(b)
Because both forms use ClassName::method, the only way to tell a static from
an unbound reference is to know whether the method is static. The compiler
knows; a reader has to check. It is also why a class with both a static and an
instance method of the same name cannot be referenced this way at all: the
reference is ambiguous and rejected.
This form is what makes stream pipelines read so cleanly, since the element is almost always the receiver:
List<String> names = List.of("ada", "grace", "alan");
List<String> result = names.stream()
.filter(s -> !s.isEmpty())
.map(String::toUpperCase) // each element is the receiver
.sorted(String::compareTo)
.toList();
// Comparator.comparing takes an unbound reference as its key extractor
people.sort(Comparator.comparing(Person::getName));
Person::getName deserves a note. It is an unbound reference producing a
Function<Person, String>, and that is exactly the key extractor
Comparator.comparing wants. Every getter reference you write in a comparator
or a collector is this form.
The three ClassName::method cases, disambiguated:
| Reference | Method kind | Equivalent lambda |
|---|---|---|
Integer::parseInt | static | s -> Integer.parseInt(s) |
String::toUpperCase | instance, unbound | s -> s.toUpperCase() |
prefix::concat | instance, bound | s -> prefix.concat(s) |
A final caution: an unbound reference invokes the method on the argument, so a null element throws inside the pipeline.
Stream.of("a", null, "c")
.map(String::toUpperCase); // NPE when it reaches the null element
Filter nulls out first, or use a lambda with a guard.
Key Takeaways
- The unbound form is
ClassName::instanceMethod, where the first argument becomes the receiver.String::toUpperCasemeanss -> s.toUpperCase(), not a static call.- Remaining arguments map to the method's parameters in order.
- Static and unbound references look identical, so you must know the method.
- A class with a static and an instance overload of one name cannot be referenced.
- Getter references such as
Person::getNameare unbound and power comparators and collectors.- A null element throws, because the reference dereferences the argument.
🧪 Practice
- Rewrite
s -> s.trim()ands -> s.length()as unbound method references.- Sort a list of objects with
Comparator.comparingand a getter reference.- Express
(a, b) -> a.equalsIgnoreCase(b)as an unbound reference and identify the interface type.- Interview: How does the compiler distinguish
Integer::parseIntfromString::length? (Hint: both areClassName::method, so what does it look up first?)
Constructor References
The fourth form references a constructor with ClassName::new. It follows the
same rule as the others: the functional interface's arguments become the
constructor's arguments, and the new object is the result.
Supplier<ArrayList<String>> maker = ArrayList::new; // () -> new ArrayList<>()
List<String> list = maker.get();
Function<String, StringBuilder> sb = StringBuilder::new; // s -> new StringBuilder(s)
System.out.println(sb.apply("hi").reverse()); // "ih"
The interface arity selects which constructor is used, which means the same
ClassName::new text can refer to different constructors:
public record Person(String name, int age) { }
Supplier<ArrayList<Person>> empty = ArrayList::new; // no-arg
IntFunction<ArrayList<Person>> sized = ArrayList::new; // (int capacity)
BiFunction<String, Integer, Person> make = Person::new; // (String, int)
Person p = make.apply("Ada", 36);
Person::new with target BiFunction<String, Integer, Person>
("Ada", 36) ---> new Person("Ada", 36) ---> Person
| |
+----+--- matched positionally to the constructor parameters
The most frequent use is as a factory handed to a library that needs to create objects without knowing the type:
List<String> raw = List.of("Ada", "Grace");
// Build objects from data
List<Person> people = raw.stream()
.map(name -> new Person(name, 0))
.toList();
// Supply the collection type to a collector
Set<String> set = raw.stream().collect(Collectors.toCollection(TreeSet::new));
// Supply the array type to toArray
String[] array = raw.stream().toArray(String[]::new);
String[]::new is the array constructor reference, and it is worth
learning separately. It is an IntFunction<String[]>: given a length, it
allocates an array of that size. That is precisely what toArray needs, since
it knows the count but not the element type.
IntFunction<String[]> arrayMaker = String[]::new;
String[] five = arrayMaker.apply(5); // new String[5]
Constructor references also give clean dependency wiring, where the caller decides which implementation to build:
public class Repository<T> {
private final Supplier<List<T>> listFactory;
// The caller chooses ArrayList::new, LinkedList::new, CopyOnWriteArrayList::new
public Repository(Supplier<List<T>> listFactory) {
this.listFactory = listFactory;
}
public List<T> newBatch() { return listFactory.get(); }
}
Repository<Order> repo = new Repository<>(ArrayList::new);
Two limits. You cannot reference the constructor of an abstract class or an interface, since there is nothing to instantiate. And for a generic class the type argument comes from the target type, though you may state it explicitly when inference needs help:
// Supplier<List<String>> s = ArrayList::new; // inferred
Supplier<List<String>> s = ArrayList<String>::new; // explicit, also legal
| Reference | Target interface | Creates |
|---|---|---|
ArrayList::new | Supplier<ArrayList<T>> | new ArrayList<>() |
ArrayList::new | IntFunction<ArrayList<T>> | new ArrayList<>(n) |
Person::new | BiFunction<String,Integer,Person> | new Person(s, i) |
String[]::new | IntFunction<String[]> | new String[n] |
Key Takeaways
ClassName::newreferences a constructor, with arguments matched positionally.- The target interface's arity decides which overloaded constructor is used.
TreeSet::newand similar are how collectors are told which collection to build.String[]::newis anIntFunctionthat allocates an array of the given length.- Passing a
Supplierof constructors lets callers choose the implementation.- Abstract classes and interfaces have no constructor reference.
🧪 Practice
- Use
Person::newwith aBiFunctionto build objects from two lists of values.- Collect a stream into a
TreeSetusingCollectors.toCollectionand a constructor reference.- Convert a stream to a typed array with
toArray(String[]::new)and explain whytoArray()alone is insufficient.- Interview: Why can the same text
ArrayList::newmean two different constructors? (Hint: what does the compiler examine besides the reference itself?)
When to Prefer a Reference Over a Lambda
Method references are shorter, but shorter is not automatically better. The right criterion is whether the reference makes the intent clearer, and there are cases where a lambda genuinely wins.
Prefer a reference when the lambda only forwards its arguments unchanged. The parameter name adds nothing:
.map(s -> s.toUpperCase()) -> .map(String::toUpperCase)
.filter(s -> s.isBlank()) -> .filter(String::isBlank)
.forEach(s -> System.out.println(s)) -> .forEach(System.out::println)
.map(p -> p.getName()) -> .map(Person::getName)
Prefer a lambda when any of the following is true.
The body does more than a single call:
.map(s -> s.trim().toUpperCase()) // two calls: no reference exists
.map(s -> Integer.parseInt(s.trim())) // the argument is transformed first
The argument order does not line up:
// Wanted: check whether a fixed target contains each element
Predicate<String> p = s -> "haystack".contains(s);
// "haystack"::contains works here, but the general reordering case does not:
BiFunction<String, String, Boolean> flipped = (a, b) -> b.startsWith(a);
// no reference can express the swap
A null guard or any conditional is involved:
.map(s -> s == null ? "" : s.trim())
The parameter name carries meaning a reader needs:
// The reference is shorter, but the lambda documents the domain
.sorted((older, newer) -> older.date().compareTo(newer.date()))
The type is ambiguous or inference struggles. An explicit lambda lets you write the types:
// Sometimes needed when the compiler cannot infer
.map((String s) -> s.length())
Summarised:
| Situation | Use |
|---|---|
| Pure forwarding of arguments | Reference |
| Getter in a comparator/collector | Reference |
| Printing or logging an element | Reference |
| Multiple chained calls | Lambda |
| Argument transformed first | Lambda |
| Conditional or null handling | Lambda |
| Reordered or dropped arguments | Lambda |
| Parameter name aids readability | Lambda |
Two practical notes to finish. First, references genuinely help when a getter
is used as a key, because Person::getName states the intent better than
p -> p.getName(). Second, be careful when converting a lambda to a reference
on a type you do not control: String::valueOf and Object::toString behave
differently on null, and swapping one for the other has changed behaviour in
real code.
Stream.of("a", null).map(String::valueOf); // "a", "null": no exception
Stream.of("a", null).map(Object::toString); // NPE on the null element
Finally, both forms compile to the same invokedynamic machinery, so the
choice is about readability, not speed. Modern IDEs will offer the conversion
in both directions; accept it when it removes a redundant parameter and decline
it when it hides a meaningful name.
Key Takeaways
- Use a reference when the lambda only forwards its arguments unchanged.
- Use a lambda when there are multiple calls, transformed arguments, or a conditional.
- A meaningful parameter name can be worth more than the shorter form.
- Reordered or dropped arguments cannot be expressed as a reference.
String::valueOfandObject::toStringdiffer on null, so convert carefully.- Both compile to the same mechanism, so the decision is purely about clarity.
🧪 Practice
- Take five lambdas from existing code and convert only those that are pure forwarding.
- Find a lambda that cannot become a reference and explain which rule blocks it.
- Compare
map(String::valueOf)andmap(Object::toString)on a stream containing a null.- Interview: Is a method reference faster than the equivalent lambda? (Hint: what bytecode instruction does each one compile to?)
<a id="84-the-stream-api"></a>
8.4 The Stream API
This subchapter covers streams: how to create them, the operations that transform them, how laziness makes long pipelines cheap, and where parallelism helps or hurts.
Stream Creation Sources
A Stream<T> is a sequence of elements supporting a pipeline of operations. It
is not a data structure. It stores nothing, and it is consumed once. Thinking
of it as "a collection with nicer methods" leads directly to the mistakes at
the end of this topic.
The better mental model is a conveyor belt. Elements travel along it, stations transform or discard them, and at the end something collects what survives. The belt itself holds nothing once the run is over.
source intermediate ops terminal op
------ ---------------- -----------
[a, b, c] --> filter --> map --> sorted --> collect --> result
Every pipeline starts with a source. The most common is a collection:
List<String> names = List.of("Ada", "Grace", "Alan");
Stream<String> s1 = names.stream(); // any Collection
Stream<String> s2 = names.parallelStream(); // the parallel variant
From explicit values or an array:
Stream<String> s3 = Stream.of("a", "b", "c");
Stream<String> s4 = Arrays.stream(new String[]{"a", "b"});
Stream<String> s5 = Arrays.stream(array, 1, 3); // a sub-range
IntStream s6 = Arrays.stream(new int[]{1, 2, 3}); // primitive array
Ranges of numbers, which replace most counting for loops:
IntStream.range(0, 5); // 0, 1, 2, 3, 4 (upper bound excluded)
IntStream.rangeClosed(1, 5); // 1, 2, 3, 4, 5 (upper bound included)
Infinite streams, which must be bounded by limit or a predicate:
// iterate: apply a function repeatedly to the previous value
Stream.iterate(1, n -> n * 2).limit(5).forEach(System.out::println); // 1 2 4 8 16
// The three-argument form has a built-in stop condition (Java 9+)
Stream.iterate(1, n -> n < 100, n -> n * 2).forEach(System.out::println);
// generate: call a supplier over and over, with no relation between elements
Stream.generate(Math::random).limit(3).forEach(System.out::println);
Stream.generate(() -> "x").limit(3).toList(); // [x, x, x]
Forgetting limit on an infinite stream is a hang, not an exception. The
program simply never reaches the terminal operation.
Other useful sources:
// A file, line by line. It holds an OS handle, so close it
try (Stream<String> lines = Files.lines(Path.of("data.txt"))) {
lines.filter(l -> !l.isBlank()).forEach(System.out::println);
}
Stream<String> words = Pattern.compile(",").splitAsStream("a,b,c");
IntStream chars = "hello".chars(); // an IntStream of code points
Stream<String> empty = Stream.empty();
Stream<String> maybe = Optional.of("a").stream(); // 0 or 1 elements
Stream<String> nullable = Stream.ofNullable(maybeNull); // 0 or 1 elements
// Map entries
Map<String, Integer> ages = Map.of("Ada", 36);
ages.entrySet().stream().map(Map.Entry::getKey);
| Source | Produces |
|---|---|
collection.stream() | Sequential stream of elements |
Stream.of(...) | Stream of explicit values |
Arrays.stream(arr) | Stream over an array |
IntStream.range(a, b) | Primitive int range |
Stream.iterate(seed, f) | Infinite, each from the last |
Stream.generate(supplier) | Infinite, independent elements |
Files.lines(path) | Lines of a file; must be closed |
"abc".chars() | IntStream of characters |
Now the two rules that the conveyor-belt model explains.
A stream may be consumed once. After a terminal operation the stream is closed, and reusing it throws:
Stream<String> s = names.stream();
s.forEach(System.out::println);
// s.count(); // IllegalStateException: stream has already been operated
// upon or closed
The fix is to create a new stream from the source each time, or to keep a
Supplier<Stream<T>>:
Supplier<Stream<String>> factory = names::stream;
factory.get().forEach(System.out::println);
System.out.println(factory.get().count()); // a fresh stream
A stream does not modify its source. Operations produce new streams and a final result; the original collection is untouched.
List<String> original = new ArrayList<>(List.of("b", "a"));
List<String> sorted = original.stream().sorted().toList();
System.out.println(original); // [b, a]: unchanged
System.out.println(sorted); // [a, b]
Key Takeaways
- A stream is a pipeline over elements, not a data structure, and stores nothing.
- Every pipeline is a source, zero or more intermediate operations, and one terminal operation.
- Sources include collections, arrays, explicit values, ranges, files, and generators.
Stream.iterateandStream.generateare infinite and needlimitor a stop predicate.- A stream can be consumed only once; reuse throws
IllegalStateException.- Keep a
Supplier<Stream<T>>when you need the same pipeline twice.- Streams never modify their source collection.
- Close file-backed streams with try-with-resources.
🧪 Practice
- Create streams from a list, an array, and
Stream.of, and print each one's count.- Generate the first ten powers of three with
Stream.iterateandlimit.- Trigger
IllegalStateExceptionby consuming a stream twice, then fix it with a supplier.- Interview: Why can a stream not be reused? (Hint: what state would a pipeline need to keep in order to restart, and where would that state live?)
Intermediate Operations: filter, map, sorted
Intermediate operations transform one stream into another. They are the
stations on the conveyor belt. Each returns a new Stream, which is what makes
chaining possible, and none of them does any work until a terminal operation
runs.
filter(Predicate<T>) keeps elements matching a condition and discards the
rest. The stream gets shorter; the element type is unchanged.
List<Integer> evens = Stream.of(1, 2, 3, 4, 5, 6)
.filter(n -> n % 2 == 0)
.toList(); // [2, 4, 6]
map(Function<T, R>) transforms each element. The length is unchanged; the
type may change.
List<Integer> lengths = Stream.of("ada", "grace")
.map(String::length) // Stream<String> -> Stream<Integer>
.toList(); // [3, 5]
filter: same type, fewer elements map: same count, possibly new type
[1, 2, 3, 4] ["ada", "grace"]
| filter(even) | map(length)
v v
[2, 4] [3, 5]
sorted() orders elements by natural ordering, or by a comparator. It is a
stateful operation: it must buffer the entire stream before it can emit the
first element.
List<String> names = Stream.of("grace", "ada", "alan")
.sorted() // natural ordering
.toList(); // [ada, alan, grace]
List<Person> byAge = people.stream()
.sorted(Comparator.comparingInt(Person::age).reversed())
.toList();
The other intermediate operations, all used the same way:
| Operation | Effect |
|---|---|
filter(p) | Keep matching elements |
map(f) | Transform each element |
distinct() | Remove duplicates, using equals/hashCode |
sorted(), sorted(c) | Order the elements; buffers everything |
limit(n) | Keep at most the first n |
skip(n) | Discard the first n |
peek(c) | Run an action on each element and pass it along |
takeWhile(p) | Keep elements until the first failure (Java 9) |
dropWhile(p) | Discard elements until the first failure |
mapMulti(...) | Emit zero or more elements per input (Java 16) |
takeWhile and dropWhile differ from filter in an important way: they stop
at the first element that fails, rather than testing every element.
List<Integer> nums = List.of(1, 2, 3, 10, 4, 5);
nums.stream().takeWhile(n -> n < 5).toList(); // [1, 2, 3]: stops at 10
nums.stream().dropWhile(n -> n < 5).toList(); // [10, 4, 5]
nums.stream().filter(n -> n < 5).toList(); // [1, 2, 3, 4, 5]: tests all
peek is intended for debugging. It lets you observe elements mid-pipeline
without changing them:
List<String> out = Stream.of("a", "b")
.peek(s -> System.out.println("before: " + s))
.map(String::toUpperCase)
.peek(s -> System.out.println("after: " + s))
.toList();
Do not use peek for side effects in production code. Because the pipeline is
lazy, a peek before an operation that never runs will not execute, and some
optimisations can skip it entirely.
Order matters for performance. Filtering before mapping means fewer elements get transformed:
// Maps all 1000, then discards most: wasteful
list.stream().map(this::expensiveTransform).filter(x -> x.isValid());
// Discards first, transforms only survivors
list.stream().filter(this::isValid).map(this::expensiveTransform);
The same reasoning applies to sorted: filter first so there is less to sort,
and place limit after sorted only when you genuinely need the top N, since
sorted cannot short-circuit.
Key Takeaways
- Intermediate operations return a new stream and run nothing until a terminal operation.
filtershortens the stream;maptransforms elements and can change the type.sortedis stateful and buffers every element before emitting any.distinctrelies onequalsandhashCode.takeWhileanddropWhilestop at the first failure;filtertests everything.peekis a debugging tool and unreliable for side effects because of laziness.- Put
filterbeforemapand beforesortedto reduce the work done.
🧪 Practice
- From a list of words, keep those longer than four characters, uppercase them, and sort them.
- Show a case where
takeWhileandfiltergive different results on the same input.- Use
skipandlimittogether to extract page three of ten-item pages.- Interview: Why is
sortedcalled a stateful operation? (Hint: how many elements must it see before it can emit the first one?)
flatMap and Nested Structures
map transforms one element into one element. When each element expands into
several, map gives you a stream of streams, which is almost never what you
want. flatMap is the operation that transforms and then flattens in one step.
The problem, concretely:
List<List<String>> nested = List.of(
List.of("a", "b"),
List.of("c", "d"));
// map gives a Stream<Stream<String>>: awkward and hard to consume
Stream<Stream<String>> wrong = nested.stream().map(List::stream);
// flatMap concatenates the inner streams into one flat stream
List<String> flat = nested.stream()
.flatMap(List::stream)
.toList(); // [a, b, c, d]
map(List::stream) flatMap(List::stream)
[ [a,b], [c,d] ] [ [a,b], [c,d] ]
| |
v v
[ <a,b>, <c,d> ] [ a, b, c, d ]
a stream of streams one flat stream
The mental model: the function you pass returns a stream for each element,
and flatMap splices all those streams together end to end. An element that
maps to an empty stream simply disappears, which makes flatMap a filter as
well as an expander.
The classic case is splitting text:
List<String> sentences = List.of("hello world", "good morning");
List<String> words = sentences.stream()
.flatMap(s -> Arrays.stream(s.split(" "))) // one sentence -> many words
.toList(); // [hello, world, good, morning]
// Unique words across all sentences
Set<String> unique = sentences.stream()
.flatMap(s -> Arrays.stream(s.split(" ")))
.collect(Collectors.toSet());
Navigating object graphs is the other everyday use. When each object owns a collection and you want all the inner elements:
record Order(String id, List<Item> items) { }
record Item(String name, double price) { }
List<Order> orders = loadOrders();
// Every item across every order
List<Item> allItems = orders.stream()
.flatMap(o -> o.items().stream())
.toList();
double revenue = orders.stream()
.flatMap(o -> o.items().stream())
.mapToDouble(Item::price)
.sum();
flatMap also removes empty Optional values cleanly, which is the idiomatic
way to turn a stream of optionals into a stream of present values:
List<Optional<String>> maybes = List.of(
Optional.of("a"), Optional.empty(), Optional.of("c"));
List<String> present = maybes.stream()
.flatMap(Optional::stream) // empty contributes nothing
.toList(); // [a, c]
Flattening more than one level requires more than one flatMap:
List<List<List<String>>> deep = ...;
List<String> flattened = deep.stream()
.flatMap(List::stream) // depth 3 -> depth 2
.flatMap(List::stream) // depth 2 -> depth 1
.toList();
Two related tools. mapMulti, added in Java 16, expresses the same idea by
pushing elements into a consumer instead of building intermediate streams,
which avoids allocation when most elements expand to zero or one result. And
the primitive variants flatMapToInt, flatMapToLong, and flatMapToDouble
flatten into a primitive stream directly.
// Cartesian product: pair every letter with every number
List<String> pairs = Stream.of("a", "b")
.flatMap(letter -> Stream.of(1, 2)
.map(number -> letter + number))
.toList(); // [a1, a2, b1, b2]
Key Takeaways
mapproduces one output per input;flatMapproduces zero or more.- The mapping function must return a
Stream, andflatMapconcatenates them.- Mapping to an empty stream removes the element, so
flatMapcan also filter.- It is the standard way to split text and to walk nested object graphs.
flatMap(Optional::stream)drops empty optionals in one step.- Each
flatMapremoves exactly one level of nesting.mapMultiavoids allocating intermediate streams when expansion is small.
🧪 Practice
- Flatten a
List<List<Integer>>into a single sorted list of distinct values.- Count the distinct words across a list of sentences.
- From a list of orders each holding items, find the most expensive item overall.
- Interview: What is the difference between
mapandflatMapin terms of element counts? (Hint: compare the output size of each when every input maps to a three-element list.)
Terminal Operations and Reduction
A terminal operation ends the pipeline. It triggers execution, produces a result that is not a stream, and closes the stream. Until one runs, nothing happens at all.
They fall into four groups.
Collecting into a container:
List<String> list = stream.toList(); // immutable, Java 16+
List<String> mutable = stream.collect(Collectors.toList()); // usually ArrayList
Set<String> set = stream.collect(Collectors.toSet());
Map<String, Integer> map = stream.collect(
Collectors.toMap(Function.identity(), String::length));
String[] array = stream.toArray(String[]::new);
Searching and matching, all of which short-circuit:
Optional<String> first = stream.findFirst(); // first in encounter order
Optional<String> any = stream.findAny(); // any element, cheaper in parallel
boolean allOk = stream.allMatch(s -> !s.isBlank());
boolean someOk = stream.anyMatch(s -> s.startsWith("A"));
boolean noneOk = stream.noneMatch(String::isBlank);
A logical quirk worth knowing: on an empty stream allMatch returns true and
anyMatch returns false. That is "vacuous truth", and it is the standard
mathematical convention, not a bug.
Aggregating:
long n = stream.count();
Optional<String> longest = stream.max(Comparator.comparingInt(String::length));
Optional<String> shortest = stream.min(Comparator.comparingInt(String::length));
Iterating:
stream.forEach(System.out::println); // no order guarantee in parallel
stream.forEachOrdered(System.out::println); // encounter order, even in parallel
The most general terminal operation is reduce, which folds the stream
down to a single value by repeatedly combining elements. It has three forms.
// 1. Identity plus accumulator: always returns a value
int sum = Stream.of(1, 2, 3, 4).reduce(0, Integer::sum); // 10
// 2. Accumulator only: returns Optional, since an empty stream has no result
Optional<Integer> product = Stream.of(1, 2, 3).reduce((a, b) -> a * b); // 6
// 3. Identity, accumulator, and combiner: for parallel with a different result type
int totalLength = Stream.of("ab", "cde")
.reduce(0, // identity
(acc, s) -> acc + s.length(), // accumulate into the result type
Integer::sum); // combine partial results
reduce(0, Integer::sum) over [1, 2, 3, 4]
0 + 1 = 1
1 + 2 = 3
3 + 3 = 6
6 + 4 = 10
Three rules govern a correct reduction, and breaking them produces wrong answers only under parallelism, which makes them hard to catch:
- The identity must satisfy
f(identity, x) == x. Zero for addition, one for multiplication, the empty string for concatenation. - The accumulator must be associative, so grouping does not matter.
- The accumulator must be stateless and must not mutate anything outside itself.
The classic misuse is string concatenation, which is associative but allocates a new string at every step:
// O(n^2): each concatenation copies the accumulated string
String bad = words.stream().reduce("", (a, b) -> a + b);
// O(n): the collector uses a StringBuilder internally
String good = words.stream().collect(Collectors.joining());
The distinction between reduce and collect is worth stating plainly.
reduce combines immutable values into a new value at each step. collect
performs a mutable reduction, accumulating into a container. Use reduce
for numbers and other immutable results, and collect whenever the result is a
collection or a string.
| Operation | Returns | Short-circuits |
|---|---|---|
toList | List<T> | No |
collect | Whatever the collector builds | No |
reduce | T or Optional<T> | No |
count | long | No |
min / max | Optional<T> | No |
findFirst / findAny | Optional<T> | Yes |
anyMatch / allMatch / noneMatch | boolean | Yes |
forEach | void | No |
Key Takeaways
- A terminal operation produces a non-stream result and triggers the whole pipeline.
toListreturns an immutable list;Collectors.toListusually returns a mutable one.findFirst,findAny, and the match operations short-circuit.- On an empty stream
allMatchistrueandanyMatchisfalse.reducefolds elements into one value using an identity and an associative accumulator.- The two-argument
reducereturnsOptionalbecause an empty stream has no result.- Use
reducefor immutable values andcollectfor containers and strings.- Reducing with string concatenation is quadratic; use
Collectors.joining.
🧪 Practice
- Compute the sum, maximum, and count of a list of integers using three terminal operations.
- Concatenate strings with
reduceand withCollectors.joining, and time both on 100,000 elements.- Use
reducewith an identity to find the longest string in a list.- Interview: Why does the one-argument
reducereturn anOptionalwhile the two-argument form does not? (Hint: what should each return for an empty stream?)
Lazy Evaluation and Short-Circuiting
The single most important thing to understand about streams is that intermediate operations do nothing when you call them. They build a plan. The plan executes only when a terminal operation arrives, and then all the operations run together, element by element.
This code prints nothing:
Stream.of("a", "b", "c")
.filter(s -> { System.out.println("filter " + s); return true; })
.map(s -> { System.out.println("map " + s); return s; });
// No terminal operation, so no output at all
Add .toList() and the output reveals the execution model:
filter a
map a <- element "a" goes through the WHOLE pipeline
filter b
map b <- then element "b"
filter c
map c
Elements are processed vertically, one at a time down the whole chain, not horizontally with each stage completing before the next begins. This is called fusion, and it has two consequences that matter in practice.
The first is that a long chain does not mean many passes over the data. Five intermediate operations still make one pass, with no intermediate collections allocated.
The second is short-circuiting: operations that can finish early stop the whole pipeline, so elements past that point are never even looked at.
Optional<String> found = Stream.of("apple", "banana", "cherry", "date")
.filter(s -> { System.out.println("checking " + s); return s.length() > 5; })
.findFirst();
// Output:
// checking apple
// checking banana <- matches, so the pipeline STOPS here
// "cherry" and "date" are never examined
The short-circuiting operations:
| Operation | Stops as soon as |
|---|---|
findFirst(), findAny() | One element is produced |
anyMatch(p) | One element matches |
allMatch(p) | One element fails |
noneMatch(p) | One element matches |
limit(n) | n elements have passed |
takeWhile(p) | One element fails the predicate |
Short-circuiting is what makes infinite streams usable at all:
// Terminates: limit stops the source after 5 elements
List<Integer> powers = Stream.iterate(1, n -> n * 2).limit(5).toList();
// Terminates: findFirst stops at the first match
Optional<Integer> firstBig = Stream.iterate(1, n -> n * 2)
.filter(n -> n > 1000)
.findFirst(); // 1024
// HANGS: count() must consume everything
// long forever = Stream.iterate(1, n -> n + 1).count();
Laziness also explains why operation order changes the amount of work done, even though the result is identical:
// limit AFTER filter: filters until 2 matches are found, then stops
Stream.of(1, 2, 3, 4, 5, 6).filter(n -> n % 2 == 0).limit(2).toList(); // [2, 4]
// limit BEFORE filter: takes 2 elements, then filters those
Stream.of(1, 2, 3, 4, 5, 6).limit(2).filter(n -> n % 2 == 0).toList(); // [2]
Two operations break the flow because they cannot emit anything until they have
seen everything. sorted and distinct are stateful barriers:
// sorted must buffer all elements, so the elements after it wait
stream.filter(...).sorted().map(...).toList();
// ^ everything upstream completes before map sees anything
Placing an expensive sorted before a limit is therefore costly, because the
sort cannot short-circuit. If you only need the top three, a bounded
PriorityQueue or Collectors with a limit does less work.
A final practical consequence: because peek runs only when elements actually
flow, a peek in a pipeline that short-circuits will print less than you
expect, and a peek in a pipeline with no terminal operation prints nothing.
That is the main reason peek is unreliable for anything but debugging.
Key Takeaways
- Intermediate operations build a plan; nothing runs until a terminal operation.
- Elements flow one at a time through the entire chain, so a long pipeline is still one pass.
- No intermediate collections are allocated between stages.
- Short-circuiting operations stop the pipeline early and skip remaining elements.
- Infinite streams are only usable because of
limit,findFirst, and the match operations.sortedanddistinctare stateful barriers that must consume everything first.- Operation order changes how much work is performed.
🧪 Practice
- Add
printlnto afilterand amap, run with and without a terminal operation, and compare.- Show that
findFirststops the pipeline by printing every element the filter examines.- Compare the element counts examined by
filter().limit(2)versuslimit(2).filter().- Interview: Why does
Stream.iterate(1, n -> n + 1).filter(n -> n > 10) .findFirst()terminate while.count()does not? (Hint: which of the two can stop before the source is exhausted?)
Collectors and Grouping
Collectors is the class that turns a stream back into a data structure. It
holds far more than toList, and the grouping collectors in particular replace
loops that would otherwise take ten lines and a computeIfAbsent.
The basic collectors:
List<String> list = stream.collect(Collectors.toList());
Set<String> set = stream.collect(Collectors.toSet());
TreeSet<String> tree = stream.collect(Collectors.toCollection(TreeSet::new));
String joined = Stream.of("a", "b", "c").collect(Collectors.joining()); // "abc"
String csv = Stream.of("a", "b").collect(Collectors.joining(", ")); // "a, b"
String wrapped = Stream.of("a", "b")
.collect(Collectors.joining(", ", "[", "]")); // "[a, b]"
Collectors.toMap needs a key function and a value function, and it throws on
duplicate keys unless you supply a merge function:
Map<String, Integer> lengths = Stream.of("ada", "grace")
.collect(Collectors.toMap(Function.identity(), String::length));
// Duplicate keys throw IllegalStateException without a third argument
Map<Integer, String> byLength = Stream.of("ab", "cd", "efg")
.collect(Collectors.toMap(String::length, Function.identity(),
(first, second) -> first)); // keep the first
groupingBy is the most valuable collector. It partitions elements into a
Map keyed by whatever the classifier returns.
record Employee(String name, String dept, double salary) { }
Map<String, List<Employee>> byDept = employees.stream()
.collect(Collectors.groupingBy(Employee::dept));
[ Ada/Eng, Bob/Sales, Cy/Eng ]
|
| groupingBy(Employee::dept)
v
{ "Eng" -> [Ada, Cy],
"Sales" -> [Bob] }
The second argument is a downstream collector, applied to each group. This
is what makes groupingBy compose into real aggregations:
// Count per department
Map<String, Long> countByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::dept, Collectors.counting()));
// Average salary per department
Map<String, Double> avgByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::dept,
Collectors.averagingDouble(Employee::salary)));
// Just the names, not whole objects
Map<String, List<String>> namesByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::dept,
Collectors.mapping(Employee::name, Collectors.toList())));
// Highest earner per department
Map<String, Optional<Employee>> topByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::dept,
Collectors.maxBy(Comparator.comparingDouble(Employee::salary))));
The third argument chooses the map implementation, which matters when you need sorted keys:
TreeMap<String, List<Employee>> sorted = employees.stream()
.collect(Collectors.groupingBy(Employee::dept, TreeMap::new,
Collectors.toList()));
Grouping nests, which handles multi-level reports in one expression:
Map<String, Map<Boolean, List<Employee>>> byDeptThenSenior = employees.stream()
.collect(Collectors.groupingBy(Employee::dept,
Collectors.groupingBy(e -> e.salary() > 100_000)));
partitioningBy is the special case of grouping by a predicate. It always
returns a map with exactly the keys true and false, even when one side is
empty, which groupingBy would omit.
Map<Boolean, List<Integer>> split = Stream.of(1, 2, 3, 4)
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
// {false=[1, 3], true=[2, 4]}
The statistical and utility collectors:
| Collector | Produces |
|---|---|
counting() | Long count |
summingInt/Long/Double(f) | Total |
averagingInt/Long/Double(f) | Double average |
summarizingInt(f) | Count, sum, min, max, average |
minBy(c) / maxBy(c) | Optional<T> |
mapping(f, downstream) | Transform before collecting |
filtering(p, downstream) | Filter within a group (Java 9) |
flatMapping(f, downstream) | Flatten within a group (Java 9) |
collectingAndThen(c, finisher) | Post-process the result |
teeing(c1, c2, merger) | Two collectors at once (Java 12) |
collectingAndThen is how you make a collected result immutable:
List<String> immutable = stream.collect(
Collectors.collectingAndThen(Collectors.toList(), List::copyOf));
A note on toList() versus Collectors.toList(). The stream method added in
Java 16 returns an unmodifiable list and is the better default. The
collector's contract does not guarantee mutability or immutability, though it
returns an ArrayList in practice. Use Collectors.toCollection(ArrayList::new)
when you specifically need a mutable result.
Key Takeaways
Collectorsconverts a stream into lists, sets, maps, and strings.Collectors.toMapthrows on duplicate keys unless given a merge function.groupingBybuilds aMapfrom a classifier function.- A downstream collector aggregates within each group, and groupings nest.
- A third argument selects the map implementation, such as
TreeMap::new.partitioningByalways yields bothtrueandfalsekeys.collectingAndThenpost-processes the result, commonly to make it immutable.stream.toList()returns an unmodifiable list and is the modern default.
🧪 Practice
- Group a list of words by their first letter.
- Compute the average salary per department with
groupingByandaveragingDouble.- Partition numbers into primes and non-primes with
partitioningBy.- Build a
Map<String, List<String>>of employee names by department usingmapping.- Interview: What is the difference between
groupingBy(p)andpartitioningBy(p)when no element matches? (Hint: how many keys does each resulting map contain?)
Primitive Streams and Statistics
Stream<Integer> boxes every element. For numeric work that is both slower and
clumsier, since Stream has no sum method. The three primitive streams,
IntStream, LongStream, and DoubleStream, fix both problems.
// Boxed: an Integer object per element, and no sum() available
int sum1 = Stream.of(1, 2, 3).mapToInt(Integer::intValue).sum();
// Primitive throughout: no allocation
int sum2 = IntStream.of(1, 2, 3).sum();
Creating them:
IntStream.of(1, 2, 3);
IntStream.range(0, 5); // 0..4
IntStream.rangeClosed(1, 5); // 1..5
Arrays.stream(new int[]{1, 2, 3});
"hello".chars(); // IntStream of char values
new Random().ints(5, 1, 7); // 5 random ints in [1, 7)
Converting between the object and primitive worlds is explicit in both directions:
// Object stream -> primitive stream
IntStream lengths = Stream.of("ab", "cde").mapToInt(String::length);
DoubleStream prices = orders.stream().mapToDouble(Order::total);
// Primitive stream -> object stream
Stream<Integer> boxed = IntStream.range(0, 3).boxed();
Stream<String> names = IntStream.range(0, 3).mapToObj(i -> "item" + i);
// Between primitive streams
LongStream longs = IntStream.of(1, 2).asLongStream();
DoubleStream doubles = IntStream.of(1, 2).asDoubleStream();
boxed() is needed whenever you want to collect into a collection, since
Collectors works on object streams:
List<Integer> list = IntStream.rangeClosed(1, 5).boxed().toList();
The extra numeric operations are the main attraction:
IntStream nums = IntStream.rangeClosed(1, 10);
int sum = IntStream.rangeClosed(1, 10).sum(); // 55
OptionalInt max = IntStream.rangeClosed(1, 10).max(); // 10
OptionalDouble avg = IntStream.rangeClosed(1, 10).average(); // 5.5
long count = IntStream.rangeClosed(1, 10).count(); // 10
Note the return types. max returns OptionalInt, not Optional<Integer>,
and average returns OptionalDouble regardless of the stream type. These are
primitive-specialised optionals with getAsInt and getAsDouble accessors.
summaryStatistics() computes count, sum, min, max, and average in a
single pass, which beats running five separate pipelines:
IntSummaryStatistics stats = IntStream.of(3, 1, 4, 1, 5).summaryStatistics();
System.out.println(stats.getCount()); // 5
System.out.println(stats.getSum()); // 14
System.out.println(stats.getMin()); // 1
System.out.println(stats.getMax()); // 5
System.out.println(stats.getAverage()); // 2.8
The collector equivalent works on object streams, which is how you get statistics over a field:
DoubleSummaryStatistics salaryStats = employees.stream()
.collect(Collectors.summarizingDouble(Employee::salary));
Three cautions.
IntStream.sum() returns an int and overflows silently on large data.
Use asLongStream().sum() or mapToLong when totals may be large:
int overflow = IntStream.of(Integer.MAX_VALUE, 1).sum(); // negative
long safe = IntStream.of(Integer.MAX_VALUE, 1).asLongStream().sum();
"hello".chars() yields int code points, so printing them directly gives
numbers rather than letters:
"abc".chars().forEach(System.out::println); // 97 98 99
"abc".chars().mapToObj(c -> (char) c).forEach(System.out::println); // a b c
And average() returns an empty OptionalDouble for an empty stream, which is
correct rather than zero. Handle it with orElse.
| Feature | Stream<Integer> | IntStream |
|---|---|---|
| Boxing | Yes | No |
sum, average, max | Not available | Built in |
summaryStatistics | Via a collector | Built in |
| Collecting | Direct | Needs boxed() |
| Optional type | Optional<Integer> | OptionalInt |
Key Takeaways
- Primitive streams avoid boxing and add numeric operations that
Streamlacks.mapToInt,mapToDouble, andmapToLongenter the primitive world.boxed()andmapToObjreturn to the object world, which collecting requires.maxandaveragereturnOptionalIntandOptionalDouble, notOptional<T>.summaryStatistics()gives count, sum, min, max, and average in one pass.IntStream.sum()overflows silently; widen tolongfor large totals.String.chars()produces code points, so cast tocharbefore printing.
🧪 Practice
- Sum the squares of 1 through 100 with
IntStreamand no boxing.- Compute count, min, max, and average of a list of prices with
summarizingDouble.- Count vowels in a string using
chars()and a filter.- Interview: Why does
IntStream.average()returnOptionalDoublerather thandouble? (Hint: what is the average of an empty stream?)
Parallel Streams and Their Caveats
Adding .parallel() splits a stream across multiple threads. It is a one-word
change, which is exactly why it is dangerous: the syntax hides a large increase
in complexity and frequently makes code slower rather than faster.
long count = hugeList.parallelStream()
.filter(this::isExpensive)
.count();
// Equivalent
long same = hugeList.stream().parallel().filter(this::isExpensive).count();
How it works: the stream's source is recursively split, each chunk is processed
by a thread in the common ForkJoinPool, and the partial results are combined.
[ 1 .. 1000 ]
| split
+----------+----------+
[1..500] [501..1000]
| split | split
+----+----+ +-----+-----+
[1..250] [251..500] [501..750] [751..1000]
| | | |
process process process process (separate threads)
| | | |
+--------+-----------+----------+
combine
The pool has availableProcessors() - 1 threads by default and is shared by
the entire JVM. One slow parallel stream can therefore starve every other
one, and blocking inside a parallel stream, on a network call for instance, is
particularly harmful because it occupies a pool thread doing nothing.
Parallelism pays off when all of the following hold:
- The data set is large, typically tens of thousands of elements or more.
- The per-element work is substantial, not a field access.
- The source splits cheaply: arrays,
ArrayList, andIntStream.rangeare ideal;LinkedListand mostStream.iteratesources are not. - The operations are stateless and independent.
- The terminal operation combines cheaply:
sumandcountare fine,Collectors.toMapmuch less so.
The costs it always pays are splitting, thread coordination, and merging. For small collections those dominate, and a parallel stream is reliably slower than a plain loop.
The correctness hazards are more serious than the performance ones.
Shared mutable state breaks silently. This is the single most common parallel stream bug:
List<Integer> results = new ArrayList<>();
IntStream.range(0, 10_000).parallel()
.forEach(results::add); // ArrayList is not thread-safe
// Lost updates, wrong size, or ArrayIndexOutOfBoundsException
// Correct: let the framework handle collection
List<Integer> safe = IntStream.range(0, 10_000).parallel().boxed().toList();
Non-associative operations give different answers depending on how the data was split:
// Subtraction is not associative: results vary run to run
int odd = Stream.of(1, 2, 3, 4).parallel().reduce(0, (a, b) -> a - b);
Order-dependent operations cost more in parallel. findFirst must
establish which match is genuinely first, while findAny may return whatever
appears soonest; forEachOrdered re-imposes ordering and discards much of the
benefit.
list.parallelStream().forEach(System.out::println); // arbitrary order
list.parallelStream().forEachOrdered(System.out::println); // ordered, slower
Stateful lambdas are unsafe even when they look harmless:
int[] counter = {0};
list.parallelStream().forEach(x -> counter[0]++); // lost updates, wrong total
If you must run parallel work on a pool you control, submit the pipeline to
your own ForkJoinPool:
ForkJoinPool pool = new ForkJoinPool(4);
long result = pool.submit(() -> hugeList.parallelStream().filter(...).count())
.get();
pool.shutdown();
| Use parallel when | Avoid parallel when |
|---|---|
| Tens of thousands of elements | Small collections |
| CPU-heavy per-element work | Trivial per-element work |
ArrayList, arrays, IntStream | LinkedList, iterate, I/O sources |
| Stateless, independent operations | Shared mutable state |
| Associative reduction | Non-associative operations |
| Order does not matter | Encounter order is required |
| No blocking calls | Network or database calls inside |
The honest summary: write the sequential version first, and only add
.parallel() if a profiler shows the pipeline is a bottleneck and measurement
proves the parallel version is actually faster on realistic data.
Key Takeaways
.parallel()splits work across the sharedForkJoinPool.commonPool().- The pool is JVM-wide, so one slow pipeline affects every other one.
- Parallelism needs large data, heavy per-element work, and a cheaply splittable source.
- Splitting, coordinating, and merging are fixed costs that small streams never recover.
- Shared mutable state produces lost updates and corrupted results.
- Reduction operations must be associative or parallel answers will differ.
findAnyandforEachare cheaper thanfindFirstandforEachOrdered.- Never block inside a parallel stream, and always measure before adopting one.
🧪 Practice
- Sum 1 to 10,000,000 sequentially and in parallel, and compare the timings.
- Do the same for a 100-element list and explain why parallel loses.
- Demonstrate corruption by adding to an
ArrayListinside a parallelforEach.- Show that
reduce(0, (a, b) -> a - b)gives inconsistent parallel results.- Interview: Why is a parallel stream over a
LinkedListusually slower than over anArrayList? (Hint: to split a range in half, what must each structure do first?)
<a id="85-optional-and-null-safety"></a>
8.5 Optional and Null Safety
This subchapter covers Optional: how to create one, how to transform it
without unwrapping, and the usage rules that separate it from a null check in
disguise.
Creating Optional Values
null has a well-known problem: it carries no information. A method returning
String might return null, and nothing in the signature says so. The caller
either writes a defensive check everywhere or eventually meets a
NullPointerException far from the cause.
// Does this ever return null? The signature does not say.
public User findUser(String id) { ... }
User user = findUser("123");
System.out.println(user.getName()); // NPE, if it did
Optional<T> is a container holding either one value or nothing, and it makes
the possibility of absence part of the type:
public Optional<User> findUser(String id) { ... }
// The caller cannot ignore this: the value must be unwrapped deliberately
Optional<User>
present: [ User(...) ] isPresent() -> true
empty: [ ] isEmpty() -> true
Think of it as a single-element box. It is either occupied or not, and you must open it to get at the contents.
There are three factory methods, and choosing the wrong one is a common error.
// 1. of: the value must NOT be null, or it throws immediately
Optional<String> a = Optional.of("hello");
// Optional<String> bad = Optional.of(null); // NullPointerException
// 2. ofNullable: accepts null, producing an empty Optional
Optional<String> b = Optional.ofNullable(maybeNull);
// 3. empty: an explicitly absent value
Optional<String> c = Optional.empty();
The rule is simple. Use of when you know the value is present, because the
immediate exception documents a broken assumption. Use ofNullable when
wrapping something that might legitimately be null, typically a value from a
legacy API or a map lookup:
public Optional<User> findUser(String id) {
return Optional.ofNullable(database.get(id)); // get may return null
}
Checking the contents:
Optional<String> opt = Optional.of("hello");
opt.isPresent(); // true
opt.isEmpty(); // true if absent (Java 11)
opt.get(); // "hello", but throws NoSuchElementException if empty
get() is the method to avoid. Calling it without first proving presence
simply moves the NullPointerException to a NoSuchElementException, gaining
nothing. Java 10 added orElseThrow() with no arguments as a clearer
equivalent, and modern code uses that or, better, the transformation methods in
the next two topics.
// The anti-pattern: this is a null check wearing a costume
if (opt.isPresent()) {
System.out.println(opt.get().toUpperCase());
}
// The idiomatic form
opt.map(String::toUpperCase).ifPresent(System.out::println);
Two conditional methods cover the common branches:
opt.ifPresent(v -> System.out.println(v)); // do X if present
opt.ifPresentOrElse( // Java 9
v -> System.out.println("found " + v),
() -> System.out.println("nothing found"));
An important limit to state early: Optional is not Serializable and is
designed as a return type, not a universal null replacement. The guidelines
topic covers where it belongs and where it does not.
Finally, note that there are primitive variants, OptionalInt, OptionalLong,
and OptionalDouble, returned by the primitive streams. They avoid boxing but
lack map and flatMap, so they are less useful for chaining.
Key Takeaways
Optional<T>makes possible absence visible in the type signature.Optional.ofthrows on null;ofNullableconverts null to empty;emptyis explicit absence.- Wrap legacy or lookup results with
ofNullable, notof.get()throwsNoSuchElementExceptionand should be avoided.- An
isPresentandgetpair is just a null check in disguise.ifPresentandifPresentOrElseexpress the two branches directly.Optionalis not serialisable and is intended as a return type.
🧪 Practice
- Write
Optional<String> findById(int id)over a map, usingofNullable.- Show that
Optional.of(null)throws whileOptional.ofNullable(null)does not.- Replace an
isPresent/getblock withifPresentOrElse.- Interview: Does
OptionaleliminateNullPointerException? (Hint: what happens if a method returns a nullOptionalreference?)
map, flatMap, and filter on Optional
The real value of Optional is not the presence check. It is that you can
transform the contained value without ever unwrapping it, and absence
propagates through the chain automatically. This is the same idea as a stream,
applied to a container of at most one element.
map applies a function if a value is present, and does nothing if it is
not:
Optional<String> name = Optional.of("ada");
Optional<String> upper = name.map(String::toUpperCase); // Optional[ADA]
Optional<Integer> length = name.map(String::length); // Optional[3]
Optional<String> none = Optional.<String>empty();
Optional<Integer> stillNone = none.map(String::length); // Optional.empty
// The function was never called. No null check was needed.
present: [ "ada" ] --map(toUpperCase)--> [ "ADA" ]
empty: [ ] --map(toUpperCase)--> [ ] (function skipped)
That skipping behaviour is what removes the nested if blocks. Compare:
// Imperative: three levels of null checking
public String getCityImperative(User user) {
if (user != null) {
Address address = user.getAddress();
if (address != null) {
City city = address.getCity();
if (city != null) {
return city.getName().toUpperCase();
}
}
}
return "UNKNOWN";
}
// With Optional: one chain, absence handled once at the end
public String getCity(User user) {
return Optional.ofNullable(user)
.map(User::getAddress) // skipped if user is absent
.map(Address::getCity) // skipped if address is null
.map(City::getName)
.map(String::toUpperCase)
.orElse("UNKNOWN");
}
Note a convenience: if getAddress() returns a plain Address that may be
null, map still works, because it wraps the result with ofNullable
internally. A null result becomes an empty Optional rather than an
Optional containing null.
flatMap is for the case where the function itself returns an
Optional. Using map there produces a doubly wrapped value:
public Optional<Address> getAddress() { ... } // already returns Optional
// map gives a nested Optional: almost never useful
Optional<Optional<Address>> nested = optUser.map(User::getAddress);
// flatMap unwraps one level
Optional<Address> address = optUser.flatMap(User::getAddress);
The rule mirrors the stream version: use map when the function returns a
plain value, flatMap when it returns an Optional.
// A chain over an API that returns Optional at every step
String city = findUser(id)
.flatMap(User::getAddress) // Optional<Address>
.flatMap(Address::getCity) // Optional<City>
.map(City::getName) // plain String, so map
.orElse("UNKNOWN");
filter keeps the value only if it matches a predicate, and turns it into
an empty Optional otherwise:
Optional<User> adult = findUser(id).filter(u -> u.age() >= 18);
// Combines naturally with the rest of the chain
String result = findUser(id)
.filter(User::isActive)
.map(User::getName)
.orElse("no active user");
or, added in Java 9, supplies a fallback Optional rather than a
fallback value, which lets you try sources in order:
Optional<User> user = findInCache(id)
.or(() -> findInDatabase(id)) // only runs if the cache missed
.or(() -> findInBackup(id));
stream, also from Java 9, bridges into the Stream API and is the clean
way to drop empties from a collection of optionals:
List<String> names = userIds.stream()
.map(this::findUser) // Stream<Optional<User>>
.flatMap(Optional::stream) // Stream<User>, empties removed
.map(User::getName)
.toList();
| Method | Function returns | Result |
|---|---|---|
map(f) | A plain value | Optional<R> |
flatMap(f) | An Optional<R> | Optional<R> |
filter(p) | A boolean | Same or empty |
or(supplier) | An Optional<T> | This, or the fallback |
stream() | - | A 0 or 1 element stream |
Key Takeaways
maptransforms the contained value and skips the function when empty.- Absence propagates automatically, replacing nested null checks with a chain.
mapwraps its result withofNullable, so a null result becomes empty.- Use
flatMapwhen the function already returns anOptional, to avoid nesting.filterempties theOptionalwhen the predicate fails.orsupplies an alternativeOptional, evaluated lazily.Optional.stream()flattens a stream of optionals into present values only.
🧪 Practice
- Chain
mapcalls to get an uppercased city name from a possibly null user.- Write two methods returning
Optionaland chain them withflatMap.- Use
filterto return a user only when active, defaulting otherwise.- Convert a
List<Optional<String>>into aList<String>of present values.- Interview: When does
mapproduceOptional<Optional<T>>? (Hint: what is the return type of the function you passed in?)
orElse, orElseGet, and orElseThrow
Eventually a chain must produce a real value. The unwrapping methods differ in
what they do when the Optional is empty, and the difference between two of
them is a genuine performance trap.
orElse(T other) returns the value, or the given default:
String name = findUser(id).map(User::getName).orElse("guest");
orElseGet(Supplier<T>) returns the value, or calls the supplier:
String name = findUser(id).map(User::getName).orElseGet(() -> lookupDefault());
These look interchangeable, and for a constant they are. The difference is
when the default is evaluated. orElse takes a value, so the expression
producing it runs before the call, whether or not it is needed. orElseGet
takes a supplier, so the work happens only on the empty path.
public String expensiveDefault() {
System.out.println("computing default");
return "default";
}
Optional<String> present = Optional.of("value");
present.orElse(expensiveDefault());
// prints "computing default", then returns "value"
// The work was done and thrown away.
present.orElseGet(this::expensiveDefault);
// prints nothing, returns "value"
orElse(expensive()) orElseGet(() -> expensive())
1. evaluate expensive() 1. is the value present?
2. is the value present? yes -> return it, supplier never runs
yes -> return it, no -> run the supplier
discarding the default
The rule: use orElse for constants and cheap literals, and orElseGet
whenever the default involves a method call, an allocation, or any side effect.
The trap is worse than wasted work when the default has side effects, such as
inserting a row or incrementing a counter, since those happen even on the
happy path.
orElseThrow(Supplier<X>) throws a chosen exception when empty. This is
the standard way to convert "not found" into a domain error at a service
boundary:
User user = findUser(id)
.orElseThrow(() -> new UserNotFoundException("No user with id " + id));
The supplier matters here for the same reason: constructing an exception fills in a stack trace, which is not free, so it should happen only when the exception is actually thrown.
The no-argument orElseThrow(), added in Java 10, throws
NoSuchElementException and is the intended replacement for get():
String value = opt.orElseThrow(); // clearer than opt.get()
The complete set:
| Method | When empty | Evaluates default |
|---|---|---|
orElse(v) | Returns v | Always |
orElseGet(supplier) | Calls the supplier | Only when empty |
orElseThrow() | Throws NoSuchElementException | - |
orElseThrow(supplier) | Throws the supplied exception | Only when empty |
get() | Throws NoSuchElementException | - |
ifPresent(consumer) | Does nothing | - |
ifPresentOrElse(c, r) | Runs the empty-case runnable | - |
A practical pattern, combining several of them:
public Order loadOrder(String id) {
return repository.findById(id)
.filter(Order::isVisible) // hide soft-deleted
.map(this::enrich) // add computed fields
.orElseThrow(() -> new OrderNotFound(id)); // fail with context
}
public String displayName(String userId) {
return findUser(userId)
.map(User::getNickname)
.filter(n -> !n.isBlank())
.orElseGet(() -> buildDefaultName(userId)); // only if needed
}
Key Takeaways
orElsetakes a value that is always evaluated, even when one is present.orElseGettakes a supplier that runs only on the empty path.- Use
orElsefor constants andorElseGetfor anything computed.- A default with side effects in
orElseexecutes even on the happy path.orElseThrow(supplier)converts absence into a meaningful domain exception.- The no-argument
orElseThrow()is the modern replacement forget().- Exception suppliers also defer the cost of building a stack trace.
🧪 Practice
- Print a message from inside a default method and show that
orElseruns it whileorElseGetdoes not.- Rewrite a method that returns null on failure to return
Optionaland throw viaorElseThrow.- Chain
filter,map, andorElseGetto produce a display name with a computed fallback.- Interview: Both
orElseandorElseGetreturn the same value here, so why prefer one? (Hint: what happens to the argument expression before the method is even entered?)
Optional Usage Guidelines and Misuse
Optional was introduced for one specific purpose, and its designers said so
explicitly: to be the return type of methods that may have no result. Using
it everywhere produces code that is more verbose than the null checks it
replaced.
The intended use is a return type signalling possible absence:
public Optional<User> findByEmail(String email) {
return Optional.ofNullable(repository.lookup(email));
}
Everything below is a documented misuse.
Do not use Optional for fields. It adds an object per field, it is not
serialisable, and it does not prevent the field itself being null:
public class User {
private Optional<String> nickname; // WRONG: wasteful and not serialisable
private String nickname2; // right: null means absent internally
public Optional<String> getNickname() {
return Optional.ofNullable(nickname2); // wrap at the boundary
}
}
Do not use Optional as a method parameter. It forces every caller to wrap
their argument, and the parameter can still be passed null:
// WRONG: callers must write findUser(Optional.of(name))
public List<User> findUser(Optional<String> name) { ... }
// Right: overload, or accept null with a documented contract
public List<User> findUser(String name) { ... }
public List<User> findAllUsers() { ... }
Never return a null Optional. It is the worst of both worlds, since
callers reasonably assume a non-null container:
public Optional<User> find(String id) {
if (id == null) return null; // WRONG
return Optional.empty(); // right
}
Do not use Optional for collections. An empty collection already
expresses "nothing here", so an Optional<List<T>> gives callers two ways to
say the same thing:
public Optional<List<User>> getUsers() // WRONG: two empty states
public List<User> getUsers() // right: return an empty list
Do not wrap a value just to unwrap it immediately. This adds allocation and indirection for nothing:
// WRONG
String result = Optional.ofNullable(value).orElse("default");
// Simpler and faster
String result2 = value != null ? value : "default";
The wrap is worth it only when a chain of transformations follows.
Avoid isPresent followed by get, which was covered earlier but is worth
repeating because it is the most common misuse of all:
// WRONG: a null check with extra steps
if (opt.isPresent()) { process(opt.get()); }
// Right
opt.ifPresent(this::process);
Avoid Optional in performance-critical inner loops. Each one is a heap
allocation, so in a tight numeric loop the overhead is real. Use it at API
boundaries, not per iteration.
Summarised:
| Context | Use Optional? | Instead |
|---|---|---|
| Return type, may be absent | Yes | - |
| Instance field | No | A nullable field, wrapped in the getter |
| Method parameter | No | Overloads, or a nullable parameter |
| Collection return type | No | An empty collection |
| Returning null itself | Never | Optional.empty() |
| Hot inner loop | No | Plain null checks |
| Chained transformations | Yes | - |
Two final points. Optional is a value-based class, so never synchronise
on one and never compare with ==; use equals, which compares the contained
values. And Optional does not remove the need for judgement about nulls
inside your own data structures. It documents a contract at a boundary; it is
not a null-safety system for the whole program.
Optional<String> a = Optional.of("x");
Optional<String> b = Optional.of("x");
System.out.println(a == b); // false, and not guaranteed either way
System.out.println(a.equals(b)); // true: compares the contents
Key Takeaways
Optionalwas designed as a return type for methods that may have no result.- Do not use it for fields; it wastes memory and is not serialisable.
- Do not use it for parameters; use overloads instead.
- Never return a null
Optional; returnOptional.empty().- Return an empty collection rather than an
Optionalof a collection.- Wrapping only to call
orElseimmediately is worse than a ternary.- Replace
isPresentplusgetwithifPresent,map, ororElseThrow.- It is a value-based class, so compare with
equalsand never synchronise on it.
🧪 Practice
- Review a class with nullable fields and convert only the getters to return
Optional.- Rewrite a method taking
Optional<String>as two overloads.- Find an
Optional.ofNullable(x).orElse(y)in your code and simplify it.- Interview: Why is
Optionaldiscouraged as a field type? (Hint: consider memory per instance, and what happens when the object is serialised.)
<a id="9-object-oriented-design-principles-and-patterns"></a>