You already know how Python calls a function and how names refer to objects. A functional design adds a useful question: can a caller understand this result using only the supplied inputs? A pure transformation returns a result determined by those inputs and creates no observable effect such as changing caller data, printing, reading a clock, or writing a file. This is an API property we deliberately maintain; Python does not verify it for us.
A program still needs effects. Place them at a narrow boundary: read text, pass the resulting value through calculations, then publish the returned result. When a report unexpectedly changes, this separation reduces the number of places to investigate. A rendering function can return strings without choosing where they will be printed. An application boundary later chooses a terminal, file, or web page.
This lab accepts words as a list or tuple of strings, so traversing it preserves the source. A later lesson accepts one-shot iterators and makes their consumption explicit: advancing a caller-owned iterator changes its position and is an observable effect.
Consider an update to a word-count dictionary. Aliasing the dictionary and incrementing through the alias changes the caller's existing state. Instead, construct a new dictionary and work on it locally:
def add_count(counts, word):
updated = dict(counts)
updated[word] = updated.get(word, 0) + 1
return updated
before = {"oak": 2}
after = add_count(before, "oak")
print(before, after)
assert before == {"oak": 2}The assignment to updated is not an external effect. The new dictionary is private until it is returned, so incrementing it does not violate input preservation. Avoid the misleading rule that functional code cannot use loops or local variables. What matters is the behavior promised at the boundary. The returned dictionary remains mutable by its new owner; purity does not magically turn it into an immutable value.
Normalization also belongs in the contract. The build uses strip().casefold(), ignores normalized empty strings, and retains a zero initial count. Unicode case folding is deliberately stronger than ASCII-only lowercasing: "Straße" and "STRASSE" share a normalized key. Keys supplied in initial counts are already normalized; this helper does not silently reinterpret an existing schema.
words = [" Oak ", "OAK", " "]
normalized = tuple(word.strip().casefold() for word in words)
print(normalized)
counts = {"oak": 2, "elm": 1}
lines = tuple(f"{word}: {counts[word]}" for word in sorted(counts))
print(lines)An invalid initial count raises ValueError even for an empty update. Returning an empty dictionary would blur an error with a legitimate empty result. Validation happens before any caller-visible state could change. Equal inputs may consistently raise the same error; determinism does not mean every possible input is successful.
In the checks, separate hidden dependencies from harmless local calculation. In the bugs, observe how aliasing and overwriting each break a different part of the contract. For the build, test an empty update, repeated words, an existing count, repeated calls and a caller mutation after return. A passing sample gives evidence for those cases, while the documented contract remains the broader obligation.