← Fundamentals
Guided lesson 7 questions · Java only

Java streams and lambdas

When a stream pipeline runs, what each stage asks for, and what collect, max, and average give back.

A Java stream runs nothing until its terminal operation asks for elements, and then it takes them one at a time through every stage. The terminal operation decides how many elements are taken, and after it the stream cannot be used again. Each stage takes a lambda or a method reference of the interface it needs.

Written for Java.

Before you begin

Why this matters

Java code often turns a List into a stream, transforms it, and collects a result in one statement. The bugs in that statement are often invisible: a lambda that never runs, a map that crashes on the first repeated name, or a get() that fails only on an empty list. If you can follow the elements through each stage, you can predict these before they happen.

What this helps you reason about

  • Predict which lambdas in a pipeline run, for which elements, and in what order.
  • Read the error messages for a stream used twice, a repeated key in toMap, and get() on an empty Optional, and fix each one.
  • Choose a merge function, a grouping, or an empty-case value that says what the result means.

In JavaScript, an array’s filter and map each run over the whole array at once. A Python generator, a Kotlin sequence, and a C# LINQ query wait until something asks for values. A Java stream waits too, and it has its own rules: the last operation decides how many elements are taken, some stages must see every element first, and a stream works once. Map, filter, and reduce introduced streams. This lesson follows an origami club’s models through longer pipelines: what each stage asks for, how collect builds maps, and what you get back when there is nothing.

Already know this? The short version
  • A terminal operation pulls elements one at a time through every stage. findFirst, anyMatch, and limit stop pulling early, so lambdas before them may not see every element; sorted takes every element before it passes one on.
  • filter takes a Predicate, map a Function, peek and forEach a Consumer, max a Comparator. Fold::model calls the method on the element; this::quick passes the element as the argument; Club::quick in filter fails with “invalid method reference … found in unbound lookup”.
  • A Stream works once: any second operation on the same object throws IllegalStateException: stream has already been operated upon or closed. Make a new stream from the List, collect a received Stream into a List first, or pass a Supplier<Stream<T>>.
  • toMap without a merge function throws IllegalStateException: Duplicate key k (attempted merging values a and b); groupingBy gives a HashMap of Lists, or of counts with Collectors.counting(). findFirst and max give an Optional, average an OptionalDouble; get() on an empty one throws NoSuchElementException: No value present, and orElse computes its argument even when a value is present.
  • The section “What goes wrong with streams in real programs?” covers effects in map or peek that never run, forEach filling an outside list, adding to a toList() result, and toMap on real data.

Go to the final check· Go to the practice tickets

Words in this lesson (8)
stream pipeline
A chain of operations on a java.util.stream.Stream: list.stream() starts it, operations such as map and filter describe the work, and one terminal operation, such as collect, runs it. A Stream can be used only once.
intermediate operation
A stream operation, such as map, filter, peek, or sorted, that returns a new Stream and runs nothing by itself.
terminal operation
A stream operation, such as collect, count, forEach, or findFirst, that runs the pipeline and gives a result. After it, the Stream cannot be used again.
short-circuiting operation
A stream operation, such as findFirst, anyMatch, or limit, that can finish without taking every element.
functional interface
An interface with exactly one abstract method, such as IntUnaryOperator with applyAsInt. A lambda or a method reference can be used wherever one is expected.
method reference
An expression such as Main::addBonus that turns an existing method into a value of a functional interface, without calling the method.
collector
An object that tells collect how to gather the elements into a result, such as Collectors.toMap or Collectors.groupingBy.
Optional
A class from java.util whose object holds one value or none. Optional.ofNullable(x) holds x, or nothing when x is null, and orElse(d) gives the value, or d when there is none.

Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.

01 / When it runs

When does a stream pipeline run, and in what order?

Map, filter, and reduce showed that map and filter run nothing until a terminal operation, such as collect, asks for results, and that each element then goes through every stage before the next one starts. Picture the terminal operation pulling one element at a time through the pipeline. Two things follow from that model.

First, a terminal operation can stop pulling. findFirst() returns the first element that reaches it, inside an Optional, and then asks for nothing more; orElse("none") takes the element out, or gives "none". An operation that can finish without taking every element is short-circuiting. findFirst, findAny, anyMatch, allMatch, noneMatch, and limit are short-circuiting.

Second, some intermediate operations must see every element before they pass any on. sorted() is one: it cannot know the smallest element until it has seen them all. So a stage before sorted runs for every element, even when a later findFirst stops early.

The code below uses peek, an intermediate operation that runs a lambda on each element as it passes and gives the element on unchanged. The documentation says peek exists mainly for debugging, and here it shows which elements are taken.

Main.java
List<String> models = List.of("frog", "crane", "boat", "star");
String found = models.stream()
        .peek(m -> System.out.println("look " + m))
        .filter(m -> m.length() > 4)
        .findFirst()
        .orElse("none");
// look frog, look crane, then found crane
System.out.println("found " + found);

A terminal operation pulls elements one at a time; findFirst stops pulling when it has an answer, and sorted takes every element before it passes one on.

Coming from JavaScript: arr.filter(…).map(…) runs filter on every element before map starts, and builds a whole array in between. A Java stream takes one element through both stages before it takes the next, and builds nothing in between.

Coming from Kotlin: A Java stream behaves like a Kotlin Sequence, not like a Kotlin List: operations wait, and the terminal operation runs them one element at a time.

Precisely: stateful operations and parallel streams

The java.util.stream documentation calls sorted and distinct stateful: they keep information about elements they have already seen. distinct(), which drops repeated elements, passes each new element on at once, so it does not wait like sorted. The element-by-element order shown here is what a sequential stream does. With parallel(), elements are processed on several threads at once, and the order in which lambdas run is not promised. Iterators, generators, and laziness used the same pulling with Stream.generate and limit.

Experiment

Watch findFirst stop after the second model

findFirst asks the pipeline for one element at a time. Each element goes through peek and filter before the next one is taken. When crane passes the filter, findFirst has its answer and asks for nothing more. sorted cannot give any element on until it has seen all of them, so it takes all four from peek first, and then passes them on in sorted order.

Why it matters A lambda before a short-circuiting operation may run for only some elements. A lambda before sorted runs for every element, even when the pipeline then stops early.

Predict which models are looked at, and what prints. Then add the line .sorted() between the peek line and the filter line, and predict again.

import java.util.List;

class Main {
    public static void main(String[] args) {
        List<String> models = List.of("frog", "crane", "boat", "star");
        String found = models.stream()
                .peek(m -> System.out.println("look " + m))
                .filter(m -> m.length() > 4)
                .findFirst()
                .orElse("none");
        System.out.println("found " + found);
    }
}
What to look for

The first stops are on lines 6 to 9 of main: building the pipeline runs no lambda. Then a frame named Main.lambda in main appears on line 7, with m "frog". The stepper names a lambda’s frame after the class and the method it was written in. main waits on line 9, the findFirst line. The next stop is line 8, filter’s lambda, with the same m, and look frog is now in the output. Then lines 7 and 8 run again for "crane". After crane, no lambda runs again: boat and star are never looked at. found shows "crane", and the run prints look frog, look crane, found crane. After the edit, the peek frame on line 7 appears four times in a row, for frog, crane, boat, and star. Only then does the filter frame, now on line 9, appear: for "boat", then "crane". The run prints look for all four models, then found crane.

Expected output

look frog
look crane
found crane

02 / Lambdas and ::

Which function does each stream operation take?

Java lambdas and effectively final locals introduced the functional interfaces in java.util.function and method references. Each stream operation asks for one of those interfaces:

  • filter takes a Predicate<T>, whose test returns a boolean.
  • map takes a Function<T, R>, whose apply turns a T into an R.
  • peek and forEach take a Consumer<T>, whose accept returns nothing.
  • max and sorted can take a Comparator<T>, such as Comparator.comparing(Fold::minutes).

T is the element type of the stream. In a Stream<Fold>, filter’s lambda receives a Fold. Fold is a record, record Fold(String model, int minutes) {}, so f.minutes() reads a field. A lambda with one parameter needs no brackets, f -> f.minutes() <= 15. Two parameters need them, (a, b) -> a + b. A lambda with several statements puts them in braces and ends with return.

A method reference can stand in for a lambda in three ways that streams use often. Ask where the element goes:

  • Fold::model and String::length call the method on the element: the element is the object.
  • this::quick calls quick on this object: the element is the argument.
  • Integer::sum calls a static method: Integer.sum(a, b) returns a + b, so both values are arguments.

Club::quick names no object, so in filter it would need a Club and a Fold, and filter passes one value. javac reports:

error: incompatible types: invalid method reference

with a second line, unexpected instance method quick(Fold) found in unbound lookup. Use this::quick, or a lambda, f -> quick(f).

Main.java
List<String> forBeginners(List<Fold> folds) {
    return folds.stream()
            // Predicate<Fold>: the Fold is the argument
            .filter(this::quick)
            // Function<Fold, String>: the Fold is the object
            .map(Fold::model)
            .toList();
}

Each operation takes one functional interface; a method reference fits when its method can take the element as the object or as the argument.

Coming from C#: LINQ’s Where and Select take Func delegates of any shape. Java’s filter and map each take one named interface, and a method reference must fit it exactly.

Precisely: what javac makes of each form

javac turns a lambda body into a private method of the class around it, such as lambda$forBeginners$0; the stepper shortens that name to Club.lambda in forBeginners. A method reference compiles to a call of the method itself, so it adds no frame of its own. JLS 15.13 describes the forms of method reference, and an unbound one, Type::method, takes the object as its first argument. Choose whichever reads more clearly; both run the same code.

Experiment

Follow this::quick and Fold::model into their methods

this::quick names the object, this, so filter only has to pass the Fold as the argument: it is a Predicate<Fold>. Fold::model names no object, so the Fold that map passes becomes the object model is called on. Club::quick also names no object, so it would need a Club and a Fold, two values, but filter passes one.

Why it matters Read a method reference by asking where the element goes: as the argument (this::quick, Integer::sum) or as the object the method is called on (Fold::model, String::length).

Predict which models are printed, and which frames lie between forBeginners and quick. Then change this::quick to Club::quick and predict whether the program compiles.

import java.util.List;

record Fold(String model, int minutes) {}

class Club {
    int limit = 15;

    boolean quick(Fold f) {
        return f.minutes() <= limit;
    }

    List<String> forBeginners(List<Fold> folds) {
        return folds.stream()
                .filter(this::quick)
                .map(Fold::model)
                .toList();
    }
}

class Main {
    public static void main(String[] args) {
        List<Fold> folds = List.of(new Fold("frog", 9),
                new Fold("crane", 22), new Fold("boat", 6));
        Club club = new Club();
        System.out.println(club.forBeginners(folds));
    }
}
What to look for

The first stops after main are in Fold.<init>, the constructor that Java writes for the record, on line 3. There this shows {model: null, minutes: 0}, because the constructor has not set the fields yet. In forBeginners, the stepper stops on lines 13 to 16 and calls nothing. Then Club.quick appears directly on top of Club.forBeginners, with f the frog Fold and this the Club {limit: 15}. There is no lambda frame between them: a method reference makes no frame of its own. Fold.minutes runs inside quick. For the frog, Fold.model follows at once, called by map. For the crane, 22 minutes, Fold.model never runs. The run prints [frog, boat]. After the edit, the program does not compile. javac reports error: incompatible types: invalid method reference, and below it: unexpected instance method quick(Fold) found in unbound lookup.

Expected output

[frog, boat]

03 / Single use

Why can a stream be used only once?

Map, filter, and reduce showed the error that a second terminal operation on the same Stream throws:

java.lang.IllegalStateException: stream has already been operated upon or closed

A Stream is not a collection. It is one pass over a source, such as a List. A terminal operation uses up the Stream, and the Stream records that. An intermediate operation also uses the Stream it is called on: after s.map(…), a call to s.filter(…) on the same s throws the same exception.

The second use is easy to see when both calls are next to each other. It is harder to see when one Stream is passed to two methods, kept in a field, or returned by a method and then used twice. There are three fixes, and each fits a different case:

  • When you have the List, call models.stream() again for each pass. Making a stream is cheap.
  • When you receive only a Stream, for example from a method that reads a file, collect it once with toList(), and make a new stream from that List for each question.
  • When code must start a pass later, give it a Supplier<Stream<T>>, such as () -> models.stream(); each get() makes a new stream.
Main.java
List<String> models = List.of("frog", "crane", "boat");
Stream<String> names = models.stream();
System.out.println(count(names)); // 3
// throws: names was used up inside count
System.out.println(first(names));
// works: a new stream over the same List
System.out.println(first(models.stream()));

A Stream is one pass: give each pass its own stream from the List, and collect a Stream you receive before you use it twice.

Coming from Python: A used-up generator gives nothing on a second loop, and no error. A used Java Stream throws IllegalStateException at once.

Experiment

Pass one stream to two methods

count is a terminal operation, so it uses up the Stream it is called on, and the Stream records that. Any later operation on the same object throws IllegalStateException. models.stream() makes a new Stream over the same List, so the second question gets its own pass.

Why it matters A Stream is one pass. When two pieces of code need the elements, give each one its own stream from the List, not the same Stream object.

Predict both output lines. Then, in the second call, change first(names) to first(models.stream()) and predict again.

import java.util.List;
import java.util.stream.Stream;

class Main {
    static long count(Stream<String> models) {
        return models.count();
    }

    static String first(Stream<String> models) {
        return models.findFirst().orElse("none");
    }

    public static void main(String[] args) {
        List<String> models = List.of("frog", "crane", "boat");
        Stream<String> names = models.stream();
        System.out.println(count(names));
        try {
            System.out.println(first(names));
        } catch (IllegalStateException e) {
            System.out.println(e.getMessage());
        }
    }
}
What to look for

names shows java.util.stream.ReferencePipeline$Head@ and an id, then the stream’s internal fields. The panel lists sourceStage first; look further along for linkedOrConsumed. It is false while the stepper is inside count. Back on line 18, the same object shows linkedOrConsumed: true and sourceSpliterator: null: the stream has been used, and it no longer holds a way to read the list. first receives the object with the same id, findFirst throws, and the run prints 3, then stream has already been operated upon or closed. In the catch block, e shows java.lang.IllegalStateException@… with internal fields such as backtrace; only its message matters here. After the edit, first receives a stream with a new id, whose linkedOrConsumed is false, and the run prints 3, then frog.

Expected output

3
stream has already been operated upon or closed

04 / Collectors

What does collect build, and what happens to repeated keys?

collect gathers the elements into a result, and a collector says how. The Collectors class has the common ones.

toList(), a method of Stream itself since Java 16, gives a List that cannot be changed, as Java collections and unmodifiable views showed. collect(Collectors.toList()) gives a List but promises nothing about whether it can be changed.

Collectors.toMap(Fold::model, Fold::minutes) makes one map entry per element: the first function gives the key, the second the value. If two elements give the same key, toMap throws:

java.lang.IllegalStateException: Duplicate key crane (attempted merging values 20 and 15)

A third argument, the merge function, decides what to keep. toMap calls it with the value already stored and the new one, and stores what it returns. Integer::sum adds them. (old, next) -> next keeps the last value, and (old, next) -> old keeps the first. Choose by what the map means; a total needs the sum.

Collectors.groupingBy(Fold::model) makes a Map from each key to a List of the elements with that key. A second collector changes what is kept for each key: groupingBy(Fold::model, Collectors.counting()) keeps the number of elements, as a Long. Both toMap and groupingBy return a HashMap, whose order is not promised; new TreeMap<>(map) gives the same entries sorted by key. Collectors.joining(", ") joins a stream of Strings into one String with ", " between them.

Main.java
List<Fold> week = List.of(new Fold("crane", 20),
        new Fold("frog", 8), new Fold("crane", 15));
Map<String, Integer> minutes = week.stream()
        .collect(Collectors.toMap(Fold::model, Fold::minutes,
                Integer::sum)); // {crane=35, frog=8}
Map<String, Long> folds = week.stream()
        .collect(Collectors.groupingBy(Fold::model,
                Collectors.counting())); // {crane=2, frog=1}

toMap needs a merge function whenever two elements can share a key; groupingBy keeps every element, or a count, for each key.

Precisely: other map types and null

toMap has a fourth argument and groupingBy a middle one that choose the kind of map, such as TreeMap::new. toMap also refuses a null value: the value function must not return null, or toMap throws NullPointerException. The Collectors documentation lists both rules.

Experiment

Collect two crane folds into one map

toMap puts one entry in the map for each element. With two arguments, it has no rule for a key it has already stored, so it throws and names the key and both values. The third argument is the merge function: toMap calls it with the stored value and the new one, and stores what it returns.

Why it matters Before you use toMap, ask whether two elements can share a key. If they can, choose the merge function that the result needs: a sum, the first value, or the last.

The week has two folds of the crane. Predict what prints. Then change toMap(Fold::model, Fold::minutes) to toMap(Fold::model, Fold::minutes, Integer::sum) and predict again.

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

record Fold(String model, int minutes) {}

class Main {
    public static void main(String[] args) {
        List<Fold> week = List.of(new Fold("crane", 20),
                new Fold("frog", 8), new Fold("crane", 15));
        try {
            Map<String, Integer> total = week.stream()
                    .collect(Collectors.toMap(Fold::model, Fold::minutes));
            System.out.println(total);
        } catch (IllegalStateException e) {
            System.out.println(e.getMessage());
        }
    }
}
What to look for

After the collect line, the stepper enters Fold.model and then Fold.minutes once for each fold, in order: crane, frog, crane. After the second crane, the next stop is in the catch block, and the run prints Duplicate key crane (attempted merging values 20 and 15). After the edit, the same six stops happen. Integer::sum makes no stop, because it is library code. total shows java.util.HashMap@… with "crane" and "frog" as keys; each value shows as an Integer object, such as java.lang.Integer@… {value: 35}. The run prints {crane=35, frog=8}.

Expected output

Duplicate key crane (attempted merging values 20 and 15)

05 / Empty results

What do findFirst, max, and average give for no elements?

findFirst, findAny, min, and max give their answer inside an Optional, because there may be no answer: a stream with no elements has no first or largest element. An empty Optional holds nothing. get() on an empty Optional throws:

java.util.NoSuchElementException: No value present

So say what the empty case means instead of calling get(). map(Fold::model) turns an Optional<Fold> into an Optional<String>, and stays empty when it was empty. orElse("none") then gives the value, or "none". orElseThrow(() -> new IllegalStateException("no folds this week")) throws an exception with your own message.

orElse and orElseGet differ in when their default is made. orElse(makeDefault()) calls makeDefault() before orElse runs, every time, even when the Optional has a value: Java computes each argument before a call. orElseGet(() -> makeDefault()) takes a Supplier and calls it only when the Optional is empty. Use orElseGet when making the default costs time or changes something.

Numbers have their own streams. mapToInt(Fold::minutes) gives an IntStream of int values, with no Integer objects. sum() returns an int, 0 for no elements. average() returns an OptionalDouble, because there is no average of nothing. Printed, an empty one shows OptionalDouble.empty; getAsDouble() on it throws like get(), and orElse(0) gives 0.0.

Main.java
Optional<Fold> best = folds.stream()
        .max(Comparator.comparing(Fold::minutes));
String name = best.map(Fold::model).orElse("none");
int total = folds.stream().mapToInt(Fold::minutes).sum();
OptionalDouble mean = folds.stream()
        .mapToInt(Fold::minutes)
        .average(); // OptionalDouble.empty for no folds

findFirst and max give an Optional and average an OptionalDouble; say what “no elements” means with orElse or orElseGet instead of get().

Coming from Python: max([]) raises ValueError. A Java stream’s max returns an empty Optional, and the exception comes later, from get().

Precisely: OptionalInt, and ties

IntStream’s max and min return OptionalInt, with getAsInt. When several elements are equally large, Stream.max with a Comparator returns one of them; the documentation does not promise which. Sort with a second key, or use reduce with your own rule, when ties must be decided.

Experiment

Ask an empty week for its slowest fold

max returns an Optional because a list may have no largest element. get() gives the value or throws NoSuchElementException. map(Fold::model) turns an Optional<Fold> into an Optional<String> and does nothing when it is empty; orElse then gives the text or "none".

Why it matters Never call get() on an Optional you have not checked. Say what the empty case means with orElse, orElseGet, or orElseThrow with your own message.

Predict both lines. Then change return best.get().model(); to return best.map(Fold::model).orElse("none"); and predict again.

import java.util.Comparator;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;

record Fold(String model, int minutes) {}

class Main {
    static String slowest(List<Fold> folds) {
        Optional<Fold> best = folds.stream()
                .max(Comparator.comparing(Fold::minutes));
        return best.get().model();
    }

    public static void main(String[] args) {
        System.out.println(slowest(List.of(new Fold("frog", 8),
                new Fold("crane", 20))));
        try {
            System.out.println(slowest(List.of()));
        } catch (NoSuchElementException e) {
            System.out.println(e.getMessage());
        }
    }
}
What to look for

In the first call, max calls Fold.minutes to compare the two folds, and best then shows java.util.Optional@… {value: Fold@… {model: "crane", minutes: 20}}. In the second call, the list is empty, no Fold method runs, and best shows java.util.Optional@… {value: null}: an empty Optional. get() throws, and the catch block prints No value present. The run prints crane, then No value present. After the edit, best shows the same two Optionals. The second call returns "none" without an exception, and the run prints crane, then none.

Expected output

crane
No value present

06 / In production

What goes wrong with streams in real programs?

Each bug below follows from a rule in this lesson.

An effect in map or peek that never runs. Since Java 9, count() may return the size of the source without running the pipeline, when map and peek cannot change the number of elements. Code that adds to a list inside map or peek works while the pipeline ends in collect, and silently stops when someone changes the end to count(). Collect the results, then act on them.

forEach filling an outside list. stream.forEach(f -> result.add(f.model())) gives the right list on one thread. If someone adds parallel(), several threads add to one ArrayList, which is not safe, and elements can be lost. collect or toList() builds the result safely in both cases.

Adding to the result of toList(). The List cannot be changed, so a later add throws UnsupportedOperationException, often far from the stream. When the caller must add, collect with Collectors.toCollection(ArrayList::new).

toMap on real data. Tests use unique names, and the first week with a repeated name crashes with Duplicate key. Decide on the merge function when you write the toMap, not after the crash.

Main.java
// may never add anything: count() can skip map
long made = models.stream()
        .map(m -> { labels.add(m); return m; })
        .count();
// builds the list in the pipeline itself
List<String> names = models.stream()
        .map(m -> m.toUpperCase())
        .toList();

Keep effects out of map and peek, build results with collect or toList(), copy a toList() result before adding, and choose toMap’s merge function at the start.

Experiment

Count labels that are never made

The List knows its size, and map and peek do not change the number of elements. So count() returns the size of the List without running the pipeline, as the Stream documentation allows since Java 9. toList() needs every element, so it runs both lambdas for each model.

Why it matters Do not put work that must happen inside map or peek. Collect the results first, then act on them.

map and peek each have a lambda. Predict the number and the list. Then replace .count() with .toList().size() and predict again.

import java.util.ArrayList;
import java.util.List;

class Main {
    public static void main(String[] args) {
        List<String> models = List.of("frog", "crane", "boat");
        List<String> labels = new ArrayList<>();
        long made = models.stream()
                .map(m -> m.toUpperCase())
                .peek(label -> labels.add(label))
                .count();
        System.out.println(made + " " + labels);
    }
}
What to look for

main stops on lines 8 to 11, and then made shows 3 on line 12. No frame named Main.lambda in main ever appears: neither map’s lambda nor peek’s lambda ran. labels stays java.util.ArrayList@… [], and the run prints 3 []. After the edit, lambda frames alternate between line 9, with m "frog", and line 10, with label "FROG". Inside the peek lambda, the panel lists the captured labels under its own name. labels grows to ["FROG", "CRANE", "BOAT"], and the run prints 3 [FROG, CRANE, BOAT].

Expected output

3 []

07 / Try it

Can you predict what a club night’s pipelines print?

One list of a club night’s folds, and four questions about it. Decide which elements each pipeline keeps and what each collector or IntStream gives back.

Main.java
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

record Fold(String model, int minutes) {}

class Main {
    public static void main(String[] args) {
        List<Fold> night = List.of(new Fold("crane", 20),
                new Fold("frog", 8), new Fold("crane", 15),
                new Fold("star", 30));
        Map<String, Long> made = new TreeMap<>(night.stream()
                .collect(Collectors.groupingBy(Fold::model,
                        Collectors.counting())));
        String slow = night.stream()
                .filter(f -> f.minutes() > 10)
                .map(Fold::model)
                .distinct()
                .collect(Collectors.joining(", "));
        int total = night.stream().mapToInt(Fold::minutes).sum();
        System.out.println(made);
        System.out.println(slow);
        System.out.println(total + " " + night.stream()
                .filter(f -> f.model().equals("boat"))
                .mapToInt(Fold::minutes)
                .average());
    }
}

What are the three output lines?

Reveal the reasoning

{crane=2, frog=1, star=1}, then crane, star, then 73 OptionalDouble.empty. groupingBy with counting() counts the folds of each model, and the TreeMap sorts the keys. filter keeps crane 20, crane 15, and star 30; map gives crane, crane, star; distinct drops the second crane; joining puts ", " between the names. The four streams are four separate passes, each from night.stream(), so none of them is used twice. mapToInt and sum add 20 + 8 + 15 + 30 = 73. No fold is a boat, so average() has no elements and gives an empty OptionalDouble, not 0.

Practice tickets

  1. JAVA-601 · The weigh-in board shows only the last fish of each species → Free
  2. JAVA-602 · The weekend summary screen fails for every weekend → Sign in · Pro
  3. JAVA-603 · Some drone batteries never appear in the check log → Sign in · Pro

Keep exploring

Errors as values or exceptions →

Primary references