انتقل إلى المحتوى
أمين أبوزيد
منشورBEG-036-L03

العمليات النهائية والتقييم الكسول (laziness)

  • الدرس 3 من 8
  • 12 دقيقة قراءة
  • مبتدئ
في هذا الدرس 11 موضوعات

هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.

Lesson Overview

Every conveyor belt needs a worker at the far end who packs the finished items, or counts them, or shouts "found one!". In a stream pipeline that worker is the terminal operation. It is the one step that produces a real result, and it is also the step that makes the belt run. Until a terminal operation is called, the stations you wrote do nothing at all. That property is called laziness.

This lesson covers the terminal operations you will use every day: collect and toList (gather), count, reduce (combine into one value), anyMatch, allMatch, noneMatch (yes/no questions), and findFirst (get one item). It also shows, with real printed output, when the work happens and why some pipelines stop early.

Why this matters: a pipeline that "does nothing" is a classic beginner puzzle, and knowing that work stops early explains both good performance and surprising log output.

This lesson builds on Lessons 1 and 2. It previews one idea from BEG-037: findFirst returns an Optional. You will only learn the minimum here, enough to use it safely.

Learning Objectives

By the end of this lesson you will be able to:

  • Name the common terminal operations and say what each returns.
  • Use reduce to combine all items into one value.
  • Use anyMatch, allMatch, and noneMatch, including on an empty stream.
  • Use findFirst and read its Optional result with map and orElse.
  • Explain laziness and predict the order of printed output in a pipeline.
  • Explain short-circuiting.

Written Explanation

The terminal operations

OperationReturnsWhat it does
toList()List<T> (unmodifiable)Gathers all items into a list
collect(collector)depends on the collectorGathers items using a Collector (Lesson 5)
count()longCounts the items
reduce(identity, op)TCombines all items into one value
reduce(op)Optional<T>Same, with no starting value
anyMatch(p)booleanIs there at least one match?
allMatch(p)booleanDo all items match?
noneMatch(p)booleanDoes no item match?
findFirst()Optional<T>The first item, if any
forEach(consumer)voidDoes something with each item

A pipeline has exactly one terminal operation, at the end.

count and collect

count() returns a long, not an int:

java
long evens = nums.stream().filter(n -> n % 2 == 0).count();

toList() is the shortest way to gather results, and since Java 16 it is built into Stream. collect is the general form. collect(Collectors.joining(", ")) joins strings into one String, and collect(Collectors.toSet()) gathers into a Set. Lesson 5 gives collect its full treatment. The Javadoc for Collectors.toList() says it makes no promise about the kind of list returned or whether it can be changed, so do not rely on modifying it either.

reduce: many items into one

reduce repeatedly combines two values until one remains. It takes a starting value (the identity) and a rule for combining two values:

java
int sum = nums.stream().reduce(0, Integer::sum);

Think of it as this loop:

java
int result = 0;                       // the identity
for (int n : nums) { result = result + n; }

The identity must be a value that changes nothing when combined: 0 for addition, 1 for multiplication. If the stream is empty, you get the identity back, so reduce(0, Integer::sum) on no numbers is 0. The combining rule must also be associative (grouping must not change the answer), which addition and maximum are, and the Javadoc requires this so a reduction could run in parallel.

Without an identity, reduce(Integer::max) returns an Optional, because an empty stream has no maximum.

Yes/no questions: anyMatch, allMatch, noneMatch

java
boolean hasNegative = nums.stream().anyMatch(n -> n < 0);
boolean allSmall    = nums.stream().allMatch(n -> n < 100);
boolean noZero      = nums.stream().noneMatch(n -> n == 0);

The empty stream deserves attention, because the answers can surprise you:

Operation on an empty streamResult
anyMatchfalse (there is nothing to match)
allMatchtrue (nothing breaks the rule)
noneMatchtrue

This follows the Javadoc. So allMatch on an empty list says true; check that the list is not empty if that matters to you.

findFirst and a first look at Optional

findFirst() returns the first item that reaches the end of the pipeline. But there may be none, so it cannot just return the item. It returns an Optional, a small box that holds either one value or nothing. (BEG-037 teaches Optional properly; for now you need only two moves.)

java
String name = people.stream()
    .filter(p -> p.age() > 60)
    .findFirst()                  // Optional<Person>
    .map(Person::name)            // Optional<String>
    .orElse("none");              // String: the name, or "none" if the box is empty

map on an Optional changes the value inside, if there is one. orElse unwraps the box and gives a fallback when it is empty. Printing an Optional directly shows its box form, such as Optional[Person[name=Lina, age=67]] or Optional.empty. Avoid calling get() for now, because it fails on an empty box.

Laziness: nothing happens until the end

Intermediate operations only describe work. They do not do it. The Javadoc says intermediate operations such as filter and map are lazy, and traversal begins only when a terminal operation runs. A pipeline with no terminal operation does nothing. Also, laziness lets Java fuse the stations: instead of filtering the whole list and then mapping the whole list, it can push items through the stations one at a time.

You can see this by putting println in the lambdas. This is for learning only; remember that side effects in lambdas are discouraged in real code.

Short-circuiting: stopping early

Some operations can finish without seeing every item. They are called short-circuiting. anyMatch stops at the first match, allMatch and noneMatch stop at the first counterexample, findFirst stops at the first item, and limit(n) stops after n items. The Javadoc's own example is a search for the first string longer than a thousand characters: only as many elements as needed are examined. Short-circuiting is also what makes it possible to use an endless stream with limit.

forEach

forEach performs an action on each item and returns nothing. It is fine for printing results at the end of a pipeline: names.stream().sorted().forEach(System.out::println). It is not the right tool for building a result by adding to an outside list; use toList() or collect for that.

Examples

Example 1: sum, any, count (simple)

java
import java.util.*;

public class Terminals {
    public static void main(String[] args) {
        List<Integer> nums = List.of(4, -2, 7, 10, 3, 8);

        int sum = nums.stream().reduce(0, Integer::sum);
        boolean hasNeg = nums.stream().anyMatch(n -> n < 0);
        long evens = nums.stream().filter(n -> n % 2 == 0).count();

        System.out.println(sum + " " + hasNeg + " " + evens);
        System.out.println(nums.stream().reduce(Integer::max));
        System.out.println(List.<Integer>of().stream().reduce(0, Integer::sum)
            + " " + List.<Integer>of().stream().anyMatch(n -> true)
            + " " + List.<Integer>of().stream().allMatch(n -> false));
    }
}

Real output:

text
30 true 4
Optional[10]
0 false true

The last line shows the empty-stream rules: the sum of nothing is the identity 0, anyMatch is false, allMatch is true.

Example 2: the first person older than 60 (practical)

java
import java.util.*;

public class FindFirstDemo {
    record Person(String name, int age) { }

    public static void main(String[] args) {
        List<Person> ps = List.of(
            new Person("Sam", 34), new Person("Lina", 67), new Person("Omar", 71));

        System.out.println(ps.stream().filter(p -> p.age() > 60).findFirst());
        System.out.println(ps.stream().filter(p -> p.age() > 90).findFirst());

        String a = ps.stream().filter(p -> p.age() > 60)
            .findFirst().map(Person::name).orElse("none");
        String b = ps.stream().filter(p -> p.age() > 90)
            .findFirst().map(Person::name).orElse("none");
        System.out.println(a + " / " + b);
    }
}

Real output:

text
Optional[Person[name=Lina, age=67]]
Optional.empty
Lina / none

Note that findFirst returned Lina, not Omar: both are over 60, and findFirst stops at the first one.

Example 3: watching laziness and short-circuiting (realistic)

java
import java.util.*;
import java.util.stream.*;

public class LazyDemo {
    public static void main(String[] args) {
        List<Integer> nums = List.of(4, -2, 7, 10, 3, 8);

        Stream<Integer> s = nums.stream()
            .filter(n -> { System.out.println("check " + n); return n > 5; })
            .map(n -> { System.out.println("double " + n); return n * 2; });
        System.out.println("pipeline built");

        List<Integer> out = s.toList();
        System.out.println(out);

        System.out.println("--- anyMatch");
        boolean any = nums.stream()
            .peek(n -> System.out.println("see " + n))
            .anyMatch(n -> n > 5);
        System.out.println(any);
    }
}

Real output:

text
pipeline built
check 4
check -2
check 7
double 7
check 10
double 10
check 3
check 8
double 8
[14, 20, 16]
--- anyMatch
see 4
see -2
see 7
true

Three lessons are in this output. First, pipeline built printed before any check: building the pipeline did no work. Second, items are processed one at a time through all stations (check 7 then double 7), not "all checks, then all doubles". Third, in the anyMatch pipeline the stream stopped after 7: the remaining items were never looked at. (peek is an intermediate operation that runs an action on each item as it passes; it exists mainly for debugging, and is used here only to show what is seen.)

Backend Insight

Laziness is why a well-written pipeline can be cheap even over a big list: anyMatch on "does this user have any overdue invoice?" stops at the first overdue one, and findFirst stops at the first match. In service code, anyMatch is also an excellent way to write a business rule as a sentence: "the order is blocked if any item is out of stock".

findFirst returning an Optional is the Java way to say "might not exist" without returning null. The database lookups you write later (findById) follow the same idea, and you will use map and orElse constantly. For a missing user, you will often prefer to throw a "not found" exception than to use orElse with a made-up value; BEG-037 covers when to use each.

Finally, laziness has a debugging consequence: an exception thrown inside a lambda appears when the terminal operation runs, not at the line where you wrote the map. When a stack trace points at toList(), look at the stations above it.

Common Mistakes

Mistake 1: No terminal operation

names.stream().filter(...).map(...); compiles, does nothing, and returns an unused stream. Add toList(), count(), or another terminal operation.

Mistake 2: Using get() on an empty Optional

Unwrapping with get() when the box might be empty throws NoSuchElementException. Use map and orElse until BEG-037 shows more options.

Mistake 3: Thinking allMatch on an empty list means "there are items and all match"

It returns true for an empty stream. If "at least one and all" is the rule, also check that the list is not empty.

Mistake 4: Wrong identity in reduce

  • Incorrect: reduce(0, (x, y) -> x * y) to compute a product, which is always 0.
  • Correct: reduce(1, (x, y) -> x * y).

Mistake 5: Expecting println in a lambda to run for every item

A pipeline can stop early, and the Javadoc notes that count() may skip running the pipeline when it can compute the count from the source. Do not use lambdas for required side effects.

Exercises

Try each exercise yourself before opening the solutions.

Level A: Foundation

A1. Name the terminal operation you would use to: (a) tell whether any word is empty, (b) add all numbers, (c) get the first word that starts with "z".

أظهر الحل A1

A1. (a) anyMatch(String::isEmpty). (b) reduce(0, Integer::sum). (c) filter(w -> w.startsWith("z")).findFirst().

A2. What does a pipeline return or do if it has no terminal operation?

أظهر الحل A2

A2. Nothing runs. You get a stream object back, and no work is done.

A3. What does List.<Integer>of().stream().noneMatch(n -> n > 0) return, and why?

أظهر الحل A3

A3. true, because there is no item that matches; "none match" is true for an empty stream.

Level B: Application

B1. Given List.of(3, 8, 5, 12, 7), write one expression for each: the sum, the product, and the number of items greater than 6.

أظهر الحل B1

B1.

java
List<Integer> nums = List.of(3, 8, 5, 12, 7);
int sum = nums.stream().reduce(0, Integer::sum);          // 35
int product = nums.stream().reduce(1, (x, y) -> x * y);  // 10080
long big = nums.stream().filter(n -> n > 6).count();      // 3

B2. Given a list of words, write a pipeline that returns the first word longer than 6 letters, or the text "short" if there is none.

أظهر الحل B2

B2.

java
String result = words.stream()
    .filter(w -> w.length() > 6)
    .findFirst()
    .orElse("short");

B3. Predict the printed output (no running first), then run to check:

java
Stream.of("a", "bb", "ccc", "dddd")
    .filter(s -> { System.out.println("f " + s); return s.length() > 1; })
    .findFirst();
أظهر الحل B3

B3. Output: f a, f bb. The element a fails the test, bb passes, and findFirst stops there, so ccc and dddd are never examined. (The result of findFirst was thrown away, but the work still ran because a terminal operation was present.)

Level C: Challenge

C1. Try to check whether a List<Integer> is sorted in increasing order using a stream, and explain why this is awkward. Then write the loop version instead and say which you prefer. (Hint: you need to compare neighbours.)

أظهر الحل C1

C1. Comparing neighbours needs two positions at once, which a plain element-by-element stream does not give, so streams are awkward here. The loop is clearer:

java
boolean sorted = true;
for (int i = 1; i < list.size(); i++) {
    if (list.get(i - 1) > list.get(i)) { sorted = false; break; }
}

I prefer the loop: it needs an index, a flag, and an early break, exactly the cases where loops win.

C2. A teammate adds .map(x -> { total += x; return x; }) and ends the pipeline with .count(), hoping to compute a total. Give two reasons it is unreliable, and show a correct way to get the total.

أظهر الحل C2

C2. First, total is an outside variable: lambdas cannot assign to a local variable, and a field would work only by relying on side effects. Second, count() may skip the pipeline when it can work out the count from the source, in which case the map never runs and total stays 0. A correct way: int total = nums.stream().reduce(0, Integer::sum);.

Mini Challenge

Take a list of at least six prices (double values) and write a small program that prints: the total (reduce), whether any price is above 100 (anyMatch), whether all prices are positive (allMatch), and the first price below 10, or -1 if there is none (findFirst, map not needed: use .orElse(-1.0)). Then add a peek that prints each price it sees before anyMatch, run it, and write one sentence about which prices were printed and why.

Check your work: each result matches a hand calculation, and the peek output stops at the first price over 100 (or shows all of them if none is over 100).

Summary & Next Lesson

Key takeaways

  • A pipeline needs a terminal operation; without one, nothing runs. That is laziness.
  • count, reduce, anyMatch, allMatch, noneMatch, findFirst, toList, and collect are the everyday terminal operations.
  • reduce needs an identity that changes nothing: 0 for sums, 1 for products.
  • findFirst returns an Optional; use map and orElse for now.
  • Short-circuiting operations stop early, so unneeded items are never processed.

Next lesson

The next lesson, "flatMap", handles nested data, such as an order that contains many items, by turning many small lists into one flat stream.

Self-check

  1. What happens if a pipeline has no terminal operation?
  2. What does allMatch return on an empty stream?
  3. What is the identity for a reduce that multiplies?
  4. Why did check 3 and check 8 not appear in the anyMatch pipeline?
  5. What does findFirst return when nothing matches?
أظهر الإجابات
  1. Nothing runs and no result is produced.
  2. true.
  3. 1.
  4. anyMatch is short-circuiting; it stopped at the first match, 7.
  5. An empty Optional (Optional.empty).

References

  • java.util.stream package summary, Java SE 21 API. Supports: intermediate operations are lazy and traversal starts at the terminal operation, short-circuiting operations (limit, findFirst), the reduction rules, and why side effects in lambdas are discouraged.
  • Stream, Java SE 21 API. Supports: anyMatch, allMatch, noneMatch results on an empty stream, count() may skip the pipeline, reduce identity and associativity requirements, and findFirst returning an Optional.
  • Collectors, Java SE 21 API. Supports: Collectors.toList() gives no guarantees about the list's type or mutability, and joining concatenates in encounter order.
  • Outputs shown were produced by compiling and running the examples with JDK 21. The behavior of Optional.map and orElse was verified by running, not from a fetched page; Optional is taught properly in BEG-037.

التطبيق

طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.

المختبر والتمارين الاختبار

تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.

اكتب للبحث في المستويات والمراحل والوحدات والدروس.