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:

  1. CPython detects that count is shared between make_counter and increment
  2. Both functions reference the same cell object instead of a regular local variable
  3. 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.

pythonfunctionsclosuresdecoratorscode-objectsframe-objectsinternals

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.