Decorators: Adding Behavior to a Function Without Touching It

Lesson goals
  • Explain what a Decorator is and how it is built from a Higher-Order Function and a Closure
  • Write a Decorator with a wrapper function and apply it with @
  • Write a wrapper that fits any function using *args and **kwargs
  • Recognize the two common mistakes — a wrapper without return, and return wrapper() with brackets

What will we learn?

  • What a Decorator is and why it is useful.
  • How to build a Decorator: a function that receives a function and returns a wrapper.
  • The @ syntax.
  • A wrapper that fits any function, with *args and **kwargs.

Why do we need this?

We want to print a message every time a function is called — for debugging:

def add(a, b):
    print("Calling add")
    return a + b

def multiply(a, b):
    print("Calling multiply")
    return a * b

The problems:

  • The same line is copied into every function — again the duplication DRY forbids.
  • The logic gets mixed with helper code that has nothing to do with it.
  • To remove the prints, you have to go over all the functions and delete them.

A Decorator lets you write the extra behavior once, and put it on any function with one line.

Explanation from the basics

What a Decorator is built from

A Decorator combines two things we learned in the previous lessons:

  • Higher-Order Function — it receives a function and returns a function.
  • Closure — the function it returns remembers the original function.

The structure is always the same:

  1. The Decorator receives the original function (func).
  2. Inside it you define a new function — the wrapper — that does something extra, and calls func.
  3. The Decorator returns wrapper — without brackets.

The @ syntax

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

This is exactly the same as writing after the definition:

add = log_call(add)

That is: the name add now points to the wrapper, and the wrapper calls the original add.

Analogy: wrapping a gift

The gift (the function) does not change. You wrap it in paper (the wrapper) — and whoever receives it meets the wrapping first, and only then the gift. You can wrap any gift in the same kind of paper, and you can take the wrapping off without hurting the gift.

Syntax

def my_decorator(func):              # 1. receive the original function
    def wrapper(*args, **kwargs):    # 2. a new function that wraps it
        # ... something before ...
        result = func(*args, **kwargs)
        # ... something after ...
        return result                # return the original result
    return wrapper                   # 3. return the wrapper (no parentheses)

@my_decorator                        # same as: my_function = my_decorator(my_function)
def my_function():
    ...

First code example

week-2/log_call.py:

def log_call(func):
    def wrapper(a, b):
        print("Calling", func.__name__)
        return func(a, b)
    return wrapper

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

@log_call
def multiply(a, b):
    return a * b

print(add(2, 3))
print(multiply(4, 5))

Output:

Calling add
5
Calling multiply
20

Breaking down the code line by line

LineExplanation
def log_call(func):The Decorator receives the original function.
def wrapper(a, b):The wrapping — it receives the same parameters as the original.
print("Calling", func.__name__)The extra behavior. __name__ is the name of the function.
return func(a, b)Call the original, and return its result.
return wrapperThe Decorator returns the wrapper — without brackets.
@log_calladd = log_call(add). From now on add(2, 3) runs the wrapper.

Another example

A wrapper that fits any function. The previous wrapper works only with functions that receive exactly a, b. With *args and **kwargs it receives any parameter and passes it on. week-2/log_any.py:

def log_call(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__, "with", args, kwargs)
        return func(*args, **kwargs)
    return wrapper

@log_call
def greet(name, greeting="Hello"):
    return greeting + ", " + name

@log_call
def total(*prices):
    return sum(prices)

print(greet("Dana", greeting="Hi"))
print(total(10, 20, 30))

Output:

Calling greet with ('Dana',) {'greeting': 'Hi'}
Hi, Dana
Calling total with (10, 20, 30) {}
60

A useful example: measuring time. week-2/timed.py:

import time

def timed(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        seconds = round(time.time() - start, 2)
        print(func.__name__, "took", seconds, "seconds")
        return result
    return wrapper

@timed
def slow_sum(n):
    time.sleep(0.5)
    return sum(range(n))

print(slow_sum(1000))

Output:

slow_sum took 0.5 seconds
499500

(The exact time varies a little between runs.) The same @timed can be put on any function in the program — without changing a single line inside it.

Common mistake

Two mistakes that happen to almost everyone who writes a first Decorator:

def log_call(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        func(*args, **kwargs)
    return wrapper()
  1. No return inside the wrapper. The original function runs, but its result is lost — and the decorated function always returns None. The fix: return func(*args, **kwargs).
  2. return wrapper() with brackets. This calls the wrapper already at definition time, and returns its result instead of the function. The fix: return wrapper.

The rule: the wrapper returns the result, and the Decorator returns the wrapper.

Practice

The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.

Exercise 1: A first Decorator

Write a Decorator named log_call that prints Calling <name> before every call to the function, and apply it with @ to add and to multiply.

print(add(2, 3))
print(multiply(4, 5))

Expected output:

Calling add
5
Calling multiply
20
Show solution
def log_call(func):
    def wrapper(a, b):
        print("Calling", func.__name__)
        return func(a, b)
    return wrapper


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


@log_call
def multiply(a, b):
    return a * b


print(add(2, 3))
print(multiply(4, 5))
Calling add
5
Calling multiply
20

@log_call above def add is equivalent to add = log_call(add). From now on add points to the wrapper.

Exercise 2: A wrapper that fits any function

The previous wrapper works only with functions that receive exactly two parameters. Rewrite log_call so that it works with any function.

@log_call
def greet(name, greeting="Hello"):
    return greeting + ", " + name

@log_call
def total(*prices):
    return sum(prices)

print(greet("Dana", greeting="Hi"))
print(total(10, 20, 30))

Expected output:

Calling greet
Hi, Dana
Calling total
60
Show solution
def log_call(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        return func(*args, **kwargs)
    return wrapper


@log_call
def greet(name, greeting="Hello"):
    return greeting + ", " + name


@log_call
def total(*prices):
    return sum(prices)


print(greet("Dana", greeting="Hi"))
print(total(10, 20, 30))
Calling greet
Hi, Dana
Calling total
60

*args and **kwargs collect all the arguments, and func(*args, **kwargs) passes them on as they are.

Exercise 3: Checking the input

Write a Decorator named positive_only for functions that receive one number: if the number is negative, it throws a ValueError and does not call the function at all. The message includes the function’s name (func.__name__) and the number that was received, as in the expected output.

@positive_only
def square_root(n):
    return n ** 0.5

print(square_root(16))
try:
    square_root(-4)
except ValueError as error:
    print("error:", error)

Expected output:

4.0
error: square_root needs a positive number, got -4
Show solution
def positive_only(func):
    def wrapper(n):
        if n < 0:
            raise ValueError(func.__name__ + " needs a positive number, got " + str(n))
        return func(n)
    return wrapper


@positive_only
def square_root(n):
    return n ** 0.5


print(square_root(16))
try:
    square_root(-4)
except ValueError as error:
    print("error:", error)
4.0
error: square_root needs a positive number, got -4

The check happens before the call to func — if the input is invalid, the original function does not run at all.

Exercise 4: Counting calls

Write a Decorator named count_calls, which counts how many times the function was called. On every call, before the function runs, it prints the function’s name and the number of the call — as in the expected output.

@count_calls
def say_hi():
    print("Hi")

say_hi()
say_hi()

Expected output:

say_hi call #1
Hi
say_hi call #2
Hi
Show solution
def count_calls(func):
    count = 0

    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        print(func.__name__, "call #" + str(count))
        return func(*args, **kwargs)

    return wrapper


@count_calls
def say_hi():
    print("Hi")


say_hi()
say_hi()
say_hi call #1
Hi
say_hi call #2
Hi

count belongs to count_calls, and the wrapper remembers it (a Closure). Because it changes count, it needs nonlocal.

Exercise 5: Two mistakes

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

The Decorator is supposed to return the function’s result in capital letters. Find two mistakes and fix them.

@shout
def greet(name):
    return "hello " + name

print(greet("dana"))

Expected output:

HELLO DANA
Show solution
  1. return wrapper() with brackets — calls the wrapper already at definition time, instead of returning it. It should be return wrapper.
  2. The wrapper returns nothing — result.upper() creates a new string, but it is not returned. It should be return result.upper().
def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper


@shout
def greet(name):
    return "hello " + name


print(greet("dana"))
HELLO DANA

The rule: the wrapper returns the result, and the Decorator returns the wrapper.

Summary

  • Decorator = a function that receives a function, wraps it in a wrapper with extra behavior, and returns the wrapper.
  • It is built from a Higher-Order Function and a Closure — the two topics of the previous lessons.
  • @decorator above a def = func = decorator(func).
  • With *args and **kwargs, the wrapper fits any function.
  • The wrapper returns the result (return func(...)), and the Decorator returns the wrapper (return wrapper, without brackets).

Official documentation