CodeOath
← All posts
Python70 min total · 18 parts

Python Fundamentals for Interviews: Data Structures, Comprehensions, and Gotchas

Contents — Part 10 of 18: Decorators
Part 10 of 18 · ~2 min

Decorators

A decorator is a function that takes a function and returns a (usually wrapped) function — @decorator above a function definition is exactly equivalent to reassigning the function to the decorator's return value:

import functools
import time

def timer(func):
    @functools.wraps(func)  # preserves func's __name__, docstring, etc. on the wrapper
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_add(a, b):
    time.sleep(0.1)
    return a + b

slow_add(2, 3)
# "slow_add took 0.1003s"
# 5

@timer above def slow_add is syntactic sugar for slow_add = timer(slow_add). The *args, **kwargs in wrapper is what lets one decorator transparently work on functions with any signature, without knowing their parameters ahead of time. functools.wraps matters more than it looks — without it, slow_add.__name__ would report "wrapper" instead of "slow_add", breaking introspection, debugging output, and tools that rely on function metadata.

Decorators with arguments

A decorator factory is a function that returns a decorator, adding one more layer of nesting:

def retry(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Attempt {attempt + 1} failed: {e}")
            raise
        return wrapper
    return decorator

@retry(times=3)
def flaky_call():
    ...

@retry(times=3) first calls retry(3), which returns decorator; decorator is then applied to flaky_call exactly like a normal decorator. This is why @retry(times=3) needs the parentheses but @timer above doesn't — retry is a factory that must be called to produce the actual decorator.

Built-in decorators worth knowing: @property, @staticmethod, @classmethod

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def area(self):
        return 3.14159 * self._radius ** 2   # accessed like an attribute, not a method call

    @staticmethod
    def from_diameter(diameter):
        return Circle(diameter / 2)           # doesn't need self or cls at all

    @classmethod
    def unit_circle(cls):
        return cls(radius=1)                  # cls lets subclasses override the returned type

c = Circle(2)
c.area                     # 12.56636 — no parentheses, looks like a plain attribute
Circle.from_diameter(4)    # Circle(radius=2.0)
Circle.unit_circle()       # Circle(radius=1)

@property is what makes computed attributes possible without changing the calling code if a plain attribute later needs to become a computed one. @staticmethod is essentially a plain function namespaced inside a class for organization; @classmethod receives the class itself (cls) rather than an instance, which matters for alternative constructors that need to work correctly with subclasses.