First-class functions: passing and returning functions
Overview
In Python, functions are objects. This is called first-class function support. Because functions are objects, they can be: - Assigned to variables - Stored in collections - Passed as arguments to other functions - Returned from other functions Assigning a function to a variable lets you call the function through that variable name. You can also pass a function by name to another function as an argument. def normalize(text): return text.strip().lower() transform = normalize # assign function to a variable result = transform(' Alice ') # call via the variable def apply(fn, value): # accept a function as an argument return fn(value) apply(normalize, ' Bob ') # pass a function by name Returning functions enables the function factory pattern. The outer function creates and configures an inner function, then returns it. def make_prefixer(prefix): def add_prefix(text): return f'{prefix}: {text}' return add_prefix # return the inner function error = make_prefixer('ERROR') print(error('disk full')) # ERROR: disk full The inner function `add_prefix` remembers `prefix` from the outer scope even after `make_prefixer` has returned. This is called a closure. A common trap with closures is late binding of variables inside a loop. def make_multipliers(): # all returned functions will capture the final value of i (which is 2) return [lambda x: x * i for i in range(3)] multipliers = make_multipliers() print(multipliers[0](10)) # prints 20, not 0! The closure captures the variable itself, not its value at the time the function was created.
Higher-order functions (functions that take or return functions) are the foundation of decorators, middleware pipelines, retry wrappers, and dependency injection. Without first-class functions, each of these patterns would require explicit `subclassing` instead of composable functions.
Where used: FastAPI middleware and dependency injection (`Depends` receives a function), Retry decorator wrapping route handlers, Pydantic `field_validator` receiving a validation function
Why learn this
- Understanding how FastAPI's `Depends()` and decorator `@app.get()` work — both receive functions as arguments
- Writing retry wrappers and middleware that accept a handler function and return an augmented version
Code walkthrough
def make_prefixer(prefix):
def add_prefix(text):
return f'{prefix}: {text}'
return add_prefix
error = make_prefixer('ERROR')
warn = make_prefixer('WARN')
print(error('disk full'))
print(warn('high memory'))
Focus: `make_prefixer` returns a new function each time; each returned function closes over its own `prefix` value — two independent prefixers from one factory.
Common mistakes
- Calling the function when you mean to pass it: `apply(normalize(), value)` calls `normalize` immediately and passes its RETURN VALUE (a string), not the function itself. Pass without parentheses: `apply(normalize, value)`.
- Losing __name__ when wrapping: A wrapper function that returns a new function hides the original's `__name__` and `__doc__`. Fix: use `@functools.wraps(fn)` inside the wrapper to copy that metadata.
- Forgetting the closure captures the variable, not the value: If a factory captures a loop variable without a default argument trick, all returned functions see the variable's final value. Use `def f(x=x):` to capture the current value.
Glossary
- closure
- A function that remembers and has access to variables from the environment where it was created, even after that environment is gone. Example: `def inner(): return val`
- subclassing
- The process of creating a new class based on an existing class to inherit or modify its behavior. Example: `class Child(Parent):`
Recall questions
- What does it mean for functions to be 'first-class' objects in Python?
- What is a higher-order function?
- What is a function factory?
- What is wrong with `apply(normalize(), value)` when you intend to pass `normalize` as a callback?
- Why might a list of functions created inside a loop using a lambda unexpectedly return the same result?
Understanding checks
What is the output of this code?
8 12
`multiply_by` is a function factory. It creates closures that remember the `factor` variable. `double` remembers `2`, `triple` remembers `3`. `double(4)` is `4 * 2 = 8`, `triple(4)` is `4 * 3 = 12`.
Why does this code fail with an error?
It calls `add_one()` immediately instead of passing the function object.
By adding parentheses in `apply_twice(add_one(), 5)`, we call `add_one` without arguments (which raises a `TypeError`) and pass its return value. We should pass the function itself: `apply_twice(add_one, 5)`.
What is the output of this code?
11 11
Due to late binding, both lambda functions capture the variable `i`, which has a final value of `1` when the loop finishes. Therefore, both adders add `1` to `10`.
Practice tasks
Pipeline runner
Modify the `process_text` function. Currently, it hardcodes calling `str.strip`, `str.lower`, and `str.split`. Change it to `run_pipeline(value, steps)`, which accepts a starting value and a list of functions (`steps`). It should iterate through the steps, passing the result of each step to the next function, and return the final value.
Challenge
Retry wrapper
Write `with_retry(fn, attempts)` that returns a new function. When the new function is called with any arguments, it tries `fn(*args, **kwargs)` up to `attempts` times, printing `'attempt <n>'` each try. On success it returns the result; if all attempts fail it raises the last exception. Test by wrapping a function that raises on the first two calls but succeeds on the third.
Continue learning
Previous: Defining functions and return values
Previous: LEGB rule: variable scope in Python
Previous: Lambda functions and when to use them