عرض عملي: إعادة كتابة stream غير مقروء على هيئة حلقة
في هذا الدرس 11 موضوعات
هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.
Lesson Overview
The previous demo built a report out of short, tidy pipelines. This demo shows the other half of the skill: noticing when a stream has gone too far and stepping back. You will take a ten-step chain with nested lambdas and a hidden side effect, work out why it is hard to read and debug, rewrite it as a plain loop with named variables, and compare the two versions.
On the conveyor belt, this is the moment a supervisor walks in and says: "this belt has ten stations, one of them secretly writes on a clipboard, and nobody can tell what happens to a parcel in the middle. Let's put one careful worker on it instead." A loop is that one worker: slower to describe, but you can see everything they do.
Why this matters: the module's last exercise (BEG-036-EX06) asks you to do this and justify it in two sentences. In real teams, being able to explain why a rewrite is better is a skill worth more than the rewrite itself.
This lesson builds on Lesson 6, "When not to use a stream", and on all the pipeline tools from Lessons 1 to 5.
Learning Objectives
By the end of this lesson you will be able to:
- Identify what makes a chain hard to read: length, nested lambdas, and hidden state.
- Explain why a side effect inside
mapis fragile, using the rules in the Java documentation. - Rewrite a long chain as a loop with named variables and the same result.
- Offer the middle path: split the chain into two honest steps.
- Compare two versions and justify the choice in two sentences.
- Recognise when you should keep the stream.
Written Explanation
The "before" code
Here is a realistic chain, the kind that grows by one extra step every month. It takes orders and builds the top three "customer=amount" labels for paid orders of at least 50, adding a ten-unit express fee, and also records the ids of paid orders over 200 in a shared list for auditing.
List<String> before = orders.stream()
.filter(o -> o.status().equals("PAID"))
.map(o -> { if (o.amount() > 200) { flagged.add(o.id()); } return o; })
.filter(o -> o.amount() >= 50)
.map(o -> o.express()
? new Order(o.id(), o.customer(), o.status(), o.amount() + 10, true) : o)
.sorted(Comparator.comparingDouble(Order::amount).reversed())
.map(o -> o.customer() + "=" + o.amount())
.distinct()
.limit(3)
.collect(Collectors.toList());flagged is a field: static final List<String> flagged = new ArrayList<>();.
The record is record Order(String id, String customer, String status, double amount, boolean express).
Why it is hard to read
Ask the ten-second question: what does this do? A reader must decode:
- Length. Nine stations. The reader must hold the type of the stream in their head after each:
Order,Order,Order,Order, thenString. - Nested lambdas. The express fee is a conditional that builds a whole new
Order, hidden in a one-line lambda. - A hidden second job. The second line is not about producing the result at all: it writes to an outside list, and the name
mapsuggests a pure transformation. - Order dependence. The audit must happen before the
>= 50filter, but that is not visible. Moving afilterline changes which orders get flagged.
Debugging is worse. If the flagged list is wrong, where do you put a breakpoint? Inside a lambda in the middle of a chain. If an order is missing from the top three, which station dropped it?
Why the side effect is fragile
The Java documentation describes side effects in behavioral parameters such as the lambda in map: they are generally discouraged, and no guarantees are made about their visibility to other threads, about whether the same thread handles different operations on an element, or about whether the lambda runs at all if the implementation can elide it. The Stream documentation gives a concrete case: count() may skip running the pipeline when it can compute the answer from the source, so side effects might not happen. We can see that rule in action:
long c = orders.stream()
.peek(o -> System.out.println("peek " + o.id()))
.count();
System.out.println(c);On JDK 21, with a list as the source, this printed only 7 and no peek lines, because the count could be computed from the size of the list. The pipeline "ran", but the stations did not. Add a filter before peek and the stations do run. Whether a station runs can depend on details of the pipeline.
The same fragility appears in two other ways, which we only explain here and do not run:
- Changing the order of operations. If someone later adds a
limitearlier in the chain, or asorted, the set of items that reach the side-effect station changes, and the audit list silently changes with it. - Parallel streams. If someone switches to
parallelStream()to "make it faster", several threads would add to the sameArrayList, which is not thread-safe, and the audit list could lose entries or the program could fail. Even with a thread-safe list, the order of the list would no longer match the order of the orders.
The rewrite as a loop
List<String> flaggedIds = new ArrayList<>();
List<Order> candidates = new ArrayList<>();
for (Order o : orders) {
if (!o.status().equals("PAID")) continue;
if (o.amount() > 200) flaggedIds.add(o.id());
if (o.amount() < 50) continue;
double charged = o.express() ? o.amount() + 10 : o.amount();
candidates.add(new Order(o.id(), o.customer(), o.status(), charged, o.express()));
}
candidates.sort(Comparator.comparingDouble(Order::amount).reversed());
List<String> after = new ArrayList<>();
for (Order o : candidates) {
String label = o.customer() + "=" + o.amount();
if (!after.contains(label)) after.add(label);
if (after.size() == 3) break;
}What changed:
- The audit is now a plain line in the loop: visible, and easy to break on.
- The fee has a name,
charged, and a line to itself. - The order dependence is now the order of lines: the audit check comes before the
< 50skip. - The early exit (
breakat three) is explicit.
The middle path: two honest pipelines
A loop is not the only repair. If the real problem is the side effect, you can split the job into two pipelines that each compute one thing:
List<String> flaggedIds = orders.stream()
.filter(o -> o.status().equals("PAID") && o.amount() > 200)
.map(Order::id)
.toList();and a separate pipeline for the labels, with the fee computed in a small named method. Two pipelines pass over the data twice, a cost that does not matter for a few thousand orders. Choose the loop when state or early exit matters; choose the split streams when the pieces are independent. Both beat the original.
Comparing the two versions
| Before (one chain) | After (loop) | |
|---|---|---|
| Lines | About 12 | About 20 |
| Where is the audit? | Hidden inside map | A visible if |
| Breakpoint for one order | Hard: inside lambdas | Easy: one line |
| Safe if made parallel | No | Not applicable (sequential by design) |
| Same output | Yes | Yes |
A two-sentence justification, as asked in the exercises, might read: "I rewrote the chain as a loop because the audit side effect inside map hid a second job and depended on step order, which makes the stream fragile and hard to debug. In the loop the audit, the fee, and the early exit are visible lines with named variables."
Examples
Example 1: same output, two forms (simple)
Put both versions in one main with this data:
List<Order> orders = List.of(
new Order("O1", "Amal", "PAID", 120, true),
new Order("O2", "Bo", "NEW", 40, false),
new Order("O3", "Chen", "PAID", 250, false),
new Order("O4", "Dina", "PAID", 90, true),
new Order("O5", "Amal", "PAID", 80, false),
new Order("O6", "Eli", "CANCELLED", 300, true),
new Order("O7", "Chen", "PAID", 30, false));Printing before plus the flagged list, then after plus flaggedIds, gave this real output (JDK 21):
[Chen=250.0, Amal=130.0, Dina=100.0] flagged=[O3]
[Chen=250.0, Amal=130.0, Dina=100.0] flagged=[O3]Identical results. Verify by hand: paid orders of at least 50 are O1 (120 + 10 = 130), O3 (250), O4 (90 + 10 = 100), O5 (80). Sorted, the top three are Chen 250, Amal 130, Dina 100. O3 is the only paid order over 200.
Example 2: remove a counter from a chain (practical)
A smaller chain with the same smell:
int[] skipped = {0};
List<String> out = names.stream()
.map(n -> { if (n.isBlank()) skipped[0]++; return n.trim(); })
.filter(n -> !n.isEmpty())
.map(String::toLowerCase)
.distinct()
.toList();The int[] skipped = {0} trick shows the loop would have been natural. With names = List.of(" Amal", "", "BO ", "amal", " ", "Chen") the chain gives [amal, bo, chen] and skipped[0] is 2. Two honest pipelines do the same without state:
List<String> clean = names.stream()
.map(String::trim)
.filter(n -> !n.isEmpty())
.map(String::toLowerCase)
.distinct()
.toList();
long skipped = names.stream().filter(String::isBlank).count();Real output of these two together: [amal, bo, chen] 2. No outside variable, no hidden job, nothing to break if someone reorders the lines.
Example 3: the "because it works" argument (realistic)
A teammate objects: "The side effect works, tests are green, why change it?" The honest reply has three parts:
- It works today under particular conditions: a sequential stream, a source that is a list, a terminal operation that visits every item. The documentation does not promise it, and the
count()experiment above shows it can fail without any compile or runtime error. - Change is likely. The people who modify the pipeline next may add a
limit, asorted, orparallelStream(), and the audit would change or break silently. Silent failures in an audit are the worst kind. - The fix is cheap. Moving the side effect into a loop, or deleting it in favour of a second pipeline, costs five minutes.
Backend Insight
Audit trails, metrics, and "who touched this" records are common in backend code, and they are exactly what tempts developers to put side effects into pipelines. The robust pattern is to separate the decision from the recording. First compute what happened as a result (a list of flagged ids), then record it in a plain statement or loop that a reader can see and a test can check.
When you review someone else's code, you will find chains like the "before" version. A useful way to give feedback: ask "what would the loop look like?" and offer the two-step version. A rewrite is not a criticism; it is a trade between brevity and visibility, and in business logic, visibility usually wins.
Common Mistakes
Mistake 1: Keeping the side effect "because it works"
It works only under the conditions of today. Move it out.
Mistake 2: Rewriting as a loop without checking the output
After any rewrite, run both versions on the same input and compare. Equal output on a few inputs, including an empty list and ties, is cheap insurance.
Mistake 3: Rewriting to a loop and keeping the unclear names
A loop with variables named x, t, r is no better than a chain. Names such as charged, candidates, and flaggedIds are the whole benefit.
Mistake 4: Replacing every stream with a loop
The goal is clarity, not retreat. Pipelines of three or four honest stations stay streams.
Mistake 5: Two-sentence justification that only says "it is cleaner"
"Cleaner" is not a reason. Name what is visible now: the side effect, the named variable, the early exit.
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. List two things that make the "before" chain hard to read.
أظهر الحل A1
A1. Any two of: nine stations; nested lambdas such as the fee conditional; a hidden side effect inside map; dependence on the order of lines.
A2. Why can peek or map with a side effect fail to run for every element?
أظهر الحل A2
A2. The documentation gives no guarantees about visibility, thread, or execution of side effects. Pipeline optimisations can skip the stations altogether, for example count() on a list source may compute the count from the list's size without running peek or map.
A3. What is the middle path to removing a side effect without a loop?
أظهر الحل A3
A3. Split the job into two honest pipelines: one computes the main result, the other computes the information that was being recorded, using only inputs and returned values.
Level B: Application
B1. Rewrite as a loop with named variables:
int[] seen = {0};
List<Integer> out = nums.stream()
.map(n -> { seen[0]++; return n * 2; })
.filter(n -> n > 10)
.toList();The loop must also produce the number of items seen.
أظهر الحل B1
B1.
int seen = 0;
List<Integer> out = new ArrayList<>();
for (int n : nums) {
seen++;
int doubled = n * 2;
if (doubled > 10) out.add(doubled);
}B2. Write the two-pipelines version of the same code, with no outside variable.
أظهر الحل B2
B2.
List<Integer> out = nums.stream().map(n -> n * 2).filter(n -> n > 10).toList();
int seen = nums.size();(The number of items seen is simply the list size.)
B3. Write a two-sentence justification for choosing one of the two forms.
أظهر الحل B3
B3. Example: "I would use the two-pipelines version because the number seen is just nums.size(), so no loop state is needed. The map and filter then do only their own jobs and cannot break when someone reorders them."
Level C: Challenge
C1. The "before" chain is used on a list with millions of paid orders and the team sees the audit list sometimes missing ids after someone switches to parallelStream(). Explain why in two sentences and say how you would fix it.
أظهر الحل C1
C1. Several threads then add to one ArrayList, which is not safe for concurrent writes, so entries can be lost or the list corrupted; and the documentation gives no guarantees about visibility or order of such side effects. Fix: remove the side effect from the chain, compute the flagged ids with their own pipeline (or in a loop), and avoid shared mutable state.
C2. Decide for each whether you would keep the stream or write a loop, with one reason: (a) "names of the five oldest users", (b) "stop reading lines when the first blank line appears and count the lines before it", (c) "total of paid amounts per customer".
أظهر الحل C2
C2. (a) Stream: sorted then limit(5) then map, no state. (b) Loop: it needs an early exit and a counter. (c) Stream: filter and groupingBy(..., summingDouble(...)).
Mini Challenge
Write a ten-station stream of your own that is deliberately hard to read, including one outside counter or list that it changes. Swap with a classmate if you can, or leave it a day. Then rewrite it twice: as a loop, and as two honest pipelines. Compare the outputs on the same input, and write a two-sentence justification for the version you would put into a real codebase.
Check your work: all three versions print the same result, the final version has no lambda that changes outside state, and your justification names a specific problem, not just "cleaner".
Summary & Next Lesson
Key takeaways
- A chain is unreadable when it is long, nested, or has a hidden job.
- Side effects in
maporpeekare fragile: the documentation gives no guarantees, and some pipelines skip the stations entirely. - A loop makes state, order, and early exit visible with named variables.
- Two honest pipelines are a middle path when the pieces are independent.
- Always compare the outputs of old and new versions, and justify the choice with specifics.
Next lesson
This was the last lesson of the module. The module's lab, "Sales report", is where you apply everything: revenue by category, top customers, and average order value per month, with readable pipelines and a note on any step you wrote as a loop. The next module is BEG-037, which starts with "The billion-dollar mistake: null".
Self-check
- Give one reason a side effect in
mapis risky. - What could
count()do that makes apeekprint nothing? - Name the two repairs you can use instead of a long chain with a side effect.
- What should you do after any rewrite?
- What makes a good two-sentence justification?
أظهر الإجابات
- The documentation gives no guarantees about whether, when, or on which thread the effect happens, and parallel use of a non-thread-safe list can corrupt it.
- It can compute the count from the source (for example a list's size) without running the pipeline.
- A loop with named variables, or two separate pipelines without side effects.
- Run old and new versions on the same input and compare.
- It names a concrete problem (hidden side effect, order dependence) and a concrete benefit (visible, named, debuggable).
References
- Stream, Java SE 21 API. Supports:
count()may skip running the pipeline, so side effects might not occur (the documented example withpeek). - java.util.stream package summary, Java SE 21 API. Supports: side effects in behavioral parameters are discouraged with no guarantees on visibility or execution, and mutating a shared list in a stream is a poor pattern.
- Outputs, including the
peekexperiment, were produced with JDK 21. The parallel-stream failure of anArrayListis explained from the library's documented guarantees and general thread-safety knowledge; it was deliberately not run, because such failures are non-deterministic. The next-module title was read from its spec.
التطبيق
طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.
تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.