Skip to content
Part IA Michaelmas Term

External versus Internal Iteration

External iteration

In traditional Java (pre-streams), iteration is external: the caller explicitly drives each step — “get the next element, process it, repeat.” The caller interleaves what to do with how to iterate.

List<String> names = List.of("Alice", "Bob", "Charlie", "Diana");
List<String> result = new ArrayList<>();
for (String name : names) {
    if (name.length() > 3) {
        result.add(name.toUpperCase());
    }
}

The caller manages:

  • The iteration variable (name)
  • The loop mechanics (enhanced for-each)
  • The accumulator (result)
  • The ordering of operations
  • The flow control (if-check inside loop)

Problems with external iteration:

  1. What and how are intertwined — the filtering logic (length > 3) and the transformation logic (toUpperCase()) are mixed with the iteration mechanics (for, add).
  2. No optimisation opportunity — the loop runs sequentially, element by element. The library cannot fuse operations or parallelise.
  3. Mutable accumulator — the result list must be created and mutated. This can be error-prone and is not thread-safe.
  4. Sequential by nature — switching to parallel execution requires rewriting the entire block.

Internal iteration

With streams, the caller declares what transformation or aggregation they want; the library drives iteration internally.

List<String> result = names.stream()
    .filter(name -> name.length() > 3)
    .map(String::toUpperCase)
    .collect(Collectors.toList());

The caller specifies:

  • What to keep (filter with a predicate)
  • What to transform (map with a function)
  • What to produce (collect into a list)

The library handles:

  • Iteration order
  • Element-by-element traversal
  • Accumulator management
  • Potential optimisation

Key benefits of internal iteration

1. Laziness

Streams are lazy — intermediate operations like filter and map do not execute immediately. They build a pipeline plan. No data flows until a terminal operation (collect, forEach, count) triggers execution. This enables optimisation across the whole pipeline.

2. Potential parallelism

The same stream pipeline can run in parallel with a single method change:

// Sequential
List<String> result = names.stream()
    .filter(...).map(...).collect(...);

// Parallel — same pipeline, different execution
List<String> result = names.parallelStream()
    .filter(...).map(...).collect(...);

The library splits the source collection into substreams, processes them in the fork-join pool, and merges results. This works because the pipeline is a declarative description of what to do, not how to iterate.

3. Operation fusion

The stream library can optimise the pipeline as a whole:

  • Combine filtermap into a single pass over the data.
  • Short-circuit early: findFirst() after filter stops as soon as a match is found.

With external iteration, these optimisations would need to be manually coded into each loop.

What is a Stream?

A Stream<T> represents a lazy, single-use pipeline of data from a source. It is NOT a data structure — it does not store elements; it carries elements from a source through a pipeline of operations.

Key properties:

  • Lazy — nothing happens until a terminal operation.
  • Single-use — after a terminal operation, the stream is exhausted and cannot be reused.
  • Non-mutating — stream operations do not modify the source collection.
  • Potentially infinite — streams can be unbounded (Stream.iterate(), Stream.generate()), relying on short-circuiting operations to terminate.

Stream sources

// Collections
list.stream()
set.stream()

// Arrays
Arrays.stream(array)
IntStream.of(1, 2, 3)

// Ranges (primitive streams)
IntStream.range(0, 100)           // 0 to 99
IntStream.rangeClosed(1, 100)     // 1 to 100

// Files (lines as a stream)
Files.lines(Path.of("file.txt"))

// Infinite generators
Stream.iterate(0, n -> n + 1)             // 0, 1, 2, 3, ...
Stream.generate(Math::random)              // infinite random numbers
Stream.iterate(0, n -> n < 100, n -> n + 2) // finite with predicate (Java 9+)

// From values
Stream.of("a", "b", "c")

Sequential vs parallel streams

collection.stream()         // sequential (default)
collection.parallelStream() // parallel
stream.sequential()         // switch to sequential (only last call before terminal wins)
stream.parallel()           // switch to parallel

You should generally prefer sequential streams unless you have:

  • A CPU-intensive pipeline
  • Stateless, non-interfering operations
  • A sufficiently large dataset (parallelism overhead is significant for small data)
  • No synchronisation or shared mutable state inside operations

Tripos traps

  1. Stream is not a collection — A stream is a pipeline, not a data store. You cannot index into it, get its size without consuming it, or reuse it after a terminal operation.
  2. Source not mutated — Stream operations produce new streams; they do not modify the source. If you need to modify the source, use an explicit loop or a Collection method.
  3. Stateful lambdas — Avoid mutable state in stream operations. A lambda like s -> { counter++; return s; } is not pure and breaks the functional model. It also makes parallelisation unsafe.