Chapter 6 — Functions as First-Class Objects
1. Opening problem
def greet(name: str) -> str:
return f"Hello, {name}"
message_builder = greet
print(message_builder("Steve"))
greet is not called during assignment. The function object is assigned to another name.
Functions in Python can be:
- stored;
- passed;
- returned;
- inspected;
- decorated;
- placed in collections;
- used as strategies;
- used as dependencies.
This model is central to idiomatic Python architecture.
2. Function objects
def add(left: int, right: int) -> int:
"""Add two integers."""
return left + right
The name add is bound to a function object.
print(type(add))
print(add.__name__)
print(add.__doc__)
print(add.__annotations__)
print(add.__module__)
Frameworks use function metadata for routing, validation, dependency injection, command discovery, and documentation.
3. Passing versus calling
These are different:
handler = add
result = add(2, 3)
The first stores the function. The second stores its returned value.
A callback bug:
scheduler.register(clean_up())
This calls clean_up immediately.
Correct:
scheduler.register(clean_up)
The scheduler receives behaviour to invoke later.
4. Higher-order functions
A higher-order function accepts or returns functions.
from collections.abc import Callable
def apply_twice(
function: Callable[[int], int],
value: int,
) -> int:
return function(function(value))
Usage:
def increment(value: int) -> int:
return value + 1
assert apply_twice(increment, 10) == 12
Functions make behaviour explicit input.
5. Generic transformation
from collections.abc import Callable, Iterable
from typing import TypeVar
T = TypeVar("T")
R = TypeVar("R")
def transform_all(
values: Iterable[T],
transformer: Callable[[T], R],
) -> list[R]:
return [
transformer(value)
for value in values
]
The two type variables preserve the relationship between input and output.
lengths = transform_all(
["Python", "TypeScript"],
len,
)
A weak annotation such as Callable without parameter types loses valuable information.
6. Returning functions and closures
from collections.abc import Callable
def create_multiplier(
factor: int,
) -> Callable[[int], int]:
def multiply(value: int) -> int:
return value * factor
return multiply
Usage:
double = create_multiplier(2)
triple = create_multiplier(3)
assert double(10) == 20
assert triple(10) == 30
Each returned function retains its own enclosing factor binding.
Closures are useful for:
- configured validators;
- callbacks;
- decorators;
- adapters;
- small stateful operations;
- dependency injection.
7. Functions in collections
def start() -> str:
return "started"
def stop() -> str:
return "stopped"
def restart() -> str:
return "restarted"
COMMANDS = {
"start": start,
"stop": stop,
"restart": restart,
}
Dispatch:
def execute(command_name: str) -> str:
try:
command = COMMANDS[command_name]
except KeyError as error:
raise ValueError(
f"Unknown command: {command_name}"
) from error
return command()
A dispatch table often communicates the mapping better than a long conditional chain.
8. Lambdas
A lambda creates a small anonymous function:
square = lambda value: value * value
Equivalent:
def square(value):
return value * value
A good use:
users.sort(
key=lambda user: user.last_name
)
A poor use:
result = list(
map(
lambda user: {
"name": user.name.strip().title(),
"active": user.status == "enabled",
},
filter(
lambda user: user.email is not None,
users,
),
)
)
For complex logic, use a named function or a comprehension.
9. map, filter, and comprehensions
normalized = list(
map(str.lower, emails)
)
Comprehension:
normalized = [
email.lower()
for email in emails
]
Filter:
active_users = list(
filter(
lambda user: user.active,
users,
)
)
Comprehension:
active_users = [
user
for user in users
if user.active
]
Python commonly prefers comprehensions because they expose both transformation and condition directly.
map remains elegant when an existing function already expresses the operation:
normalized = list(
map(normalize_email, emails)
)
10. Callable objects
A class can implement __call__:
class Prefixer:
def __init__(self, prefix: str) -> None:
self._prefix = prefix
def __call__(self, value: str) -> str:
return f"{self._prefix}{value}"
Usage:
error_prefixer = Prefixer("ERROR: ")
print(
error_prefixer("Database unavailable")
)
Callable objects fit behaviour that needs configuration or state.
A protocol can describe both normal functions and callable objects:
from typing import Protocol
class StringTransformer(Protocol):
def __call__(
self,
value: str,
) -> str:
...
11. Bound methods
class Greeter:
def greet(self, name: str) -> str:
return f"Hello, {name}"
greeter = Greeter()
method = greeter.greet
method is bound to greeter.
method("Steve")
Conceptually resembles:
Greeter.greet(greeter, "Steve")
Compare:
print(Greeter.greet)
print(greeter.greet)
The first is accessed through the class. The second remembers the instance.
12. Partial application
from functools import partial
def send_message(
channel: str,
recipient: str,
message: str,
) -> None:
print(channel, recipient, message)
send_email = partial(
send_message,
"email",
)
Usage:
send_email(
"steve@example.com",
"Welcome",
)
A wrapper function may be easier to type and document:
def send_email(
recipient: str,
message: str,
) -> None:
send_message(
"email",
recipient,
message,
)
Use partial for simple local preconfiguration, not to hide complex semantics.
13. Function-based strategy pattern
Class-style strategy:
from typing import Protocol
class PricingStrategy(Protocol):
def calculate(
self,
subtotal: float,
) -> float:
...
Function-style strategy:
from collections.abc import Callable
PricingStrategy = Callable[
[float],
float,
]
Implementations:
def regular_price(
subtotal: float,
) -> float:
return subtotal
def ten_percent_discount(
subtotal: float,
) -> float:
return subtotal * 0.90
Use:
def calculate_total(
subtotal: float,
strategy: PricingStrategy,
) -> float:
return strategy(subtotal)
Use a function when there is one stateless operation.
Use a callable object or class when behaviour needs:
- multiple operations;
- meaningful state;
- lifecycle;
- rich configuration;
- inspection;
- several dependencies.
14. Dependency injection with functions
from collections.abc import Callable
UserExists = Callable[[str], bool]
SaveUser = Callable[[str], None]
SendWelcome = Callable[[str], None]
def register_user(
email: str,
*,
user_exists: UserExists,
save_user: SaveUser,
send_welcome: SendWelcome,
) -> None:
if user_exists(email):
raise ValueError(
"User already exists"
)
save_user(email)
send_welcome(email)
Test:
saved: list[str] = []
sent: list[str] = []
register_user(
"steve@example.com",
user_exists=lambda _: False,
save_user=saved.append,
send_welcome=sent.append,
)
assert saved == ["steve@example.com"]
assert sent == ["steve@example.com"]
This is lightweight dependency injection without a container.
15. Closures versus classes
Closure:
def create_counter():
value = 0
def increment():
nonlocal value
value += 1
return value
return increment
Class:
class Counter:
def __init__(self) -> None:
self._value = 0
def increment(self) -> int:
self._value += 1
return self._value
Prefer a closure when:
- state is small;
- only one operation is exposed;
- internal state should remain private.
Prefer a class when:
- multiple operations exist;
- state needs inspection;
- representation matters;
- lifecycle is meaningful;
- a protocol or inheritance relationship matters.
16. TypeScript comparison
TypeScript:
type Transformer =
(value: string) => string;
function apply(
value: string,
transformer: Transformer
): string {
return transformer(value);
}
Python:
from collections.abc import Callable
Transformer = Callable[[str], str]
def apply(
value: str,
transformer: Transformer,
) -> str:
return transformer(value)
Both languages support callbacks and closures. Python additionally integrates callable behaviour through __call__, descriptors, decorators, and runtime introspection.
17. Common mistakes
Calling instead of passing
run_later(task())
versus:
run_later(task)
Complex lambdas
Name the operation.
Overusing classes
A stateless one-operation strategy may be a function.
Overusing closures
Complex mutable state often belongs in a class.
Weak callable annotations
Preserve parameter and return types.
Hidden global dependencies
Pass behaviour explicitly.
18. English vocabulary
| Term | Meaning |
|---|---|
| first-class object | a value that can be stored, passed, and returned |
| higher-order function | a function accepting or returning functions |
| callback | behaviour passed for later invocation |
| closure | a function retaining enclosing bindings |
| callable | an object supporting invocation |
| bound method | a method associated with an instance |
| partial application | fixing some arguments in advance |
| strategy | interchangeable behaviour |
| dispatch | selecting and invoking behaviour |
| introspection | examining runtime metadata |
Useful sentences:
- “The callback is passed without being invoked.”
- “This closure retains the configured threshold.”
- “A function is sufficient because the strategy is stateless.”
- “The callable object is justified because the behaviour needs state.”
- “The dispatch table replaces a long conditional chain.”
19. Speaking task
Explain for seven minutes:
How first-class functions change Python architecture.
Include callbacks, closures, strategies, dispatch tables, and dependency injection.
20. Writing task
Compare function-based, closure-based, and class-based strategies in approximately 300 words.
21. Exercises
Exercise 1
Replace a start/stop/restart conditional chain with a dispatch table.
Exercise 2
Implement a generic transform_all.
Exercise 3
Create a callable RangeValidator.
Exercise 4
Create:
format_currency = create_formatter(
prefix="$",
decimals=2,
)
Exercise 5
Write a delete_user function with function dependencies for existence checking, removal, and audit logging.
22. Complete solutions
Solution 1
COMMANDS = {
"start": start,
"stop": stop,
"restart": restart,
}
def execute(command: str) -> str:
try:
handler = COMMANDS[command]
except KeyError as error:
raise ValueError(
f"Unknown command: {command}"
) from error
return handler()
Solution 2
from collections.abc import Callable, Iterable
from typing import TypeVar
T = TypeVar("T")
R = TypeVar("R")
def transform_all(
values: Iterable[T],
transformer: Callable[[T], R],
) -> list[R]:
return [
transformer(value)
for value in values
]
Solution 3
class RangeValidator:
def __init__(
self,
minimum: int,
maximum: int,
) -> None:
if minimum > maximum:
raise ValueError(
"minimum must not exceed maximum"
)
self._minimum = minimum
self._maximum = maximum
def __call__(self, value: int) -> int:
if not (
self._minimum
<= value
<= self._maximum
):
raise ValueError(
"Value outside configured range"
)
return value
Solution 4
from collections.abc import Callable
def create_formatter(
*,
prefix: str,
decimals: int,
) -> Callable[[float], str]:
if decimals < 0:
raise ValueError(
"decimals must not be negative"
)
def format_value(
value: float,
) -> str:
return (
f"{prefix}"
f"{value:.{decimals}f}"
)
return format_value
Solution 5
from collections.abc import Callable
UserExists = Callable[[int], bool]
RemoveUser = Callable[[int], None]
WriteAudit = Callable[[str], None]
def delete_user(
user_id: int,
*,
user_exists: UserExists,
remove_user: RemoveUser,
write_audit: WriteAudit,
) -> None:
if not user_exists(user_id):
raise LookupError(
f"User {user_id} not found"
)
remove_user(user_id)
write_audit(
f"Deleted user {user_id}"
)
23. Chapter checkpoint
You should now be able to explain:
- why functions are runtime objects;
- passing versus invoking;
- higher-order functions;
- closures and configured behaviour;
- callable objects;
- bound methods;
- function-based strategies;
- function-based dependency injection.