Challenge

Predict the interleaving

20 minintermediate110 yrs

Edge cases. You have to reason, and two valid fixes differ.

What this teaches

  • Elements go through the whole pipeline one at a time, not stage by stage
  • A stateful operation buffers and breaks that flow
  • Short-circuiting works only because the terminal operation pulls
  • sorted().findFirst() reads everything; min() does not
  • Operation order is a performance decision, not a style one

Starter

Starter.java
import java.util.*;
import java.util.concurrent.atomic.*;
import java.util.function.*;
import java.util.stream.*;

/**
 * Challenge: six pipelines. Predict every line of output before you run them.
 *
 * This is a prediction exercise, so the value is entirely in doing it in that
 * order. If you run first you will find each result obvious and learn nothing.
 *
 * Write your predictions in the comment under each one, then run.
 */
public class Starter {

    static final List<String> WORDS = List.of("delta", "echo", "alpha", "bravo", "foxtrot");

    /** Traced operations, so you can see exactly what ran. */
    static Predicate<String> filter(String label, Predicate<String> p) {
        return s -> {
            System.out.println("  " + label + " " + s);
            return p.test(s);
        };
    }

    static Function<String, String> map(String label, Function<String, String> f) {
        return s -> {
            System.out.println("    " + label + " " + s);
            return f.apply(s);
        };
    }

    public static void main(String[] args) {

        // ── 1 ────────────────────────────────────────────────────────────
        // PREDICT: how many lines, and in what order?
        System.out.println("1: filter then map then forEach");
        WORDS.stream()
            .filter(filter("filter", s -> s.length() > 4))
            .map(map("map", String::toUpperCase))
            .forEach(s -> System.out.println("      out " + s));

        // ── 2 ────────────────────────────────────────────────────────────
        // PREDICT: does swapping the two operations change the OUTPUT, the
        // amount of WORK, or both?
        System.out.println("2: map then filter");
        WORDS.stream()
            .map(map("map", String::toUpperCase))
            .filter(filter("filter", s -> s.length() > 4))
            .forEach(s -> System.out.println("      out " + s));

        // ── 3 ────────────────────────────────────────────────────────────
        // PREDICT: how many filter lines before the first output?
        System.out.println("3: findFirst");
        WORDS.stream()
            .filter(filter("filter", s -> s.startsWith("a")))
            .findFirst()
            .ifPresent(s -> System.out.println("      out " + s));

        // ── 4 ────────────────────────────────────────────────────────────
        // PREDICT: this one surprises people. How many map lines appear, and
        // where do they appear relative to the sort?
        System.out.println("4: sorted then findFirst");
        WORDS.stream()
            .map(map("map", String::toUpperCase))
            .sorted()
            .findFirst()
            .ifPresent(s -> System.out.println("      out " + s));

        // ── 5 ────────────────────────────────────────────────────────────
        // PREDICT: how many elements does the source produce?
        System.out.println("5: infinite source with limit");
        AtomicInteger produced = new AtomicInteger();
        Stream.iterate(1, n -> n * 2)
            .peek(n -> produced.incrementAndGet())
            .filter(n -> n > 10)
            .limit(3)
            .forEach(n -> System.out.println("      out " + n));
        System.out.println("      source produced " + produced.get() + " elements");

        // ── 6 ────────────────────────────────────────────────────────────
        // PREDICT: does the audit see anything?
        System.out.println("6: peek before count");
        AtomicInteger peeked = new AtomicInteger();
        long n = WORDS.stream().peek(s -> peeked.incrementAndGet()).count();
        System.out.println("      count " + n + ", peek ran " + peeked.get() + " times");

        // TODO 1: for each of the six, write what you predicted and what
        // actually happened. Explain every difference.

        // TODO 2: number 4 reads and maps the whole list to return one
        // element. Say why findFirst cannot short-circuit through sorted, then
        // rewrite it so it does not sort at all.

        // TODO 3: number 2 does more work than number 1 for the same result.
        // Quantify it — count the map lines in each — and state the rule.

        // TODO 4: number 5 terminates over an infinite stream. Say which two
        // properties make that possible, and what happens if you move limit
        // before filter.

        // TODO 5: number 6 is the one that changes with the JDK version. Say
        // what Java 9 changed and what it means for side effects in peek.
    }
}

Run it locally:

cd exercises/java/java8/stream-lazy-evaluation/02-challenge
javac Starter.java -d /tmp/out && java -cp /tmp/out Starter

Hints

  1. Hint 1

    Write the expected output line by line before running anything. Getting it wrong is the point of the exercise.

  2. Hint 2

    Which operations cannot emit their first element until they have seen the last one?

  3. Hint 3

    For the short-circuit cases, count how many elements the source produced rather than how many came out.

  4. Hint 4

    One of the six reads the whole source even though it returns one element. Find it and write the version that does not.

Done when

  • You wrote predictions for all six before running them
  • Every difference between prediction and output has a written explanation
  • You can name the two operations that buffer, and why they must
  • You rewrote the wasteful pipeline and measured the difference in elements read

← Back to Why does a stream with no terminal operation do nothing?