Zum Hauptinhalt springen

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

TermMeaning
first-class objecta value that can be stored, passed, and returned
higher-order functiona function accepting or returning functions
callbackbehaviour passed for later invocation
closurea function retaining enclosing bindings
callablean object supporting invocation
bound methoda method associated with an instance
partial applicationfixing some arguments in advance
strategyinterchangeable behaviour
dispatchselecting and invoking behaviour
introspectionexamining 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:

  1. why functions are runtime objects;
  2. passing versus invoking;
  3. higher-order functions;
  4. closures and configured behaviour;
  5. callable objects;
  6. bound methods;
  7. function-based strategies;
  8. function-based dependency injection.