A test shows that the code worked on the inputs you tried. The functional discipline lets you make claims about the inputs you did not try.
Introductions to functional programming usually call it “programming with functions,” and functions really are a central part of the practice. But after (slowly) studying it for over ten years, I have started to wonder whether it’s more about “functionality.” One definition of science is “what works.” Science has theories that fit the data, are predictive, and are falsifiable. If “computer science” is to live up to its name, some of its ideas and practices should fit that definition, and perhaps some should even be mathematically provable. This seems to me to be the broader challenge that functional programming tackles.
The preceding chapters build the machinery. Functional Foundations establishes pure functions and immutable values, Functional Toolkits supplies the standard library’s support, and Error Handling makes failure an ordinary value. This chapter asks what that machinery lets you claim about your code, and how far those claims can go.
An expression is referentially transparent when you can replace it with its value and the program behaves the same. Pure functions have this property, and that is the reason purity matters:
# referential_transparency.py
def add(a: int, b: int) -> int:
return a + b
x = add(2, 3) + add(2, 3)
y = 5 + 5
print(x, y, x == y)
#: 10 10 Trueadd(2, 3) always returns 5, so the
call and the value are interchangeable everywhere in the
program. An implementation may therefore cache the call, run the
two calls in either order, or skip the second. The language has
no way to mark add() as pure, so CPython runs both
calls, left to right, and any cache is yours to add.
The same substitution serves your reasoning as well as the machine’s optimization. You can reason about the code by replacing a call with its value, as you do in algebra. That substitution lets you check parts of a program, and sometimes prove them correct.
Substitution breaks the moment a function reads something
that can change, or writes outside itself. withdraw()
does both, reading and writing the module-level
balance:
# not_transparent.py
type Balance = int
balance: Balance = 100
def withdraw(amount: int) -> Balance:
global balance
balance -= amount
return balance
print(withdraw(30) + withdraw(30))
#: 110
balance = 100
print(70 + withdraw(30))
#: 140The first withdraw(30) evaluates to
70, so substituting 70 for it ought to
leave the result at 110. The substitution changes
110 into 140. withdraw()
is not referentially transparent. Every expression that calls it
fails the same substitution.
A global statement is
one way to break substitution. A function that mutates an
argument is another:
# mutates_argument.py
def add_item(cart: list[str], item: str) -> list[str]:
cart.append(item)
return cart
cart: list[str] = ["milk"]
first = add_item(cart, "eggs")
add_item(cart, "eggs")
print(cart)
#: ['milk', 'eggs', 'eggs']
cart = ["milk"]
first = ["milk", "eggs"] # [1]
add_item(cart, "eggs")
print(cart)
#: ['milk', 'eggs']Each call to add_item() returns the same list
the caller passed in, so replacing the call with that list looks
safe. It is not. The call also appends to cart. The
list put in its place leaves cart as it was. The
rest of the listing puts the returned list in place of the
add_item() call that produced it
([1]).
Referential transparency makes lru_cache
safe. A memoizer can return a stored result because the call is
interchangeable with its value. Every optimization that skips or
reuses work depends on the same interchangeability, from a cache
to a database query planner. The more of your program is
referentially transparent, the more of it a machine optimizes
and a proof covers.
Caching an impure function quietly
returns wrong values. Because withdraw() reads and
writes balance, decorating it with
lru_cache leaves balance wrong:
# cached_withdraw.py
from functools import lru_cache
type Balance = int
balance: Balance = 100
@lru_cache
def withdraw(amount: int) -> Balance:
global balance
balance -= amount
return balance
print(withdraw(30), withdraw(30))
#: 70 70
print(f"balance: {balance}")
#: balance: 70
print(withdraw.cache_info())
#: CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)Two withdrawals of 30 should leave
balance at 40. The second call is a
cache hit, so lru_cache answers it with the stored
70. The body of withdraw() runs once,
for the first call, and subtracts one 30. The
skipped subtraction passes silently. lru_cache
returns the stored result for any repeated arguments, and never
checks whether the wrapped function is referentially
transparent.
A pure function is automatically parallelizable. Each call’s answer comes from its arguments alone, so the calls are independent. The calls can run in any order, on any schedule, on any number of cores, and still return the same answers.
Shared state makes the answers depend on the schedule. Two
parallel withdraw() calls can both read
balance before either writes it back. The second
write then overwrites the first, so balance records
one withdrawal. A lock makes each call finish its read and write
before the other call reads balance, but the lock
serializes the work you wanted to overlap. Purity removes the
problem instead of managing it. With nothing shared, a lock has
nothing to guard.
count_primes() is pure, and each call does
enough work to be worth sending to a separate core:
# parallel_pure.py
import time
from concurrent.futures import ProcessPoolExecutor
from benchmark import report
def count_primes(limit: int) -> int:
count = 0
for n in range(2, limit):
if all(n % d for d in range(2, int(n**0.5) + 1)):
count += 1
return count
if __name__ == "__main__":
limits = [200_000, 400_000, 600_000, 800_000]
start = time.perf_counter()
serial = list(map(count_primes, limits))
serial_time = time.perf_counter() - start
start = time.perf_counter()
with ProcessPoolExecutor() as pool:
parallel = list(pool.map(count_primes, limits))
parallel_time = time.perf_counter() - start
assert parallel == serial
report(serial=serial_time, parallel=parallel_time)
print(parallel)
# Sample run: [17984, 33860, 49098, 63951]
faster = serial_time > 1.3 * parallel_time
print(f"serial at least 1.3x parallel time: {faster}")
# Sample run: serial at least 1.3x parallel time: Truelist(map(...)) runs
the four calls one at a time, on one core.
pool.map() sends the same calls to worker
processes, which the operating system places on separate cores.
The assert passes on every run, because a pure call
returns the same answer whichever process runs it and whenever
it runs. Like map(), pool.map()
returns the answers in the order of limits,
whichever call finishes first, so the two lists match position
by position.
The limits in parallel_pure.py are large
enough for the parallel run to finish sooner. report()
prints the two times only when you run the listing with
--numbers. On the machine that built this book, the
serial run took a few seconds and the parallel run about half
that, a speedup well over the 1.3x margin faster
checks. At small enough limits the serial run finishes before a
pool has started its workers. At those sizes the parallel run
takes longer than the serial one. Purity guarantees that the
parallel run returns the serial run’s answers. Whether parallel
runs faster at a given size is a separate question, and the
timing answers it.
Sending the calls to a worker adds requirements. Each
argument and each result pickles to cross the process boundary.
The function pickles as its qualified name, so
count_primes() must sit at the top level of a
module a worker can import. A lambda or a closure
fails with a PicklingError. That rules out two
shapes these chapters use often. By contrast, a
functools.partial pickles as its wrapped function
plus its bound arguments, so a partial of a
top-level function carries to a worker the argument a closure
captures.
The
if __name__ == "__main__" guard follows from the
same import. Each worker imports this module to find
count_primes(). Without the guard, each worker
re-runs the pool-building code, which fails in the worker with a
RuntimeError and in the parent with
BrokenProcessPool. Concurrency
covers the pickling boundary and the guard, along with why
CPU-bound work goes to processes rather than threads unless the
interpreter is free-threaded.
Can programming make provable claims? Functional programming answers with a spectrum, not a single guarantee. These chapters build three properties: purity, immutability, and referential transparency. Together they provide confidence at every level.
Style contributes before the first
rung of that spectrum. Declarative code states the
result you want. Imperative code spells out each step
to produce it. A comprehension
describes the result, “the squares of the even numbers.” match
describes the shapes you expect, the way Error
Handling takes a Result apart with one branch
per kind of failure. A description of the result is easier to
check than a sequence of steps, because it has no intermediate
state, such as an accumulator or a loop counter, to get
wrong.
A description of the result also leaves the steps to the runtime. A SQL query, a NumPy expression, or a dataframe operation describes its result. The library chooses an optimized or parallel engine to compute that result.
You decide how far up the spectrum to go.
Any, a
cast(), or the type written for data arriving from
outside the program. The theorem holds only for the code where
the checker verifies every type. Running ty over
the examples in this book reports mistakes such as an argument
of the wrong type, a misspelled attribute, or a
possibly-None value used without a check, and that
is most of what this rung offers.The functions under test are an encoder and its decoder, in a
module of their own so that every test can import them.
encode() writes the text’s UTF-8 bytes as
hexadecimal digits, and decode() turns the digits
back into text. encode() is not its own inverse, so
a test that encodes and then decodes exercises two different
functions:
# codec.py
def encode(text: str) -> str:
return text.encode().hex()
def decode(encoded: str) -> str:
return bytes.fromhex(encoded).decode()A hand-written property check loops over random inputs and asserts the roundtrip law: decoding an encoding returns the original.
# property_check.py
import random
from codec import decode, encode
random.seed(42) # A failing search must be reproducible
alphabet = "abcde"
for _ in range(1000):
sample = "".join(
random.choices(alphabet, k=random.randint(0, 8)))
assert decode(encode(sample)) == sample
print("1000 random cases passed")
#: 1000 random cases passedEvery property test has this shape: a law that states what must always be true, and a machine that searches for a counterexample.
A bare assert like
this one reports a broken law as an AssertionError.
The traceback shows the assert’s source line and leaves out the
value that broke the law. To find that value, you add a
print() and rerun. A tool like Hypothesis
runs the same search with inputs it chooses at boundaries and
unusual values, and shrinks any failure to a minimal
counterexample.
Hypothesis replaces the
hand-written loop with a declaration. You describe the inputs
with a strategy, Hypothesis’s name for an input
generator, and state the law once, as a normal
test_ function. The framework supplies the cases.
It draws on every character UTF-8 can encode, so it reaches
inputs that property_check.py’s
five-letter alphabet excludes, such as unusual Unicode:
# test_property.py
from codec import decode, encode
from hypothesis import given, strategies
@given(strategies.text())
def test_roundtrip(sample: str) -> None:
assert decode(encode(sample)) == sample@given(strategies.text()) wraps
test_roundtrip(), and each call to the wrapper runs
the original body once per generated string. By default
Hypothesis generates a hundred of them, a tenth of the
hand-written loop’s thousand. Those hundred strings cover more
of the input space, because Hypothesis generates boundary values
and unusual characters instead of sampling evenly.
When a law fails, Hypothesis reports the failing input, which
property_check.py’s bare
assert leaves out. Hypothesis also shrinks that
input to the smallest example that still fails, so the report
shows the simplest failing case rather than whichever random one
failed first. The framework automates falsification.
Shrinking needs a failure, but property_check.py and test_property.py both
pass. The next codec has a bug that Hypothesis exposes with unusual Unicode:
# shrinking.py
from codec import encode
from hypothesis import given, settings, strategies
def decode(encoded: str) -> str:
return bytes.fromhex(encoded).decode("latin-1")
@settings(derandomize=True, database=None)
@given(strategies.text())
def roundtrip(sample: str) -> None:
assert decode(encode(sample)) == sample
try:
roundtrip()
except AssertionError as e:
print(e.__notes__[0])
#: Failing test case: roundtrip(
#: sample='\x80',
#: )
print(repr(decode(encode("\x80")))) # [1]
#: 'Â\x80'shrinking.py imports
encode() from codec.py, so text still
becomes UTF-8 bytes. But shrinking.py defines its
own decode(), which reads those bytes back as
Latin-1 instead of UTF-8. UTF-8 and Latin-1 agree on the 128
ASCII code points, and property_check.py’s
five-letter alphabet is all ASCII. Every string its loop builds
decodes the same way under both, so all thousand cases pass.
Hypothesis draws from every character UTF-8 can encode. It
shrinks its failure to '\x80', the smallest code
point on which the two decodings disagree. '\x80'
is the first character UTF-8 encodes in two bytes instead of
one. Decoding those two bytes as Latin-1 returns two characters
where one went in ([1]), so the round trip returns
'Â\x80' instead of '\x80'. That code
point is the unusual Unicode the hand-written loop’s alphabet
excludes. Hypothesis finds it by calling decode()
and comparing results, without reading its body.
derandomize=True
seeds the search from a hash of the test function, so this book
gets the same answer on every run. random.seed(42)
makes the hand-written loop draw the same strings on every run.
database=None turns off the example database, so
every run searches from scratch. A real test keeps both
defaults, a randomized search and a database that replays past
failures.
roundtrip() exists to fail, so the listing calls
it inside a try. Its name drops the
test_ prefix, since it is a demonstration rather
than a test for pytest to collect.
The roundtrip law belongs to a
small family of reusable property shapes. Knowing the family is
most of the skill. An invariant states a fact about
every output. Sorting produces an ordered list.
Idempotence states that running an operation twice
gives the same result as running it once. Sorting a sorted list
returns the same order. An oracle states that two
implementations agree. The simple version you can check by
reading matches the fast one. parallel_pure.py’s
assert parallel == serial makes that claim about
map() and pool.map().
Avoid a law that restates the implementation. Asserting
encode(text) == text.encode().hex() compares
encode() with a copy of its body, so the test and
the code share any bug. A good law, like the roundtrip law,
constrains what the function computes and leaves the body to
decide how.
Every shape in the family requires purity. Hypothesis can
rerun and shrink freely because each call depends on its
arguments alone. A test whose outcome also depends on hidden
state can pass when Hypothesis replays the failing input.
Hypothesis then raises a FlakyFailure in place of a
shrunk counterexample.
Two caveats limit the chapter’s argument. First, proof works on imperative code too. Hoare logic and tools like Dafny verify imperative code. Purity changes the length of the proof. With no mutable state to track, each step of the reasoning is shorter. Functional programming does not make correctness provable so much as it makes the proof short enough to write.
Second, most functional code stays well below the top rung. Haskell programmers rely on types and on reasoning by substitution, and write a full proof for the few places that need one.
The claim these chapters share is that purity, immutability, and referential transparency reduce the work of turning “I believe this is correct” into “I can show why.” A full proof does all of that work. The everyday gain comes from doing part of it: code you can read, check, and test as statements about what is true. That gain, more than the presence of functions, is the “functionality” the introduction sets out to find.
Part V extends the same discipline and asks the type checker to enforce it. Effect Management puts a function’s effects in its signature, and the chapters after it build a checked system on that idea.
Try each exercise before opening its solution.
count_primes() to return
(count, os.getpid()) and print how many distinct
process IDs appear alongside the counts. Narrow
assert parallel == serial to compare only the
counts, since the serial run now carries the parent’s ID and the
parallel one carries the workers’ IDs. Compare the number of
distinct IDs to os.process_cpu_count(), and run parallel_pure.py three
times before deciding what that number means.ProcessPoolExecutor with
ThreadPoolExecutor in the previous exercise and
explain the IDs you see instead.sorted() using
two shapes from A Family
of Property Shapes: an invariant (every adjacent pair of the
output is in order) and idempotence (sorting a sorted list
returns an equal list). Then add the oracle property that
sorted(xs) agrees with a hand-written insertion
sort on short lists.@given(strategies.text()) with
assert s.upper().lower() == s.lower(). Report the
counterexample Hypothesis finds after shrinking. Run the test a
few times, deleting the .hypothesis/ directory
before each run, to see which characters Hypothesis reports.
Then explain what those characters reveal about Unicode case
mapping.group_rounds() from
Functional
Toolkits: for any roster and any positive group size, every
student appears in exactly one group per round. Use a strategy
that generates rosters of distinct names. Then break
group_rounds() on purpose, run the test twice, and
confirm Hypothesis reports the same counterexample both times.
Hypothesis records a failing case under
.hypothesis/ and replays it first on the next
run.global, write two functions that
break referential transparency: one that reads
datetime.now(), and one that reads an environment
variable. For each, name the substitution that changes the
program’s behavior, then rewrite the function so the value
arrives as an argument.describe() function from Error
Handling and rewrite its match as
isinstance() tests. Count the lines, then run
ty on both versions and compare the type it infers
for the value inside the Ok in each.