Python Functions — Deep Dive
Functions Are Objects
In CPython, a function is an instance of function type. Calling def creates a function object and binds it to a name in the current namespace:
def add(a, b):
return a + b
print(type(add)) # <class 'function'>
print(add.__name__) # 'add'
print(add.__module__) # '__main__'
The function object holds:
__code__— the compiled bytecode and metadata__globals__— the global namespace where the function was defined__defaults__— tuple of default argument values__closure__— tuple of cell objects for captured variables
Code Objects: The Blueprint
The __code__ attribute is a code object — Python’s compiled representation of the function body:
def add(a, b):
x = a + b
return x
c = add.__code__
print(c.co_varnames) # ('a', 'b', 'x') — local variable names
print(c.co_argcount) # 2
print(c.co_consts) # (None,) — constants in the function
print(c.co_stacksize) # max stack depth needed
Code objects are immutable and shareable. If you define the same function twice (e.g., in a loop), each function object is different, but they may share the same code object — the compiled bytecode is identical.
Frames: Execution State
When a function is called, CPython creates a frame object — a runtime snapshot of everything needed to execute the function:
- Reference to the code object
- Local variable values
- Current bytecode offset (instruction pointer)
- Evaluation stack
- Reference to the global namespace
Frames form a linked list (the call stack). sys._getframe() gives you the current frame:
import sys
def show_caller():
frame = sys._getframe(1)
print(f"Called from {frame.f_code.co_filename}:{frame.f_lineno}")
def main():
show_caller()
main() # Called from script.py:8
When a function returns, its frame is freed (reference-counted). Generators are special: their frames are kept alive between yield calls — this is how generator state persistence works.
How Closures Work
When a nested function references a variable from an enclosing scope, CPython uses cell objects to share that variable:
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
Internally:
- CPython detects that
countis shared betweenmake_counterandincrement - Both functions reference the same
cellobject instead of a regular local variable - The cell object contains the actual value
counter = make_counter()
print(counter.__closure__) # (<cell at 0x...>,)
print(counter.__closure__[0].cell_contents) # 0
counter()
print(counter.__closure__[0].cell_contents) # 1
When make_counter returns, its frame is gone — but the cell object survives because increment holds a reference to it. This is the memory model behind closures: the captured variables live in cells, not in the enclosing function’s frame.
Argument Passing: Call by Object Reference
Python’s argument passing is “call by object reference” (sometimes called “call by sharing”). The function receives references to the same objects:
def modify(lst, num):
lst.append(4) # Modifies the original list
num = num + 1 # Creates a new int, doesn't affect caller
my_list = [1, 2, 3]
my_num = 10
modify(my_list, my_num)
print(my_list) # [1, 2, 3, 4] — modified
print(my_num) # 10 — unchanged
- Mutable objects (lists, dicts): modifications inside the function are visible outside
- Immutable objects (ints, strings, tuples): you can’t modify them, and rebinding the parameter inside the function doesn’t affect the caller’s variable
This is often mischaracterized as “pass by reference” (wrong — you can’t reassign the caller’s variable) or “pass by value” (wrong — you’re not copying objects). “Pass by object reference” is most accurate.
The Descriptor Protocol and Methods
When you access a function through a class, something interesting happens:
class Dog:
def bark(self):
print("Woof!")
d = Dog()
print(type(Dog.bark)) # <class 'function'>
print(type(d.bark)) # <class 'method'>
Accessing bark through an instance creates a bound method — a wrapper that holds both the function and the instance. When called, the instance is automatically passed as self.
This happens via the descriptor protocol: function objects implement __get__, which is called when the function is accessed through a class or instance. Dog.bark.__get__(d, Dog) returns a bound method.
Understanding this explains classmethod, staticmethod, and property — they’re all descriptors that implement __get__ differently.
Decorators: Implementation Details
A decorator is syntactic sugar for function composition. @decorator is equivalent to func = decorator(func) after the def statement.
The canonical decorator pattern preserves the wrapped function’s metadata using functools.wraps:
import functools
def retry(times=3):
def decorator(func):
@functools.wraps(func) # Preserves __name__, __doc__, etc.
def wrapper(*args, **kwargs):
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception:
if attempt == times - 1:
raise
return wrapper
return decorator
@retry(times=5)
def flaky_network_call():
...
Without @functools.wraps, the wrapper function’s __name__ would be 'wrapper' instead of the original function name — which breaks logging, tracebacks, and introspection.
Class-Based Decorators
Any callable can be a decorator. Classes that implement __call__ work too:
class Memoize:
def __init__(self, func):
self.func = func
self.cache = {}
functools.update_wrapper(self, func)
def __call__(self, *args):
if args not in self.cache:
self.cache[args] = self.func(*args)
return self.cache[args]
@Memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
functools: The Function Toolkit
functools in the standard library provides essential function utilities:
from functools import partial, lru_cache, reduce
# partial: fix some arguments
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(4)) # 16
print(cube(3)) # 27
# lru_cache: memoization with max size
@lru_cache(maxsize=128)
def expensive_computation(n):
...
# reduce: fold a sequence
product = reduce(lambda acc, x: acc * x, [1, 2, 3, 4, 5])
print(product) # 120
lru_cache is particularly important — it turns any pure function into a memoized version with an LRU eviction policy, with zero boilerplate.
Type Annotations for Functions
Complete function signatures with modern Python typing:
from typing import Callable, TypeVar
T = TypeVar('T')
def apply_twice(func: Callable[[T], T], value: T) -> T:
return func(func(value))
result = apply_twice(lambda x: x * 2, 3) # 12
Callable[[ArgTypes], ReturnType] describes function types. Type checkers use this to validate that you’re passing the right kind of function.
One Thing to Remember
Python functions are objects with a
__code__attribute (compiled bytecode), a__globals__namespace, and a__closure__for captured variables — and understanding these three components explains everything from decorators to generators to why the mutable default argument bug exists.
See Also
- Python Async Await Async/await helps one Python program juggle many waiting jobs at once, like a chef who keeps multiple pots moving without standing still.
- Python Basics Python is the programming language that reads like plain English — here's why millions of beginners (and experts) choose it first.
- Python Booleans Make Booleans click with one clear analogy you can reuse whenever Python feels confusing.
- Python Break Continue Make Break Continue click with one clear analogy you can reuse whenever Python feels confusing.
- Python Closures See how Python functions can remember private information, even after the outer function has already finished.