Pure Functions, Immutability and Recursion

advanced35 min

Learning 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:

  1. 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).
  2. 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.

  1. Diagnose: does prices[i] = ... inside the function create a new list, or does it change the exact same list object the caller passed in?
  2. 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.
  3. Fix: rewrite apply_discount so it builds and returns a brand-new list, leaving prices (and therefore original_prices) completely untouched.
  4. Test: confirm original_prices still prints [10, 20, 30] after calling the fixed function.
  5. 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.

  1. Diagnose: which of the two purity tests does price_with_tax fail — "same input, same output" or "no side effects"?
  2. Explain: why does this function "work" the first time it's called, making the bug easy to miss during quick testing?
  3. Fix: rewrite price_with_tax so tax_rate is passed in as a parameter instead of read from the outer scope.
  4. Test: confirm the fixed version gives the same answer for the same two arguments regardless of what tax_rate happens to be set to elsewhere in the program.
  5. 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.

Practise

Apply what you've just learned in the Coding Lab.

Open Coding Lab

Test yourself

Check your understanding with exam-style questions.

Go to Exam Practice
Log in to track this lesson on your progress dashboard.
Log in