Grokking Spring Boot
Vote

0% completed

​

Streams

What a Stream Is

The Core Operations

Grouping Values

Laziness: A Stream Does Only the Work Its Result Needs

When a Loop Reads Better

Key Takeaways

Practice Questions

Event 7 at Riverside Hall has 600 seats. In the program for this lesson, 545 of them are free, and the 55 taken seats are worth 247,500 cents, which is 2,475 dollars. The taken seats fall into three rows: 30 in row A, 20 in row B, and 5 in row C.

The count of free seats, the total in cents, and the counts per row come from one short statement each, and none of these statements contains a loop. They are streams, the part of Java that processes a collection as a chain of small steps. The sections below build those chains and show why they do less work than they appear to. They also name the cases where a plain loop is still the better choice.

What a Stream Is

A stream is a sequence of values that passes through a chain of operations, where each operation receives the values that the step before it let through. A chain, called a pipeline, has three parts:

  1. A source. Calling stream() on a list, such as seats.stream(), starts a stream of its elements.
  2. Intermediate operations. Steps such as filter and map each return a new stream, so they can be chained.
  3. A terminal operation. A final step such as count or toList produces a result and ends the stream.

The count of free seats is the shortest example:

long free = seats.stream() .filter(s -> !s.taken()) .count();

Each operation takes a lambda or a method reference, the subject of Lambdas and Functional Interfaces. The filter receives a Predicate<Seat>, and map receives a Function. A stream never changes its source. The list of 600 seats is the same after the statement as before it.

The Core Operations

A small set of operations covers most stream code: filter keeps some values, map turns each value into another, and a terminal operation collects or counts the result. This statement finds the first 12 free seats in row C as labels:

List<String> firstFreeInC = seats.stream() .filter(s -> s.row() == 'C') .filter(s -> !s.taken()) .map(Seat::label) .limit(12) .toList();
Seats flow from the list through two filters, a map to labels, and a limit, into a list of 12 labels.
Seats flow from the list through two filters, a map to labels, and a limit, into a list of 12 labels.

Notice that the type changes partway through the chain. Before map, the stream holds Seat objects. After it, the stream holds String labels, so limit and toList work on labels.

These operations appear in almost every service:

OperationKindWhat it doesExample
filterIntermediateKeeps values that pass a testFree seats only
mapIntermediateTurns each value into anotherSeat to label
mapToIntIntermediateTurns each value into an int, for sumsSeat to price in cents
limitIntermediateStops after n valuesThe first 12
toListTerminalCollects into a listLabels for a response
count, sumTerminalProduces one numberFree seats, total cents
findFirstTerminalReturns the first value as an OptionalThe first free seat

The list from toList cannot be changed. The Java documentation says that every method that would modify it throws UnsupportedOperationException. In the booking service, this shape appears whenever stored records become response objects for the API.

Grouping Values

The collector groupingBy puts values into groups by a key, and it can then summarize each group. Counting the taken seats in each row uses all three of its arguments:

Map<Character, Long> takenPerRow = seats.stream() .filter(Seat::taken) .collect(Collectors.groupingBy(Seat::row, TreeMap::new, Collectors.counting()));

The first argument picks the key, which is the row letter. The second creates the map that holds the groups. The third decides what each group holds, here a count instead of a list of seats.

The second argument matters more than it looks. Without it, the Java documentation makes no guarantee about the type of map returned, so the rows may print in any order. A TreeMap keeps its keys sorted, so the result always reads {A=30, B=20, C=5}.

The program below runs these statements on all 600 seats and adds three more parts: a lazy search, a stream used twice, and a loop.

Java
Java

. . . .

It prints the following:

Seats at event 7: 600 1. filter and count free seats: 545 2. filter, map, limit, collect first 12 free seats in row C: [C1, C2, C3, C4, C5, C6, C7, C8, C9, C10, C16, C17] 3. mapToInt and sum value of taken seats: 247500 cents 4. groupingBy taken seats per row: {A=30, B=20, C=5} 5. Streams are lazy: findFirst stops early first free seat: B21, seats checked: 51 6. A stream can be used once seats in row B: 30 second use: stream has already been operated upon or closed 7. A loop: two free seats side by side in row B first pair: B21 and B22

Laziness: A Stream Does Only the Work Its Result Needs

Intermediate operations do not run when they are written. They run only when the terminal operation asks for values, and only for as many values as it needs. The Java documentation states it directly: streams are lazy, and source elements are consumed only as needed.

Part 5 makes the laziness visible. The filter counts every seat it checks, and findFirst asks for one free seat. Seats A1 to A30 are taken, and so are B1 to B20, so the filter checks 51 seats and stops at B21. The other 549 seats are never read.

findFirst pulls seats one at a time through the filter and stops at B21 after 51 checks.
findFirst pulls seats one at a time through the filter and stops at B21 after 51 checks.

Notice that the filter tests the seats one at a time, and its counter reaches 51 at seat B21, the only seat that travels on through map to findFirst. The gray area stands for the 549 seats that were never read, because findFirst stopped asking after its first match.

A stream also has a rule about reuse, shown in part 6. A stream can be used only once, as the documentation also states. The second count on rowB fails with "stream has already been operated upon or closed". To ask a second question, create a new stream from the list.

When a Loop Reads Better

Streams handle each value on its own, so a task that compares neighboring values is usually clearer as a loop. Part 7 looks for two free seats side by side in row B, for Ana and Ben. Each seat must be checked together with the next one. A loop with an index expresses that in one condition.

A loop is also the better choice in three other cases:

  • A changing local variable. A lambda cannot change a local variable, so a counter needs a trick such as the one-element array in part 5. That trick is acceptable in a demonstration, but service code should avoid it.
  • Checked exceptions. A step that throws a checked exception must catch it inside the lambda, which hides the main logic.
  • Several results at once. A loop can count the free seats and the taken seats in one pass, while one stream produces one result.
TaskClearer as
Filter, transform, collectStream
Count, sum, group by a keyStream
Compare each value with its neighborLoop
Update several variables in one passLoop

Key Takeaways

  • A stream pipeline has a source, intermediate operations, and one terminal operation, and it never changes its source.
  • The operations filter, map, toList, count, and sum cover most code, and the list from toList cannot be changed.
  • The collector groupingBy takes a key, a map factory, and a summary. Pass TreeMap::new when the order of keys matters.
  • Streams are lazy: findFirst stopped after 51 of 600 seats, and a stream can be used only once.
  • Use a loop when values depend on their neighbors or when one pass must update several variables.

The search for the first free seat ended with orElse, because a search may find nothing. Optional explains the type that findFirst returns and how repositories use it.

Practice Questions

1. A developer writes seats.stream().filter(s -> { System.out.println(s.label()); return !s.taken(); }); and runs the program. Nothing is printed. Why?

<details><summary>Show answer</summary>

The statement has no terminal operation, so the filter never runs. Intermediate operations only describe the work. They run when a terminal operation such as count or toList asks for values, and a pipeline without one asks for nothing.

</details>

2. In part 5, findFirst checked 51 seats. How many seats would the filter check if findFirst were replaced by count, and why?

<details><summary>Show answer</summary>

All 600. To count the free seats, count must see every seat, so it pulls every value through the filter. findFirst needs only one matching value and stops as soon as it has it. Laziness saves work only when the terminal operation can finish early.

</details>

3. A report groups bookings by event title with groupingBy(title, counting()), and the order of events in the output changes between versions of the service. What is wrong, and how is it fixed?

<details><summary>Show answer</summary>

The two-argument groupingBy makes no promise about the type of map, so the order of keys is not defined. Passing TreeMap::new as the map factory sorts the keys, so the report always lists events by title. If the report needs another order, such as by date, the code should sort explicitly instead of relying on a map.

</details>

4. The app must offer a family the first block of four free seats side by side in one row. Would you write this as a stream or as a loop?

<details><summary>Show answer</summary>

As a loop. Each candidate depends on its three neighbors, and streams process each value on its own. A loop over the seats of a row can keep a count of free neighbors that resets at each taken seat. It states the rule directly and stops at the first match.

</details>

Reading Progress

0%


Vote for new content

On This Page

What a Stream Is

The Core Operations

Grouping Values

Laziness: A Stream Does Only the Work Its Result Needs

When a Loop Reads Better

Key Takeaways

Practice Questions