This chapter teaches you to pass small pieces of behaviour (lambdas) to library methods, to sort with comparators, and to summarise collections with streams. Before the questions, make sure you can: name the six core functional interfaces (Predicate, Function, Consumer, Supplier, BiFunction, Comparator) and the single abstract method of each; write a lambda in every legal shape and explain why captured local variables must be effectively final; rewrite a lambda as each of the four kinds of method reference; tell Comparable from Comparator and build multi-key comparators with comparing, thenComparing and reversed; sort and filter a list in place with List.sort and removeIf; and trace a stream pipeline (source → intermediate operations → terminal operation), including laziness, element-by-element processing, single use, collect with groupingBy/counting/joining, reduce, and Optional results.
A lambda is like a short note with instructions that you hand to someone else: "for each student, look at the score", "keep only the words longer than 3 letters". The library method (sort, removeIf, filter) does the boring loop; your note says what to do with each element. A stream is a factory assembly line: items travel along a belt through several stations (filter, transform, sort), and the belt only starts moving when somebody at the end asks for the finished product. With these two ideas you can sort, filter and summarise any collection in one or two readable lines.
Data structures only become useful when you can say how to order, filter and transform what they hold. Since Java 8 you do that by passing small pieces of behaviour — lambdas and method references — to library methods. This chapter gives you the vocabulary you will use in every later chapter: comparators for sorting and priority queues, predicates for filtering, and streams for summarising collections.
Functional interfaces
Think of an electric socket. Any plug with the right shape fits, whatever device is attached to it. A functional interface is a socket with exactly one "hole" (one abstract method). Any lambda with the right shape — the right number and types of inputs and output — can be plugged in.
A functional interface is an interface with exactly one abstract method. Any lambda or method reference can be used wherever such an interface is expected; the compiler matches the lambda against that one method. The annotation @FunctionalInterface is optional — it only asks the compiler to check the rule.
The package java.util.function supplies the shapes you need most:
| Interface | Abstract method | Shape | Typical use |
|---|---|---|---|
Predicate<T> |
boolean test(T t) |
T → boolean | filtering, removeIf |
Function<T, R> |
R apply(T t) |
T → R | map, key extractors |
BiFunction<T, U, R> |
R apply(T t, U u) |
(T, U) → R | combining two values |
Consumer<T> |
void accept(T t) |
T → nothing | forEach |
Supplier<T> |
T get() |
nothing → T | factories, orElseGet |
Comparator<T> (java.util) |
int compare(T a, T b) |
(T, T) → int | sorting |
A quick way to remember them: a Predicate answers a yes/no question, a Function turns one thing into another, a Consumer eats a value and gives nothing back, a Supplier gives a value without being asked anything.
Predicate<String> isLong = s -> s.length() > 3;
isLong.test("pear"); // true
Consumer<String> shout = s -> System.out.println(s.toUpperCase() + "!");
shout.accept("hi"); // prints HI!
Supplier<List<String>> make = ArrayList::new;
make.get(); // a new empty list: []
UnaryOperator<T> and BinaryOperator<T> are the special cases of Function and BiFunction where every type is the same. Many interfaces also have default methods that combine behaviour: Predicate.and/or/negate, Function.andThen/compose, Comparator.reversed/thenComparing.
Function<Integer, Integer> add3 = x -> x + 3;
Function<Integer, Integer> twice = x -> x * 2;
add3.andThen(twice).apply(1); // (1 + 3) * 2 = 8 — add3 first
add3.compose(twice).apply(1); // (1 * 2) + 3 = 5 — twice first
Step by step:
add3.andThen(twice).apply(1): 1 ──add3──> 4 ──twice──> 8
add3.compose(twice).apply(1): 1 ──twice─> 2 ──add3───> 5
f.andThen(g) means "f, and then g" — read it left to right. f.compose(g) means "g first, then f", like f(g(x)) in maths. If you forget, try both with the input 1, as above.
Lambda syntax
Before Java 8 you had to write a whole anonymous class just to pass one small method. A lambda is the same thing with everything the compiler can guess removed — like writing "2 coffees, no sugar" on a note instead of a formal letter.
From a class to a lambda, step by step. All four versions below are the same comparator (shorter strings first); each one removes something the compiler can work out itself:
// 1. Anonymous class: the old way
Comparator<String> c1 = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
};
// 2. Lambda with types and a block body
Comparator<String> c2 = (String a, String b) -> { return Integer.compare(a.length(), b.length()); };
// 3. Types inferred, expression body: no braces, no return, no inner semicolon
Comparator<String> c3 = (a, b) -> Integer.compare(a.length(), b.length());
// 4. A library helper does the whole job
Comparator<String> c4 = Comparator.comparingInt(String::length);
All four give compare("fig", "pear") = −1 (negative: "fig" is shorter, so it comes first).
A lambda is parameters -> body.
() -> 42 // no parameters: brackets required
s -> s.length() // one parameter: brackets optional
(a, b) -> a + b // several parameters: brackets required
(String a, String b) -> a + b // explicit types: all or none
x -> { int y = x * x; return y; } // block body: braces, semicolons, return
If the body is a single expression you write no return and no semicolon inside. As soon as you use braces you are writing ordinary statements, so a value-returning lambda needs return. You cannot mix typed and untyped parameters ((int a, b) -> … is illegal), and a lambda parameter may not reuse the name of a local variable that is already in scope.
Capturing variables. A lambda may read local variables of the enclosing method only if they are final or effectively final — never assigned after initialisation, anywhere in the method (before or after the lambda). The lambda may run later, perhaps after the method has returned, so Java copies the value; allowing the variable to change would make the copy silently out of date. Fields and array elements are not restricted, which is why "use a one-element array" is a known (but ugly) workaround. The clean fix is usually a stream operation such as count() or sum().
Think of it like a photo: the lambda takes a photo of the variable's value. If the variable could change later, the photo would show old information, so Java simply forbids changing it.
int total = 0; list.forEach(x -> total += x);does not compile —totalis modified.- Assigning the variable after the lambda also breaks the rule: the error is still reported inside the lambda.
x -> return x * 2;is illegal:returnneeds a block bodyx -> { return x * 2; }.
Method references
If your note only says "call Anna", you do not need to write "take the phone, dial Anna's number, wait for her to answer". A method reference is that short form: when a lambda does nothing except call one existing method, you just name the method.
When a lambda does nothing but call one existing method, a method reference says the same thing more briefly. There are four kinds:
| Kind | Syntax | Equivalent lambda |
|---|---|---|
| Static method | Integer::parseInt |
s -> Integer.parseInt(s) |
| Bound instance (a particular object) | System.out::println |
x -> System.out.println(x) |
| Unbound instance (object supplied later) | String::length |
s -> s.length() |
| Constructor | ArrayList::new |
() -> new ArrayList<>() |
The unbound kind is the one that surprises students: the first parameter of the functional interface becomes the object the method is called on, and any remaining parameters become the arguments. So BiFunction<String, Integer, Character> f = String::charAt; means (s, i) -> s.charAt(i). Which kind a reference is depends on what stands before :: — a class name with an instance method is unbound; an expression (such as System.out or "abc") is bound.
Worked example: f.apply("hello", 1) with f = String::charAt.
| Step | What happens |
|---|---|
| 1 | BiFunction<String, Integer, Character> has apply(String, Integer). |
| 2 | String::charAt is unbound, so the first argument "hello" becomes the object. |
| 3 | The second argument 1 becomes the argument: "hello".charAt(1). |
| 4 | Result: 'e'. |
To check a method reference, rewrite it as a lambda with the parameters of the target interface. If that lambda compiles, so does the reference.
Comparable versus Comparator
Comparable is the order a class carries inside itself, like people in a phone book sorted by name by default. Comparator is an outside judge you can hire for a different order: by age today, by height tomorrow. One class has at most one natural order, but you can create as many comparators as you like.
A class that has one obvious order implements java.lang.Comparable<T> and its method int compareTo(T other) — its natural ordering. String, Integer and LocalDate all do. Any other ordering lives outside the class in a java.util.Comparator<T> with int compare(T a, T b). Both follow the same contract: negative if the first comes first, zero if they tie, positive otherwise.
class Student implements Comparable<Student> {
private final String name;
private final int score;
// constructor and getters omitted
public int compareTo(Student o) { return name.compareTo(o.name); } // natural: by name
}
Comparator<Student> byScore = Comparator.comparing(Student::getScore);
The sign is all that matters, not the size of the number:
Result of compare(a, b) |
Meaning | Sorted order |
|---|---|---|
| negative (e.g. −1, −7) | a is "smaller" |
a before b |
| 0 | tie | either order (a stable sort keeps the original order) |
| positive (e.g. 1, 42) | a is "bigger" |
b before a |
Use Comparable for the single default order; use Comparator when you need several orders, when you cannot edit the class, or when you need the reverse of the natural order.
Comparable<T> |
Comparator<T> |
|
|---|---|---|
| Package | java.lang |
java.util |
| Method | compareTo(T other) — one parameter |
compare(T a, T b) — two parameters |
| Where it lives | inside the class being sorted | in a separate object |
| How many | one (the natural order) | as many as you want |
Writing (a, b) -> a - b for integers overflows when the values have opposite signs and large magnitudes. Prefer Integer.compare(a, b) or Comparator.comparingInt(...).
For example, with a = Integer.MAX_VALUE and b = -1, the subtraction overflows and a - b gives −2147483648: the comparator claims that the largest int is smaller than −1. Integer.compare(a, b) correctly returns 1.
Building comparators
Sorting a class list is like a teacher's ranking: "highest score first; if two scores are equal, alphabetical by name". Each rule is one link in a chain, and a later link is only used to break ties from the earlier links.
The static and default methods of Comparator let you build orderings from key extractors instead of writing compare by hand:
Comparator<String> byLength = Comparator.comparing(String::length);
Comparator<String> longestFirst = byLength.reversed();
Comparator<String> lengthThenAlpha = byLength.thenComparing(Comparator.naturalOrder());
Comparator<Student> ranking = Comparator.comparing(Student::getScore).reversed()
.thenComparing(Student::getName);
comparing(keyExtractor)compares by the key;comparingIntavoids boxing.thenComparing(...)is consulted only when the previous comparator returns 0.reversed()reverses everything built so far in the chain, not just the last key.Comparator.naturalOrder()andComparator.reverseOrder()(alsoCollections.reverseOrder()) work for anyComparabletype.
A key extractor is a function that picks the value to compare, such as Student::getScore.
Worked example. Start with four students: [Mia:85, Ali:92, Zoe:85, Ben:70].
| Comparator | Result | Why |
|---|---|---|
natural order (compareTo, by name) |
[Ali:92, Ben:70, Mia:85, Zoe:85] |
alphabetical |
ranking (score descending, then name) |
[Ali:92, Mia:85, Zoe:85, Ben:70] |
Mia and Zoe tie on 85 → name decides: Mia first |
comparing(getScore).thenComparing(getName).reversed() |
[Ali:92, Zoe:85, Mia:85, Ben:70] |
reversed() at the end reverses the name rule too: Zoe before Mia |
The last row shows the trap: the position of reversed() in the chain matters.
Comparator.comparing(s -> s.length()).reversed() does not compile: with a lambda and a chained call, the compiler cannot infer the type of s and treats it as Object. Use a method reference (String::length) or a typed lambda ((String s) -> s.length()).
Sorting and bulk operations on lists
Instead of writing your own loop, you tell the list what you want: "sort yourself by this rule", "remove everything that matches this test", "do this to every element". The list does the loop for you.
List<String> words = new ArrayList<>(Arrays.asList("pear", "fig", "apple"));
Collections.sort(words); // natural order (needs Comparable)
words.sort(Comparator.comparing(String::length)); // any Comparator; null = natural order
words.removeIf(w -> w.startsWith("f")); // removes every match, returns boolean
words.forEach(System.out::println); // Consumer applied to each element
State after every line:
| Line | words after the line |
|---|---|
| create | [pear, fig, apple] |
Collections.sort(words) |
[apple, fig, pear] (alphabetical) |
words.sort(comparing(String::length)) |
[fig, pear, apple] (lengths 3, 4, 5) |
words.removeIf(w -> w.startsWith("f")) |
[pear, apple] — returns true because something was removed |
words.forEach(System.out::println) |
unchanged; prints pear then apple |
Collections.sort(list) is declared as <T extends Comparable<? super T>> void sort(List<T> list), so calling it on a list of a class that is not Comparable is a compile-time error. Both sorts are stable: equal elements keep their relative order, which is why sorting first by name and then by score gives "by score, ties by name".
Stability in action. Sorted by name, the students are [Ali:92, Ben:70, Mia:85, Zoe:85]. Now sort that list by score only (ascending): the result is [Ben:70, Mia:85, Zoe:85, Ali:92]. Mia and Zoe tie on 85, and a stable sort keeps them in their previous (alphabetical) order.
Streams: source → intermediate → terminal
A stream is an assembly line. The source puts items on the belt. Each intermediate station does one job: throw away bad items (filter), change items (map), put them in order (sorted). The terminal station at the end packs the result into a box (collect), counts it (count) or hands out one answer. The line is a way of processing items, not a place to store them.
A stream is a pipeline that processes the elements of a source; it does not store them and it never modifies the source collection.
List<String> names = Arrays.asList("Omar", "Bea", "Lina", "Al", "Bea");
List<String> result = names.stream() // source
.filter(n -> n.length() > 2) // intermediate
.distinct() // intermediate
.map(String::toUpperCase) // intermediate
.sorted() // intermediate
.collect(Collectors.toList()); // terminal
// [BEA, LINA, OMAR]
Worked example: the data after each station.
source [Omar, Bea, Lina, Al, Bea]
filter >2 [Omar, Bea, Lina, Bea] "Al" has only 2 letters: removed
distinct [Omar, Bea, Lina] the second "Bea" is a duplicate: removed
map upper [OMAR, BEA, LINA] every name changed
sorted [BEA, LINA, OMAR] alphabetical
collect a new List: [BEA, LINA, OMAR]
names itself is still [Omar, Bea, Lina, Al, Bea] afterwards: the stream never changes its source.
| Kind | Examples | Returns |
|---|---|---|
| Source | list.stream(), Stream.of(...), Arrays.stream(arr), IntStream.range(a, b) |
a stream |
| Intermediate | filter, map, mapToInt, sorted, distinct, limit, skip, peek |
a new stream |
| Terminal | forEach, count, collect, reduce, min, max, anyMatch, findFirst |
a result (or void) |
filter keeps the elements for which the test is true (it does not throw them away). removeIf on a list does the opposite: it removes the elements for which the test is true. Also: list.removeIf changes the list; stream().filter creates a new result and leaves the list alone.
Laziness and single use
On this assembly line, the belt does not move until the last station presses the start button. Building the line (writing filter, map, …) does no work at all. And the line is single-use: after one run it is taken apart. To run again, build a new line from the source.
Intermediate operations are lazy: they only describe the work. Nothing at all happens until a terminal operation asks for results. Then elements flow through the pipeline one at a time — the first element passes through filter and map before the second element is even looked at — except for stateful operations such as sorted, which must see everything first. Laziness lets limit and findFirst stop early, even on an infinite stream like Stream.iterate(1, x -> x * 2).
Worked example: one element at a time.
List<String> out = Stream.of("ant", "bee", "cat")
.filter(x -> { System.out.println("filter " + x); return !x.equals("bee"); })
.map(x -> { System.out.println("map " + x); return x.toUpperCase(); })
.collect(Collectors.toList());
Output:
filter ant
map ant ← "ant" goes all the way down the line first
filter bee ← "bee" is stopped by the filter: no "map bee"
filter cat
map cat
and out is [ANT, CAT]. Notice the order: it is not "filter, filter, filter, then map, map".
Worked example: sorted waits for everything.
Stream.of("c", "a", "b")
.peek(x -> System.out.println("before sort " + x))
.sorted()
.forEach(x -> System.out.println("after sort " + x));
before sort c
before sort a
before sort b ← sorted must collect all three before it can answer
after sort a
after sort b
after sort c
Early stop. Stream.iterate(1, x -> x * 2).limit(5).collect(Collectors.toList()) gives [1, 2, 4, 8, 16]. The stream is infinite, but limit(5) stops asking after five elements, so the program ends.
A stream can be consumed once. After a terminal operation the stream is closed, and any further use — even calling another intermediate operation on the same variable — throws IllegalStateException at run time. The code compiles fine. If you need the data twice, call list.stream() again.
Stream<String> s = names.stream();
s.count(); // fine: 5
s.count(); // IllegalStateException: stream has already been operated upon or closed
No terminal operation → no work, no output from peek or map. One terminal operation per stream.
Terminal operations that summarise
The terminal station decides what you get at the end: a number (count), one combined value (reduce), a single string (joining) or a map of groups (groupingBy) — like sorting post into pigeonholes by the first letter of the name.
long n = words.stream().filter(w -> w.length() > 3).count(); // count() is long
int sum = Stream.of(1, 2, 3, 4).reduce(0, (a, b) -> a + b); // 10
String csv = words.stream().collect(Collectors.joining(", ", "[", "]"));
Map<Integer, List<String>> byLen = words.stream()
.collect(Collectors.groupingBy(String::length));
Map<Integer, Long> howMany = words.stream()
.collect(Collectors.groupingBy(String::length, Collectors.counting()));
reduce(identity, op)starts from the identity and folds every element in; the identity must really be neutral (0 for +, 1 for ×) or the answer is shifted.groupingBy(classifier)builds aMapfrom key toList; add a downstream collector such ascounting()to summarise each group instead.groupingBy(f, TreeMap::new, counting())gives sorted keys.joining(delimiter, prefix, suffix)concatenates strings.
Worked example: reduce(0, (a, b) -> a + b) on 1, 2, 3, 4. The first parameter a is the running total; b is the next element:
| Step | a (total so far) | b (next element) | a + b |
|---|---|---|---|
| start | 0 (identity) | — | — |
| 1 | 0 | 1 | 1 |
| 2 | 1 | 2 | 3 |
| 3 | 3 | 3 | 6 |
| 4 | 6 | 4 | 10 |
With a wrong identity, reduce(10, (a, b) -> a + b) gives 20: the answer is shifted by 10.
Worked example: the collectors with words = [pear, fig, apple, kiwi, plum]:
| Terminal operation | Result |
|---|---|
filter(w -> w.length() > 3).count() |
4 (a long) |
collect(joining(", ", "[", "]")) |
"[pear, fig, apple, kiwi, plum]" |
collect(groupingBy(String::length)) |
{3=[fig], 4=[pear, kiwi, plum], 5=[apple]} |
collect(groupingBy(String::length, counting())) |
{3=1, 4=3, 5=1} |
groupingBy(String::length) — like putting letters into pigeonholes:
3 │ fig
4 │ pear, kiwi, plum (in the order they arrived)
5 │ apple
A plain groupingBy makes no promise about the order of the keys (in practice it builds a HashMap; here the small integer keys just happen to print in order). Use TreeMap::new when you need sorted keys.
Optional and primitive streams
An Optional is a gift box that may be empty. Instead of giving you null (and a NullPointerException later), the method gives you a box and makes you decide what to do if it is empty: "give me the value, or else this default".
Some terminal operations may have no answer: the maximum of an empty stream does not exist. They return an Optional<T> instead of null:
Optional<String> longest = words.stream().max(Comparator.comparing(String::length));
String s = longest.orElse("none"); // value or a default
boolean present = longest.isPresent();
| Stream | max(comparing(String::length)) |
.orElse("none") |
.isPresent() |
|---|---|---|---|
[pear, fig, apple, kiwi, plum] |
Optional[apple] |
"apple" |
true |
| empty | Optional.empty |
"none" |
false |
mapToInt turns a Stream<T> into an IntStream, which offers sum() (an int, 0 when empty), average() (an OptionalDouble) and max() (an OptionalInt). Calling get()/getAsDouble() on an empty optional throws NoSuchElementException, so prefer orElse.
For the same five words, mapToInt(String::length) gives the stream 4, 3, 5, 4, 4: sum() is 20, average() is OptionalDouble[4.0] and max() is OptionalInt[5]. On an empty stream sum() is 0 but average() is OptionalDouble.empty — the average of nothing does not exist.
When tracing a pipeline, write the elements down after each step. Mark the step where a stateful operation (sorted, distinct) waits for all input, and check the return type of the terminal operation — count() is long, average() is OptionalDouble, max(cmp) is Optional<T>.
Key takeaways
- A functional interface has exactly one abstract method; a lambda is a short way to implement it. Know
Predicate,Function,BiFunction,Consumer,Supplier,Comparator. - Expression lambdas have no
returnand no braces; block lambdas need both. Captured local variables must be effectively final. - A method reference replaces a lambda that only calls one method; for the unbound kind (
String::length) the first parameter becomes the object. Comparable= the natural order inside the class (compareTo);Comparator= any outside order (compare). Only the sign of the result matters. Avoida - b.- Build comparators with
comparing,thenComparing(tie-breaker) andreversed(reverses the whole chain so far). List.sortandCollections.sortare stable;removeIfremoves matches in place;filterkeeps matches in a new stream.- Streams are lazy (nothing runs without a terminal operation), process elements one at a time (except
sorted/distinct), and can be used only once. count()returnslong;max/min/findFirstreturnOptional;average()returnsOptionalDouble; useorElsefor a safe default.
Ready? Close the notes and practise.
33 questions. Predict the output before you check — that is the skill the exam measures.