Pure Functions, Immutability and Recursion
advanced35 minLearning objectives
- Explain pure functions and immutable data
- Identify unintended mutation and hidden state in given code
- Evaluate the role of recursion in functional programming
Learn
AQA 4.12.1 — Pure functions and immutability
Retrieval: the previous lesson showed the same "apply a discount" task written procedurally, object-orientedly and functionally, and used the functional version — the one that returned a new list instead of mutating the original — to introduce transformation over mutation. This lesson makes that idea precise, with a name and a test you can apply to any function: is it pure?
Key vocabulary
- Pure function — a function that always returns the same output for the same input, and has no side effects.
- Side effect — any change a function makes that's visible outside itself: reassigning a variable it didn't create, mutating an argument, printing, writing to a file.
- Immutable — a value that cannot be changed after creation; producing a modified version means creating a new value, not editing the existing one.
- Hidden state / hidden dependency — a value a function's output secretly depends on, without that value being passed in as a parameter — most often a global variable.
Understand — the two-part test for purity
A function is pure only if both of these hold:
- Same input, same output, every time — no dependency on anything that could change between calls (today's date, a random number, a global variable, a file's current contents).
- No side effects — calling it doesn't change anything that exists outside it.
# Pure - always the same output for the same input, no side effects
def add_tax(price, rate):
return price * (1 + rate)
# Impure - depends on hidden external state (fails test 1)
tax_rate = 0.2
def add_tax_impure(price):
return price * (1 + tax_rate) # works only because tax_rate happens to be 0.2 right now
# Impure - mutates something outside itself (fails test 2)
def apply_discount(prices):
for i in range(len(prices)):
prices[i] = prices[i] * 0.8 # side effect: the caller's own list changes
add_tax_impure isn't wrong, exactly — it genuinely returns the correct answer today. The problem is that it only works because tax_rate happens to be set correctly at the moment it's called; change tax_rate somewhere else in a large program, or call this function before tax_rate is even defined, and it silently breaks or crashes, for a reason invisible anywhere in the function itself.
Why purity is valuable
Pure functions are trivial to test — call it, check the output, no setup of external state needed, and the same test will pass forever, since the answer can never depend on when you run it. They're also easy to reason about in isolation: nothing else in the program can be affected by calling a pure function, so you never need to check "what else does this touch?" before calling it. Functional programming favours immutable data for exactly the same reason: if a value can never change after creation, an entire category of bug — something unexpectedly modifying data another part of the program is still relying on — simply cannot happen.
Recursion fits naturally
Because functional programming avoids mutating variables (like a loop counter), recursion — which needs no mutable loop variable at all — is the paradigm's natural way to express repetition:
def sum_list(numbers):
if not numbers: # base case
return 0
return numbers[0] + sum_list(numbers[1:]) # recursive case, nothing reassigned
Compare this with an iterative version that mutates an accumulator (total += n) on every pass: the recursive version never reassigns anything at all — each call simply returns a new value built from a smaller version of the same problem.
Retrieval check: is Sequence 10's factorial(n) — return 1 if n == 0 else n * factorial(n - 1) — pure? (Yes: given the same n it always returns the same result, and it neither mutates anything nor depends on anything outside its own parameter.)
Debug it — diagnose, explain, fix, test, justify (unintended mutation)
def apply_discount(prices):
for i in range(len(prices)):
prices[i] = prices[i] * 0.8
return prices
original_prices = [10, 20, 30]
discounted = apply_discount(original_prices)
print(original_prices) # expected: [10, 20, 30] unchanged
This prints [8.0, 16.0, 24.0] — the original list has changed, even though the calling code never reassigned original_prices itself.
- Diagnose: does
prices[i] = ...inside the function create a new list, or does it change the exact same list object the caller passed in? - Explain: in Python, a list argument is passed by reference to the underlying object — indexed assignment (
prices[i] = ...) mutates that shared object directly, it doesn't create a local copy. - Fix: rewrite
apply_discountso it builds and returns a brand-new list, leavingprices(and thereforeoriginal_prices) completely untouched. - Test: confirm
original_pricesstill prints[10, 20, 30]after calling the fixed function. - Justify: explain why the fixed version is a pure function and the original was not.
(The original mutates the list object it was given - indexed assignment changes the shared object in place, so original_prices and prices are the same list under two names, and any change through either name is visible through both. The fix is def apply_discount(prices): return [p * 0.8 for p in prices] - a new list is built and returned, and the caller's original list is never touched. The fixed version is pure: same input always gives the same output, and it has no side effects, since nothing outside the function is ever modified. The original was impure specifically because of its side effect - not because its output was wrong.)
Debug it — diagnose, explain, fix, test, justify (hidden external-variable dependency)
tax_rate = 0.2
def price_with_tax(price):
return price * (1 + tax_rate)
print(price_with_tax(100)) # 120.0 - looks correct
tax_rate = 0.0 # somewhere else in the program, tax_rate is later changed
print(price_with_tax(100)) # 100.0 - same call, different answer
The exact same call, price_with_tax(100), produces two different results, because the function's answer secretly depends on something that isn't one of its parameters.
- Diagnose: which of the two purity tests does
price_with_taxfail — "same input, same output" or "no side effects"? - Explain: why does this function "work" the first time it's called, making the bug easy to miss during quick testing?
- Fix: rewrite
price_with_taxsotax_rateis passed in as a parameter instead of read from the outer scope. - Test: confirm the fixed version gives the same answer for the same two arguments regardless of what
tax_ratehappens to be set to elsewhere in the program. - Justify: explain why a function that only reads (never modifies) an external variable can still be impure.
(It fails "same input, same output" - price_with_tax(100) alone isn't enough information to know the result, because the answer also depends on the current value of a variable the function never received as a parameter. It "works" the first time purely by coincidence, because tax_rate happens to hold the value the function needs at that moment - nothing about the function itself guarantees that. The fix is def price_with_tax(price, rate): return price * (1 + rate), called as price_with_tax(100, tax_rate) - the dependency becomes explicit and visible at the call site instead of hidden. Reading (not writing) an external variable still breaks purity because "same input, same output" is about the FUNCTION's own declared inputs - if the true inputs to the calculation aren't all passed as parameters, the function's output depends on something invisible to its own signature, which is exactly what a hidden dependency is.)
Common mistake
Assuming a function is pure just because it doesn't use global to write to an external variable. Reading an external variable without a global keyword is still a hidden dependency and still breaks purity, exactly as the second debug task shows — global only matters for writing, not for the purity test, which cares about any dependency on anything outside the function's own parameters.
Check your understanding
Is this function pure? def is_even(n): return n % 2 == 0. Justify your answer. (Yes - for any given n it always returns the same True/False result, and it neither reads nor modifies anything outside its own parameter n.)
Challenge
Find (or write) a short Python function from an earlier sequence's coding challenge that is currently impure, and rewrite it to be pure — state clearly which purity test it originally failed and why your rewrite now passes both.
Why this matters for the NEA
A function whose result depends only on its own parameters is trivial to unit-test in isolation, exactly the kind of testable, well-scoped component AQA's NEA marking rewards — a function that secretly depends on program-wide state can only be tested by first recreating that entire state correctly, a genuine, avoidable source of NEA testing difficulty.
Looking ahead: the next lesson builds on immutability and transformation directly — treating functions themselves as values that can be passed around and combined, the foundation of map, filter and function composition.