The Streams API

Course 2 · Ch 4
The Streams API
Chainable, lazy data pipelines — Java's closest parallel to Rust's own iterator adapters

java2-3's functional interfaces weren't just for standalone use — they're the building blocks Streams are made of. This chapter puts Function, Predicate, and method references to work in a real pipeline.

What a Stream Is

A Stream is not a collection — it stores nothing. It's a one-shot pipeline over a data source (a List, an array, a file), describing a sequence of operations to run over that source's elements.

Building a Pipeline — map/filter/reduce

List<String> names = List.of("Alice", "Bo", "Charlotte", "Sam"); List<String> result = names.stream() // source .filter(n -> n.length() > 3) // intermediate op .map(String::toUpperCase) // intermediate op — java2-3's method reference .collect(Collectors.toList()); // terminal op — nothing has run until this line

.stream() opens a pipeline from a source; filter and map are intermediate operations that describe transformations without executing them; collect is a terminal operation that finally runs the whole pipeline and produces a result.

Lazy Evaluation — The Genuine Surprise

names.stream() .filter(n -> { System.out.println("Checking: " + n); // this print never runs — yet return n.length() > 3; }); // nothing printed at all — no terminal operation was ever called

Intermediate operations don't run when they're written — they only run once a terminal operation triggers the whole pipeline. Building the pipeline above prints nothing at all, because filter alone is never enough to force evaluation.

Comparison to C++'s STL Algorithms and Rust's Iterator Adapters

cpp2-3's STL algorithms — std::sort, std::find — are standalone free functions operating on an iterator range; they don't chain into a pipeline the way Streams do. Rust's iterator adapters are the genuinely closest parallel on this site: .map().filter().collect() chains exactly the way a Java Stream does, and Rust's own iterators are lazy in the identical sense — an adapter chain does nothing until something actually consumes it, like collect() or a for loop.

Streams Can Only Be Consumed Once

Stream<String> s = names.stream(); s.forEach(System.out::println); s.forEach(System.out::println); // IllegalStateException — this stream has already been operated upon

A stream is a single-use pipeline. Once a terminal operation runs, that stream instance is spent — calling a second terminal operation on the same stream throws IllegalStateException at runtime, not a compile error.

AspectC++ STL algorithms (cpp2-3)Rust iterator adaptersJava Streams
Shapestandalone free functionschainable adapter methodschainable pipeline methods
Lazinessruns immediately when calledlazy until consumedlazy until a terminal op runs
Reusable after usen/a — operates on a range directlyiterator itself is consumedno — single-use, throws if reused
Prefer a method reference over a lambda that just calls one method
.map(String::toUpperCase) reads more directly than .map(s -> s.toUpperCase()) for the same result — reach for the method-reference form from java2-3 whenever a stream operation is nothing but a single existing-method call.
A stream cannot be reused after its terminal operation runs
Store the source collection, not the stream, if the same data needs to be processed more than once — call .stream() again from the source each time, since the stream object itself is genuinely single-use.

Coding Challenges

Challenge 1

Given a List<Integer> of numbers, use a stream pipeline to filter out odd numbers, square the remaining ones, and collect the result into a new List, using method references where possible.

📄 View solution
Challenge 2

Build a stream pipeline with a filter lambda that prints a message as a side effect, but don't call any terminal operation. Run the program and explain in a comment why nothing prints.

📄 View solution
Challenge 3

Create a Stream, call a terminal operation on it (e.g. forEach), then call a second terminal operation on the same stream object. Show the resulting IllegalStateException and explain why it happens.

📄 View solution

Chapter 4 Quick Reference

  • A Stream is a one-shot pipeline over a data source, not a collection itself
  • Intermediate operations (map, filter) are lazy — nothing runs until a terminal operation (collect, forEach, reduce) triggers the pipeline
  • Closest parallel on this site: Rust's own lazy, chainable iterator adapters — C++'s STL algorithms (cpp2-3) run immediately and don't chain
  • A stream is single-use — a second terminal operation on the same stream throws IllegalStateException
  • Method references (java2-3) read cleanly inside stream pipelines whenever an operation is just an existing method call
  • Next chapter: concurrency in Java — Thread, Runnable, and java.util.concurrent