deep-dive2024-06-08·9·296/348

Python decorator 동작 원리 이해

A deep-dive into how Python decorators work under the hood — from basic function decorators to class decorators, stacking, and practical patterns.

Python decorator 동작 원리 이해

Introduction

Decorators are one of Python's most elegant features, but they are also one of the most misunderstood. Many developers use decorators daily — @app.route in Flask, @login_required in Django, @property in classes — without truly understanding what happens when Python encounters the @ symbol.

I spent time deeply understanding decorators while refactoring a trading system in Lisbon. The codebase was littered with poorly written decorators that were causing subtle bugs — modified function signatures, lost metadata, and unexpected side effects. Understanding how decorators work at a fundamental level was essential to fixing these issues.

Environment

Python 3.12.3

Problem

Problem 1: Decorator destroys function metadata

import functools

def my_decorator(func):
    def wrapper(*args, **kwargs):
        """Wrapper documentation"""
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def greet(name):
    """Greet someone by name."""
    return f"Hello, {name}"

print(greet.__name__)  # "wrapper" — not "greet"!
print(greet.__doc__)   # "Wrapper documentation" — not the original!

Output:

wrapper
Wrapper documentation

Problem 2: Decorator with arguments is confusing

def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(times=3)  # What is happening here?
def say_hello():
    print("Hello")

# Equivalent to: say_hello = repeat(times=3)(say_hello)
# Which means: say_hello = decorator(say_hello)
# Which means: say_hello = wrapper

Problem 3: Class-based decorator fails

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.count = 0
    
    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.func(*args, **kwargs)

@CountCalls
def say_hello():
    print("Hello")

# This works:
say_hello()  # Prints "Hello"
print(say_hello.count)  # 1

# But isinstance check fails:
print(isinstance(say_hello, CountCalls))  # True
print(type(say_hello))  #  — OK

Problem 4: Stacked decorators execute in wrong order

def decorator_a(func):
    def wrapper(*args, **kwargs):
        print("A before")
        result = func(*args, **kwargs)
        print("A after")
        return result
    return wrapper

def decorator_b(func):
    def wrapper(*args, **kwargs):
        print("B before")
        result = func(*args, **kwargs)
        print("B after")
        return result
    return wrapper

@decorator_a
@decorator_b
def say_hello():
    print("Hello")

say_hello()
# Output:
# A before
# B before
# Hello
# B after
# A after
# A wraps B, not the other way around

Analysis

A decorator is simply a function that takes a function and returns a new function. The @ syntax is syntactic sugar for function composition.

How @decorator works:

@decorator
def function():
    pass

# Is equivalent to:
def function():
    pass
function = decorator(function)

The three layers of decorators:

  1. Simple decorator: Takes a function, returns a wrapper
  2. Decorator factory: Takes arguments, returns a decorator
  3. Class decorator: Uses __init__ and __call__ instead of nested functions

Why function metadata is lost:

When you return wrapper from a decorator, the decorated function becomes wrapper, which has wrapper's name, docstring, and module. functools.wraps copies the original function's metadata to the wrapper.

Solution

Fix 1: Always use functools.wraps

import functools

def my_decorator(func):
    @functools.wraps(func)  # This is critical!
    def wrapper(*args, **kwargs):
        """Wrapper documentation"""
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def greet(name):
    """Greet someone by name."""
    return f"Hello, {name}"

print(greet.__name__)  # "greet" — correct!
print(greet.__doc__)   # "Greet someone by name." — correct!

Fix 2: Decorator with arguments

import functools

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(times=3)
def say_hello():
    print("Hello")

say_hello()  # Prints "Hello" 3 times
print(say_hello.__name__)  # "say_hello" — preserved!

Fix 3: Class-based decorator

import functools

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.count = 0
    
    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.func(*args, **kwargs)
    
    def reset(self):
        self.count = 0

@CountCalls
def say_hello():
    """Say hello."""
    print("Hello")

say_hello()  # Prints "Hello"
say_hello()  # Prints "Hello"
print(say_hello.count)  # 2
print(say_hello.__name__)  # "say_hello"
say_hello.reset()  # Reset counter

Fix 4: Practical decorator patterns

import functools
import time
import logging

def timer(func):
    """Measure execution time."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        logging.info(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

def retry(max_attempts=3, delay=1):
    """Retry on exception."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    logging.warning(f"{func.__name__} failed (attempt {attempt + 1}): {e}")
                    time.sleep(delay)
        return wrapper
    return decorator

def cache(maxsize=128):
    """Simple LRU cache."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            key = (args, tuple(sorted(kwargs.items())))
            if not hasattr(wrapper, '_cache'):
                wrapper._cache = {}
            if key in wrapper._cache:
                return wrapper._cache[key]
            result = func(*args, **kwargs)
            wrapper._cache[key] = result
            return result
        return wrapper
    return decorator

Lessons Learned

  • Always use @functools.wraps(func) in your decorators to preserve function metadata.
  • Understand the three layers: simple decorator, decorator factory (with arguments), class decorator.
  • Stacked decorators execute bottom-up — the bottom decorator wraps first.
  • Use functools.update_wrapper in class-based decorators for the same effect.
  • Keep decorators simple and focused — complex logic should be in the decorated function, not the decorator.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.