THINK FIRST·CODE LATER

← Data Structures and Algorithms
Chapter 3 · Week 2–3

Lambdas, Comparators and Streams

Before You Start: What You Must Be Able to Do

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.

The Big Idea

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

In plain words

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
Common confusion

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

In plain words

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.

Common Pitfalls
  • int total = 0; list.forEach(x -> total += x); does not compile — total is 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: return needs a block body x -> { return x * 2; }.

Method references

In plain words

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'.
Exam Tip

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

In plain words

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
Common Pitfalls

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

In plain words

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; comparingInt avoids 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() and Comparator.reverseOrder() (also Collections.reverseOrder()) work for any Comparable type.

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.

Common Pitfalls

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

In plain words

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

In plain words

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)
Common confusion

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

In plain words

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
Remember

No terminal operation → no work, no output from peek or map. One terminal operation per stream.

Terminal operations that summarise

In plain words

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 a Map from key to List; add a downstream collector such as counting() 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

In plain words

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.

Exam Tip

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 return and 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. Avoid a - b.
  • Build comparators with comparing, thenComparing (tie-breaker) and reversed (reverses the whole chain so far).
  • List.sort and Collections.sort are stable; removeIf removes matches in place; filter keeps 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() returns long; max/min/findFirst return Optional; average() returns OptionalDouble; use orElse for a safe default.

Ready? Close the notes and practise.

33 questions. Predict the output before you check — that is the skill the exam measures.