Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 4 of 10

You met @property in lesson 5.8. A decorator is a function that takes a function and returns a new one wrapping it — a way to add behaviour to something without editing it.

It builds on lesson 4.7: functions are values you can pass around and return.

Building one from scratch

def shout(func):
    """Wrap func so its result is upper-cased."""
    def wrapper():
        result = func()
        return result.upper()
    return wrapper

def greet():
    return "hello"

greet = shout(greet)
print(greet())

shout takes a function, defines a new function that calls it and modifies the result, and returns that. Reassigning greet swaps the original for the wrapped version.

The @ syntax

@shout above a definition means exactly greet = shout(greet):

def shout(func):
    def wrapper():
        return func().upper()
    return wrapper

@shout
def greet():
    return "hello"

print(greet())

Same thing, less noise.

Handling arguments

A decorator that only works on zero-argument functions is nearly useless. *args and **kwargs from lesson 4.4 make it universal:

def shout(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

@shout
def greet(name, greeting="hello"):
    return f"{greeting}, {name}"

print(greet("ada"))
print(greet("grace", greeting="hi"))

wrapper accepts anything and forwards it unchanged.

functools.wraps

Wrapping hides the original's identity:

def shout(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

@shout
def greet():
    """Say hello."""
    return "hello"

print(greet.__name__)
print(greet.__doc__)

The name and docstring now belong to wrapper, which breaks help() and confuses debuggers. functools.wraps copies them across:

import functools

def shout(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

@shout
def greet():
    """Say hello."""
    return "hello"

print(greet.__name__)
print(greet.__doc__)

Always use functools.wraps. It's one line and it prevents real confusion later.

A practical example

Logging calls without touching the functions:

import functools

def logged(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}{args}")
        result = func(*args, **kwargs)
        print(f"  -> {result}")
        return result
    return wrapper

@logged
def add(a, b):
    return a + b

add(2, 3)

Common real uses: timing, caching, retries (lesson 8.5), access checks, and input validation.

Stacking

Decorators apply bottom-up — the one closest to the function wraps first:

import functools

def bold(func):
    @functools.wraps(func)
    def wrapper():
        return f"<b>{func()}</b>"
    return wrapper

def italic(func):
    @functools.wraps(func)
    def wrapper():
        return f"<i>{func()}</i>"
    return wrapper

@bold
@italic
def text():
    return "hi"

print(text())

italic wraps text, then bold wraps that — so <b> ends up outermost.

Built-in decorators

functools.lru_cache memoises results, which turns the slow recursive Fibonacci from lesson 4.8 into a fast one:

import functools

@functools.lru_cache(maxsize=None)
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(30))
print(fib.cache_info().hits > 0)

Without the cache, fib(30) makes over a million calls. With it, each n is computed once.

Try it yourself

Checkpoint

  • A decorator takes a function and returns a wrapped replacement.
  • @decorator is shorthand for func = decorator(func).
  • Use *args, **kwargs in the wrapper so it works with any signature.
  • Always apply @functools.wraps(func) to preserve name and docstring.
  • Stacked decorators apply bottom-up; lru_cache is a ready-made memoiser.

Questions about this lesson

Log in to ask a question.