Skip to content

Functions In Depth

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 variable
say_hi = greet
# Pass as an argument
def 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").

LessonCore concept
Args & KwargsPositional, keyword, variadic, and keyword-only parameters
ClosuresNested functions, free variables, and nonlocal
DecoratorsFunctions that wrap other functions; functools.wraps
Generatorsyield, lazy evaluation, and StopIteration

Each lesson is self-contained and builds on this foundation: functions are objects you can manipulate freely.

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 list
ops = [double, triple, make_multiplier(10)]
for op in ops:
print(op(7))

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 chain
SHAPE_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 functions
print(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)

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)

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.

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.

What you getBenefitCost
Dict dispatch instead of if/elifA new case is one dict entry, not one more branch; the mapping itself is data you can build, extend, or inspect at runtimeA 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 loopMirrors the problem’s own recursive structure; often shorter and closer to the definitionEvery call keeps its stack frame alive — no tail-call optimization in CPython — so deep recursion costs real memory and can hit sys.getrecursionlimit()
  • “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 KeyError from a dispatch dict means the dict itself is broken” — fact: it usually means the input didn’t match any registered case — exactly the situation an if/elif chain would fall through to its final else for, just reported as an exception instead of silent fallthrough.
  • “callable(x) tells you x is a function” — fact: it tells you x supports being called with (). Classes, bound methods, and any instance whose class defines __call__ are all callable without being functions.
  • Does a dict-based dispatch table have a fallback for an unregistered key (.get(key, default), or a try/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 like isinstance(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.

What does 'first-class function' mean in Python?
Which built-in function accepts a function and an iterable as arguments?
After writing f = greet, which statement is true?
What does this print? ops = {"add": lambda a, b: a + b, "sub": lambda a, b: a - b} def calc(op, a, b): return ops[op](a, b) print(calc("add", 2, 3)) print(calc("mul", 2, 3))
What happens when this runs? def countdown(n): return countdown(n - 1) countdown(0)
What does callable(5) return, and why?