CodeRigor

FREE LESSON

Pure functions and effect boundaries

Make state changes explicit as returned values, preserve caller inputs, and keep rendering separate from effects.

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.

A functional core inside an effectful program
start / 3
Input effects supply values; a deterministic core returns data; output effects publish it. Only the middle stage is covered by the pure API.
A functional core inside an effectful program
Input effects supply values; a deterministic core returns data; output effects publish it. Only the middle stage is covered by the pure API.

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:

Python
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.

Updating a private copy
start / 3
The original mapping and the new mapping are separate objects. Local increments change only the result.
Updating a private copy
The original mapping and the new mapping are separate objects. Local increments change only the result.

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.

Python
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.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Focus on the functional core, separating calculations from effects, and reusable data. This conceptual talk complements the exact tally/render contract; Python does not enforce purity.