Module 7 · Functional & Decorative Python ⏱ 19 min

Higher-Order Functions

By the end of this lesson you will be able to:
  • Pass functions as arguments to other functions
  • Return functions from other functions
  • Explain why treating functions as values makes code more flexible

Imagine you have three different lists and you need the length of every item in each one. You write the loop once, copy it twice, and change the list name. Later you realise you also need to strip whitespace first, so you edit the first copy, forget the second, and now your results are inconsistent.

The real problem is not the loop — it is that the behaviour (what to do to each item) is welded to the mechanics (how to iterate). A higher-order function separates the two. It is any function that either accepts another function as an argument or returns a function as its result. Once you can pass behaviour around as data, you write the loop once and plug in the changing part.

flowchart LR
  A["main()"] -->|"pass len"| B["apply_to_all(fn, items)"]
  B --> C["fn(item) for each item"]
  C --> D["return results"]
  style B fill:#3776ab,color:#fff
A higher-order function receives a function value and calls it internally.

Functions as arguments

Python treats functions as first-class values. You can store them in variables, append them to lists, and hand them to other functions exactly like integers or strings. The built-in sorted already exploits this: passing key=len tells sorted to use the len function to decide the order of every item.

You can write your own higher-order tools. A reusable apply_to_all accepts any function and any list, runs the function on every item, and returns the results. Inside apply_to_all, the parameter fn is just a name that happens to hold a callable; you invoke it with parentheses exactly like any other function.

Passing `len` and a lambda into the same higher-order function.
def apply_to_all(fn, items):
    return [fn(item) for item in items]

words = ['cat', 'elephant', 'ant']
print(apply_to_all(len, words))
print(apply_to_all(lambda s: s.upper(), words))

The value-versus-call trap

Notice that len is written without parentheses. len is the function object itself; len() would call it immediately and pass the result (a number) instead of the function. That distinction — value versus call — is the first place higher-order code goes wrong.

The same trap appears when you store functions in a list. handlers = [print, len] stores two function objects. handlers = [print(), len()] would call them immediately and store their results (None and a number). If you later try to iterate over handlers and call each one, the second version explodes because integers are not callable.

flowchart TD
  A["make_multiplier(3)"] --> B["builds multiplier(x)"]
  B --> C["returns the function"]
  C --> D["triple = make_multiplier(3)"]
  D --> E["triple(4) -> 12"]
  style B fill:#3776ab,color:#fff
A factory function builds and returns a new function, configured by its arguments.

Functions as return values

A function can also build and hand back another function. This is sometimes called a factory pattern: the outer function configures the inner one, and the inner one remembers that configuration every time it is called later.

In the example below, make_multiplier(n) returns a new function that multiplies its argument by n. The returned function is a perfectly ordinary value — you can assign it to a name, pass it along, or call it immediately. Each factory call creates a fresh, independent function with its own captured value of n.

A factory that bakes a multiplier into the returned function.
def make_multiplier(n):
    def multiplier(x):
        return x * n
    return multiplier

triple = make_multiplier(3)
print(triple(4))
print(triple(10))

double = make_multiplier(2)
print(double(5))

Why this matters

Higher-order functions let you separate what to do from how to iterate. The looping logic lives in one place; the changing behaviour arrives as a function argument. That means less duplicated code, fewer off-by-one errors, and tests that target small, pure functions instead of sprawling loops.

You have already met one higher-order function in disguise: the comprehension. [fn(x) for x in items] is a loop that accepts the transformation as an expression. Passing a real function is the generalisation of that idea. The difference is that a comprehension is limited to expressions; a higher-order function can accept any callable, including named functions with docstrings and complex logic.

flowchart LR
  A["loop logic"] --> B["higher-order function"]
  C["behaviour: fn"] --> B
  B --> D["clean separation"]
  style B fill:#3776ab,color:#fff
Higher-order code separates iteration logic from the behaviour applied to each item.

Storing and dispatching functions

Because functions are values, you can store them in dictionaries for simple dispatch tables. Instead of a long if...elif chain that checks a string and runs the right block, you map strings directly to functions and look them up. This scales better than conditional chains, and adding a new operation means adding one dictionary entry instead of editing a branching structure.

ops = {
    'double': lambda x: x * 2,
    'negate': lambda x: -x,
}
result = ops['double'](5)  # 10

This pattern is the seed of more advanced designs like command patterns and plugin architectures. The higher-order idea — passing behaviour as data — is what makes them possible.

Functions stored in a dictionary create a simple dispatch table.
def double(x):
    return x * 2

def negate(x):
    return -x

ops = {'double': double, 'negate': negate}
print(ops['double'](5))
print(ops['negate'](3))
Exercise

Write apply_to_all(fn, items) that returns a new list containing fn(item) for every item in items. Use a list comprehension.

def apply_to_all(fn, items):
    # your code here
    pass
Exercise

Read carefully. The variable shout holds a function; shout('hi') calls it.

def make_shout(word):
    def shout():
        return word.upper()
    return shout

say_hi = make_shout('hi')
print(say_hi())
Exercise

Write make_adder(n) that returns a function which adds n to its argument. So add5 = make_adder(5) and add5(10) gives 15.

def make_adder(n):
    # your code here
    pass
Exercise

This factory is meant to return a function, but it calls the inner function immediately and returns the result (a number). Fix it so it returns the function itself.

def make_suffixer(suffix):
    def suffixer(word):
        return word + suffix
    return suffixer('test')
Exercise

Write select_and_transform(items, test_fn, transform_fn) that keeps only items where test_fn(item) is truthy, then applies transform_fn to each kept item. Return the final list. Do this in one comprehension.

def select_and_transform(items, test_fn, transform_fn):
    # one comprehension with an if clause
    pass

Recap

  • Functions are values: you can store them in variables, append them to lists, pass them as arguments, and return them from other functions.
  • A higher-order function takes a function as an argument or returns one as a result.
  • Pass the function object (len) rather than calling it (len()). Calling too early is the most common beginner mistake.
  • Factory functions build configured functions and return them for later use; each call creates an independent closure.
  • Function dispatch tables replace long if...elif chains with lookups, making code easier to extend.

Next you will meet three classic higher-order tools — map, filter, and reduce — that turn common loop patterns into single expressions.

Checkpoint quiz

What is the difference between fn and fn() when passed to another function?

What does make_multiplier(2)(5) return if make_multiplier returns an inner function that multiplies its argument by the captured n?

Go deeper — technical resources