Higher-Order Functions
advanced40 minLearning 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 owndef. map()— applies a function to every element of an iterable, returning a new transformed sequence, one output per input.filter()— applies a function that returnsTrue/Falseto every element of an iterable, keeping only the elements it returnsTruefor.- 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:
- Given a list of names, produce a list of their lengths.
- Given a list of temperatures, keep only the ones above freezing.
- 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.
- Diagnose: does each
lambda x: x * icapture the valueihad at the moment it was created, or a live reference to the variableiitself? - Explain: by the time any of the three lambdas is actually called (in the second loop), what is the value of
iin the enclosing scope, and how many separateivariables actually exist across the whole first loop? - Fix: rewrite the first loop so each lambda captures its own, independent value of
iat creation time (a default-argument trick —lambda x, i=i: x * i— is one standard fix). - Test: confirm the fixed version prints
0,10,20. - Justify: explain why this is specifically a lambda/closure bug, not a
map/filterbug — 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'.
- Diagnose: by the time
filter's lambda (n > 0) runs, hasmap(str, numbers)already converted every number to a string, or doesfiltersee the original numbers? - Explain: why does comparing a string to
0with>raise aTypeErrorrather than just silently giving the wrong answer? - Fix: rewrite the composition so
filterruns first, while the values are still numbers, andmap(str, ...)runs second, only on the values that survived filtering. - Test: confirm the fixed version prints
['4', '12', '9'], with no error. - 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.