Python Part I · Module 10 · Loops
Counters and Accumulators
A loop that only prints has told you something and kept nothing. Almost every useful loop produces a result — a count of errors, a total runtime, a list of failing tests, a table of severities — and that result has to survive the loop that built it. The pattern for doing that is three lines long and does not change between those cases: create the result before the loop, update it inside, use it afterwards. It is the single most reused shape in automation code, which is why it is worth being precise about, and why the most common bug in that code is one of those three lines being in the wrong place.
Foundation17 min readPythonPart IFundamentalsLoopsAccumulators
Part I · Module 10 · Chapter 10.10 · Counters and Accumulators
1. Before, Inside, After
Every accumulating loop has the same three parts:
<result> = <empty starting value> # before the loop
for <item> in <collection>:
<update result using item> # inside the loop
<use result> # after the loopThe starting value is whatever "nothing yet" means for the kind of result: 0 for a count or a total, [] for a collection, {} for a table, "" for text.
Each part has to be in its own place. The next section shows the shape working; Section 3 shows what the middle one being in the wrong place costs.
2. Counting
The canonical log checker:
lines = [
"UVM_INFO @0: reset asserted",
"UVM_ERROR @120: crc mismatch",
"UVM_INFO @200: burst complete",
"UVM_ERROR @410: unexpected BRESP",
]
errors = 0
for line in lines:
if "UVM_ERROR" in line:
errors += 1
print(errors)2errors = 0 before, errors += 1 inside, print(errors) after. The += is Chapter 3.3's operator — errors = errors + 1 written shorter — and it is doing the only thing that makes the result survive: updating a name that lives outside the loop body.
That is the crux. The loop variable line is re-bound every pass and is gone in any useful sense when the loop ends. errors is not touched by the iteration at all, so what the body does to it accumulates.
3. The Reset in the Wrong Place
Move errors = 0 one line down, into the loop:
lines = [
"UVM_ERROR @120: crc mismatch",
"UVM_INFO @200: burst complete",
"UVM_ERROR @410: unexpected BRESP",
]
for line in lines:
errors = 0
if "UVM_ERROR" in line:
errors += 1
print("counted:", errors)counted: 1There were two errors in that log. The program reported one.
To see why, record the counter's value at the end of every pass:
lines = [
"UVM_ERROR @120: crc mismatch",
"UVM_INFO @200: burst complete",
"UVM_ERROR @410: unexpected BRESP",
]
errors_seen = []
for line in lines:
errors = 0
if "UVM_ERROR" in line:
errors += 1
errors_seen.append(errors)
print("per-pass counts:", errors_seen)
print("final value:", errors)per-pass counts: [1, 0, 1]
final value: 1The counter is reset at the top of every pass, so it never accumulates anything. Each pass sets it to 0 and then records whether that one line was an error, and what survives the loop is the answer for the last line only — which happened to be an error, so the program reports 1.
That is worse than reporting 0. A zero would look suspicious in a log that obviously contains errors; a one looks like a real count and differs from the truth by exactly the amount nobody checks.
The correct version on the same data:
lines = [
"UVM_ERROR @120: crc mismatch",
"UVM_INFO @200: burst complete",
"UVM_ERROR @410: unexpected BRESP",
]
errors = 0
for line in lines:
if "UVM_ERROR" in line:
errors += 1
print("counted:", errors)counted: 24. Totalling
Same shape, different update:
runtimes = [120, 450, 90, 300]
total = 0
for runtime in runtimes:
total += runtime
print("total runtime:", total, "seconds")
print("tests:", len(runtimes))total runtime: 960 seconds
tests: 4A count and a total in one loop are two accumulators, not one:
results = [("smoke", 120, "PASS"), ("burst", 450, "FAIL"), ("reset", 90, "PASS")]
passed = 0
failed = 0
total_time = 0
for name, runtime, status in results:
total_time += runtime
if status == "PASS":
passed += 1
else:
failed += 1
print("passed", passed, "failed", failed, "total", total_time, "s")passed 2 failed 1 total 660 sNote where each update sits. total_time += runtime is at the top of the body because every run contributes to it; the two counters are inside branches because each run contributes to exactly one of them.
5. Collecting Into a List
When you need the items rather than how many, the empty starting value is []:
results = [("smoke", 120, "PASS"), ("burst", 450, "FAIL"), ("reset", 90, "FAIL")]
failed_names = []
for name, runtime, status in results:
if status == "FAIL":
failed_names.append(name)
print("failures:", failed_names)
print(len(failed_names), "of", len(results), "failed")failures: ['burst', 'reset']
2 of 3 failed.append() from Chapter 5.3 is the update. The list is created once, before the loop, for the same reason the counter was.
6. Building Text — and Why Not Like This
The same shape appears to work for strings, and it is the one case where you should not use it:
names = ["smoke", "reset", "burst"]
line = ""
for name in names:
line += name + ", "
print(repr(line))'smoke, reset, burst, 'There is a comma and a space on the end that should not be there. Every loop-built string has this problem, and the usual responses — stripping the last two characters afterwards, or an if that skips the separator on the first pass — are both patches for having chosen the wrong tool.
Chapter 4.9 already gave the right one:
names = ["smoke", "reset", "burst"]
print(repr(", ".join(names)))'smoke, reset, burst'.join() puts the separator between items, so there are three names and two separators. No loop, no trailing comma, and no accumulator to misplace.
Build a list in the loop, then join it after. That keeps the accumulator pattern where it works and uses the right tool for the text:
results = [("smoke", "PASS"), ("burst", "FAIL"), ("reset", "FAIL")]
failed_names = []
for name, status in results:
if status == "FAIL":
failed_names.append(name)
print("FAILED: " + ", ".join(failed_names))FAILED: burst, reset7. Counting Per Category
When the answer is not one number but one number per kind, the accumulator is a dictionary:
lines = [
"UVM_ERROR @1: a",
"UVM_INFO @2: b",
"UVM_ERROR @3: c",
"UVM_WARNING @4: d",
"UVM_INFO @5: e",
]
counts = {}
for line in lines:
severity = line.split()[0]
counts[severity] = counts.get(severity, 0) + 1
for severity in sorted(counts):
print(severity, counts[severity])UVM_ERROR 2
UVM_INFO 2
UVM_WARNING 1The update line is the whole technique. counts.get(severity, 0) is Chapter 8.3's safe lookup: it gives the running count for a severity already seen, or 0 for one met for the first time. Adding one and storing it back handles both cases without a branch.
Written with an if it is three lines and says the same thing:
lines = ["UVM_ERROR @1: a", "UVM_INFO @2: b", "UVM_ERROR @3: c"]
counts = {}
for line in lines:
severity = line.split()[0]
if severity in counts:
counts[severity] += 1
else:
counts[severity] = 1
print(counts){'UVM_ERROR': 2, 'UVM_INFO': 1}Both are correct. The .get() form is preferred because the first-time case is not special enough to deserve a branch.
Note the sorted() in the first version's report loop — dictionary order is insertion order, which here means order of first appearance in the log, and that is rarely what a reader wants from a summary table.
8. Tracking a Maximum
A maximum is an accumulator whose update is a comparison, and it has one wrinkle: what to start from.
runtimes = [120, 450, 90, 300]
longest = runtimes[0]
for runtime in runtimes:
if runtime > longest:
longest = runtime
print("longest:", longest)longest: 450Starting from runtimes[0] is correct and requires the list to be non-empty. Starting from 0 would look safer and is wrong for any data that can be negative — a temperature margin, a slack value — because every real value would be below the seed and the answer would come back as 0.
For a bare maximum Python already has max(), so the loop is only worth writing when you need something alongside the value — typically which item it was:
results = [("smoke", 120), ("burst", 450), ("reset", 90)]
slowest_name = ""
slowest_time = 0
for name, runtime in results:
if runtime > slowest_time:
slowest_time = runtime
slowest_name = name
print("slowest:", slowest_name, slowest_time)slowest: burst 450Two accumulators updated together, inside one if, so they can never disagree about which run they describe. Updating one and forgetting the other is the classic failure here — the time from one test and the name from another.
9. The Shape, Five Times
| Result | Before the loop | Inside the loop | After |
|---|---|---|---|
| a count | errors = 0 | errors += 1 | print(errors) |
| a total | total = 0 | total += runtime | print(total) |
| a collection | failed = [] | failed.append(name) | len(failed), or join it |
| a table | counts = {} | counts[k] = counts.get(k, 0) + 1 | report, usually sorted() |
| a maximum | best = items[0] | if x > best: best = x | print(best) |
One skeleton, five fillings. Learning it as a shape rather than as five techniques is what makes the next one you need obvious.
10. Predict, Run, Observe
# A
total = 0
for n in [1, 2, 3, 4]:
total += n
print("A:", total)
# B
for n in [1, 2, 3, 4]:
total_b = 0
total_b += n
print("B:", total_b)
# C
kept = []
for name in ["clk", "rst_n", "data"]:
if len(name) > 3:
kept.append(name)
print("C:", kept, len(kept))
# D
counts = {}
for ch in "axaxb":
counts[ch] = counts.get(ch, 0) + 1
print("D:", counts)The verified output:
A: 10
B: 4
C: ['rst_n', 'data'] 2
D: {'a': 2, 'x': 2, 'b': 1}A — the shape done correctly: 1 + 2 + 3 + 4.
B — Section 3's bug. The reset is inside, so total_b holds only the last item's contribution. 4 is a plausible total for this data, which is exactly what makes it dangerous.
C — "clk" is three characters, so it is excluded; the list carries the names and len() gives the count.
D — three distinct characters counted in one pass, in order of first appearance.
11. Exercises
Exercise 1 — count and collect. Given a list of log lines, count the UVM_ERROR lines and also collect them. Print the count twice — once from the counter, once from the list — and say which accumulator you would keep if you could have only one.
Exercise 2 — move the reset. Take Exercise 1's counter and move its initialisation inside the loop. Predict the output before running, then explain in two sentences why the result is not zero and not correct.
Exercise 3 — two counters. For results = [("a", "PASS"), ("b", "FAIL"), ("c", "FAIL")], count passes and failures in one loop, then print the pass rate as a percentage of the total.
Exercise 4 — total and average. Given a list of runtimes, print the total and the average. Say what you must check before dividing and why the loop cannot help you with it.
Exercise 5 — the trailing separator. Build a comma-separated string of three names with += and print it with repr(). Then do it with .join(). Explain in one sentence what repr() showed you that print() would have hidden.
Exercise 6 — severity table. Count each severity in a list of log lines using a dictionary and .get(). Print the table sorted by severity name, then print just the severity with the highest count.
Exercise 7 — longest with its name. Given [("smoke", 120), ("burst", 450), ("reset", 90)], find the slowest test and print both its name and its runtime. Then remove one of the two update lines and describe what the output becomes.
Exercise 8 — seed the maximum. Find the largest value in [-5, -2, -9] twice: once seeding from 0 and once from the first item. Explain the difference in one sentence, and say what each version does on an empty list.
12. Summary
- Every accumulating loop is three parts: create the result before the loop, update it inside, use it after.
- The starting value is "nothing yet" for the kind of result:
0,[],{},"". - The accumulator must live outside the loop body, which in practice means its creation line sits at the same column as the
for. - Creating it inside the loop resets it every pass, producing a plausible small number instead of an error.
+=is the usual update for counts and totals;.append()for collections.- Collect the items rather than just counting them when the items might be wanted —
len()gives you the count anyway. - Do not build strings with
+=in a loop. It leaves a trailing separator every time; build a list and.join()it. - A dictionary accumulator counts per category, and
counts.get(k, 0) + 1handles the first sighting without a branch. - Report a dictionary with
sorted()unless order of first appearance is genuinely what you want. - A maximum seeded from
0is wrong for data that can be negative — seed from the first item. - Track a value and its name as two accumulators updated together, inside the same
if, so they cannot disagree.
The next chapter puts one loop inside another, and counts how many times the inner body really runs.
Where this fits
Part of the Python curriculum.
