Higher-Order Functions

advanced40 min

Learning objectives

  • Explain how higher-order functions improve code reuse and readability
  • Apply map, filter and lambda expressions to transform and select data
  • Compose functions correctly and diagnose incorrect higher-order-function use

Learn

AQA 4.12.1 — Higher-order functions: map, filter and composition

Retrieval: the previous lesson established that functions should avoid mutating anything outside themselves — genuinely pure functions instead take input and return a new, transformed value. This lesson takes that idea further: if functions just take values and return values, there's no reason a function itself can't be one of those values, passed into and returned from other functions.

Key vocabulary

  • First-class function / functions as values — in Python, a function can be assigned to a variable, stored in a list, passed as an argument, and returned from another function, exactly like any other value (an int, a string, a list).
  • Higher-order function — a function that takes another function as an argument, returns a function, or both.
  • Lambda expression — a small, unnamed function written inline as lambda parameters: expression, useful when a function is only needed briefly and doesn't deserve its own def.
  • map() — applies a function to every element of an iterable, returning a new transformed sequence, one output per input.
  • filter() — applies a function that returns True/False to every element of an iterable, keeping only the elements it returns True for.
  • Function composition — building a new function by feeding one function's output directly into another's input.

Understand — a function is just a value

def square(x):
    return x * x

operation = square        # square itself (not square()) is assigned to a variable
print(operation(5))        # 25 - calling operation calls square

def apply_twice(func, value):     # func is a PARAMETER - a function passed in as an argument
    return func(func(value))

print(apply_twice(square, 3))     # 81 - square(square(3)) = square(9) = 81

apply_twice is a higher-order function: it takes another function, square, as one of its own arguments. Nothing here is special Python magic — square is simply a value like any other, and operation = square assigns the function itself, not the result of calling it (note there are no parentheses after square).

Transform it — map()

prices = [10, 20, 30]
discounted = list(map(lambda p: p * 0.8, prices))
print(discounted)   # [8.0, 16.0, 24.0]

This is exactly the functional apply_discount from two lessons ago, expressed with map() instead of a list comprehension — both produce a brand-new list and leave prices untouched; map() is simply another tool for the same non-mutating transformation.

Select it — filter()

scores = [45, 78, 32, 91, 55]
passing = list(filter(lambda s: s >= 50, scores))
print(passing)   # [78, 91, 55]

filter()'s lambda must return True or False for each element — it decides whether an element survives, unlike map()'s lambda, which decides what an element becomes. Confusing these two roles is the single most common map/filter mistake.

Compose it — chaining transformations

scores = [45, 78, 32, 91, 55]

passing_scores_as_grades = list(
    map(lambda s: "Distinction" if s >= 90 else "Pass",
        filter(lambda s: s >= 50, scores))
)
print(passing_scores_as_grades)   # ['Pass', 'Distinction', 'Pass']

This composes two higher-order functions: filter() runs first, narrowing scores down to the passing ones, and map() then transforms only those survivors into grade labels. Order matters here — filtering first means map() never even looks at the scores that didn't pass.

Identify it — map, filter, or both?

For each, state whether map(), filter(), or a composition of both is the right tool, and justify your choice in one sentence:

  1. Given a list of names, produce a list of their lengths.
  2. Given a list of temperatures, keep only the ones above freezing.
  3. Given a list of ages, produce the ages that are NOT adults (under 18), converted to the label "child".

(1: map() - every name produces exactly one output, its length; nothing is discarded. 2: filter() - elements are kept or discarded, never transformed. 3: filter() then map() composed - first keep only the under-18 ages, then transform each surviving age into the label "child".)

Debug it — diagnose, explain, fix, test, justify (incorrect lambda behaviour)

multipliers = []
for i in range(3):
    multipliers.append(lambda x: x * i)

for m in multipliers:
    print(m(10))

The intention is three different functions — "multiply by 0," "multiply by 1," "multiply by 2" — so this is expected to print 0, 10, 20. It actually prints 20, 20, 20.

  1. Diagnose: does each lambda x: x * i capture the value i had at the moment it was created, or a live reference to the variable i itself?
  2. Explain: by the time any of the three lambdas is actually called (in the second loop), what is the value of i in the enclosing scope, and how many separate i variables actually exist across the whole first loop?
  3. Fix: rewrite the first loop so each lambda captures its own, independent value of i at creation time (a default-argument trick — lambda x, i=i: x * i — is one standard fix).
  4. Test: confirm the fixed version prints 0, 10, 20.
  5. Justify: explain why this is specifically a lambda/closure bug, not a map/filter bug — the same mistake would happen with three separately-def-ined functions built the same way.

(Each lambda captures a live reference to the single shared variable i, not a snapshot of its value at creation time - there is only ever one i across the whole loop, not three. By the time any lambda is called, the first loop has already finished and i holds its final value, 2 - every lambda reads that same final value when called, regardless of which iteration created it. The fix, lambda x, i=i: x * i, works because default argument values ARE evaluated once, at function-definition time, capturing i's value at that exact moment as i's own independent default - one per lambda. This is a closure bug, not a map/filter bug, because it's about how any function (lambda or def) captures variables from an enclosing scope, unrelated to which higher-order function later calls it.)

Debug it — diagnose, explain, fix, test, justify (incorrect higher-order-function composition)

numbers = [4, -7, 12, -2, 9]

result = list(filter(lambda n: n > 0, map(str, numbers)))
print(result)

The intention is "convert the positive numbers to strings" — expected output ['4', '12', '9']. This instead raises TypeError: '>' not supported between instances of 'str' and 'int'.

  1. Diagnose: by the time filter's lambda (n > 0) runs, has map(str, numbers) already converted every number to a string, or does filter see the original numbers?
  2. Explain: why does comparing a string to 0 with > raise a TypeError rather than just silently giving the wrong answer?
  3. Fix: rewrite the composition so filter runs first, while the values are still numbers, and map(str, ...) runs second, only on the values that survived filtering.
  4. Test: confirm the fixed version prints ['4', '12', '9'], with no error.
  5. Justify: explain, in general terms, why the order two composed higher-order functions run in can change not just the result, but whether the code runs at all.

(map(str, numbers) runs first here, since it's the innermost call - by the time filter's lambda receives each element, it's already a string, not the original number, because map already converted everything before filter got a chance to look at it. Comparing a str to an int with > raises TypeError because Python has no defined ordering between the two types - unlike some languages, it refuses to guess. The fix is list(map(str, filter(lambda n: n > 0, numbers))) - filter now runs first on the real numbers, keeping only the positive ones, and map converts only those survivors to strings afterwards. In general, composed functions run from the innermost outward, and swapping their order can change which TYPE each stage receives, not just which VALUES - here it turned a working filter condition into a comparison between incompatible types.)

Common mistake

Assuming map()/filter() always run left-to-right, top-to-bottom the way separate lines of code do. A composed call like filter(f, map(g, data)) runs its innermost function first — map(g, data) completes (conceptually) before filter ever sees a single element — exactly the order-of-operations trap in the second debug task above.

Check your understanding

Given words = ["cat", "elephant", "dog", "hippopotamus"], state (in words, not code) what list(map(len, filter(lambda w: len(w) > 3, words))) would produce, and justify each step. (filter keeps words longer than 3 characters: "elephant" and "hippopotamus" survive ("cat" and "dog" are filtered out). map then converts each surviving word to its length: [8, 12]. The two composed functions apply in sequence, innermost first - filtering happens before the lengths are ever calculated.)

Challenge

Using only map(), filter() and lambda, no loops or list comprehensions, write an expression that takes a list of exam scores and produces the grade boundaries crossed — "Distinction" for 90+, "Merit" for 70–89, discarding anything below 70 entirely (no label produced for a failing score).

Looking ahead: the final lesson steps back and evaluates functional programming properly — not "is it correct," but "when is it genuinely the right choice," compared honestly against the procedural and object-oriented work already built across this course.

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