Functions In Depth
Functions are first-class objects
Section titled “Functions are first-class objects”In Python, functions are values — not special syntax, not second-class constructs. A function can be assigned to a variable, stored in a list, passed as an argument, or returned from another function. This property is called first-class status, and it is the foundation of every advanced pattern in this module.
def greet(name: str) -> str: return f"Hello, {name}!"
# Assign to a variablesay_hi = greet
# Pass as an argumentdef apply(fn, value): return fn(value)
result = apply(greet, "Python")print(result) # Hello, Python!Because greet is just an object, say_hi and greet point to the same function.
Calling say_hi("World") is identical to calling greet("World").
What this module covers
Section titled “What this module covers”| Lesson | Core concept |
|---|---|
| Args & Kwargs | Positional, keyword, variadic, and keyword-only parameters |
| Closures | Nested functions, free variables, and nonlocal |
| Decorators | Functions that wrap other functions; functools.wraps |
| Generators | yield, lazy evaluation, and StopIteration |
Each lesson is self-contained and builds on this foundation: functions are objects you can manipulate freely.
A first runnable demo
Section titled “A first runnable demo”The snippet below demonstrates two core ideas simultaneously: passing a function as an argument, and returning a function as a value.
def make_multiplier(factor: int): def multiply(x: int) -> int: return x * factor return multiply
double = make_multiplier(2)triple = make_multiplier(3)
numbers = [1, 2, 3, 4, 5]doubled = list(map(double, numbers))tripled = list(map(triple, numbers))
print("doubled:", doubled)print("tripled:", tripled)
# Functions are objects — we can store them in a listops = [double, triple, make_multiplier(10)]for op in ops: print(op(7))Loading Python runtime (first run only)…
Dispatching on data instead of an if/elif chain
Section titled “Dispatching on data instead of an if/elif chain”Because functions are ordinary values, they can be stored as dict values, keyed by whatever selects the behavior — a string, an enum, a type. Looking a function up in a dict and calling it replaces a long if/elif chain with one dict lookup, and adding a new case means adding one entry instead of one more elif.
def area_circle(r): return 3.14159 * r ** 2
def area_square(s): return s ** 2
def area_rectangle(w, h): return w * h
# Dict dispatch instead of an if/elif chainSHAPE_AREA = { "circle": area_circle, "square": area_square, "rectangle": area_rectangle,}
def compute_area(shape, *args): handler = SHAPE_AREA.get(shape) if handler is None: raise ValueError(f"unknown shape: {shape}") return handler(*args)
print(compute_area("circle", 2))print(compute_area("square", 3))print(compute_area("rectangle", 2, 5))
# callable() asks "does this support ()?" for any object, not just def-defined functionsprint(callable(area_circle))print(callable(SHAPE_AREA))print(callable(42))
try: compute_area("triangle", 1, 2)except ValueError as e: print(type(e).__name__ + ":", e)Loading Python runtime (first run only)…
SHAPE_AREA.get(shape) returns None for an unregistered shape, which compute_area turns into an explicit ValueError — the dict-dispatch equivalent of an if/elif chain’s final else. callable(x) is the general-purpose check for “can I put () after this?”: it is True for plain functions, but False for a dict — even one whose values are all functions.
Recursion: a limit, and no tail-call optimization
Section titled “Recursion: a limit, and no tail-call optimization”A recursive function calls itself to make progress toward a base case. Every call keeps its own stack frame alive until it returns — CPython performs no tail-call optimization, so this is true even when the recursive call is the very last thing a function does. sys.getrecursionlimit() reports how many nested calls Python allows before giving up with RecursionError, not a stack overflow crash.
import sys
def count_down(n): if n <= 0: return "liftoff" return count_down(n - 1) # not tail-call optimized -- Python keeps every frame
print("recursion limit:", sys.getrecursionlimit())print(count_down(50))
def infinite_recursion(n): return infinite_recursion(n + 1)
try: infinite_recursion(0)except RecursionError as e: print(type(e).__name__ + ":", e)Loading Python runtime (first run only)…
count_down(50) only needs 50 nested calls, well under the default limit of 1000, so it returns normally. infinite_recursion has no base case at all — every call recurses again — so it always runs into the recursion limit and Python raises RecursionError rather than exhausting all available memory.
Mental Model
Section titled “Mental Model”A function is just an object with one extra super-power: put () after it and it runs. Everything the rest of this module teaches — closures, decorators, generators, *args/**kwargs — is built on that one fact. A dict mapping strings to functions (like {"circle": area_circle}) works because functions are ordinary values that can sit anywhere a value can; callable(obj) asks whether obj supports (), for any object at all, not just things defined with def.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
Dict dispatch instead of if/elif | A new case is one dict entry, not one more branch; the mapping itself is data you can build, extend, or inspect at runtime | A missing key raises KeyError (or is silently ignored with .get) instead of Python telling you which elif you forgot to write |
| Recursion instead of a loop | Mirrors the problem’s own recursive structure; often shorter and closer to the definition | Every call keeps its stack frame alive — no tail-call optimization in CPython — so deep recursion costs real memory and can hit sys.getrecursionlimit() |
Common Misconceptions
Section titled “Common Misconceptions”- “Python optimizes tail-recursive calls the way some other languages do” — fact: CPython performs no tail-call optimization at all; a recursive function that would run forever will eventually raise
RecursionError, no matter how the recursive call is positioned. - “A
KeyErrorfrom a dispatch dict means the dict itself is broken” — fact: it usually means the input didn’t match any registered case — exactly the situation anif/elifchain would fall through to its finalelsefor, just reported as an exception instead of silent fallthrough. - “
callable(x)tells youxis a function” — fact: it tells youxsupports being called with(). Classes, bound methods, and any instance whose class defines__call__are all callable without being functions.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a dict-based dispatch table have a fallback for an unregistered key (
.get(key, default), or atry/except KeyError) — or does a missing key crash the whole call chain with an unhandled exception? - Does a recursive function have a base case at all, and can every possible input actually reach it — or does the base case depend on a condition the arguments can never satisfy?
- Is deep, unbounded recursion used where an equivalent loop would use a small, constant amount of stack space regardless of input size?
- Is
callable(x)used where a check likeisinstance(x, SomeClass)was actually meant — the two ask different questions and are not interchangeable?
🐛 Spot the bug
def factorial(n): return n * factorial(n - 1)
print(factorial(5))Show the answer
factorial has no base case — every call makes another recursive call, including factorial(0), factorial(-1), factorial(-2), and so on forever. Verified on Python 3.14: this never reaches a correct answer; it recurses until the call stack exceeds sys.getrecursionlimit() and Python raises RecursionError: maximum recursion depth exceeded. The fix is a base case that stops the recursion, e.g. if n <= 1: return 1 before the recursive line — the same shape of bug as an if/elif chain or dict-dispatch table with no case for some inputs, just discovered through a stack of calls instead of a single lookup.