SpiralTrain
Exercises › Block 1 · Exercise 6

Block 1 · Exercise 6

Decorators

Starter notebook
06-decorators-starter
Fabric path
/lakehouse/default/Files/data/solutions/06.decorators/

Open the notebook 06-decorators-starter in your workspace. Its first cell holds the calculator's functions and two calls to them at the bottom. Work in that cell for Steps 1 to 3, and in the second cell for Step 4.

Do one part at a time, 1a, 1b and so on, and compare with the expected output before you go on. The solution is in 06-decorators-solution, under every part on the exercise site, and at the back of the exercises PDF.

Step 1Benchmark decorator

1a

Write a decorator function benchmark at the top of the first cell. It returns a wrapper that calls the function with any arguments, measures the call with time.time(), prints the function's name and the elapsed seconds, and returns the function's result.

Hint 1

The slides Variable Arguments and Execution Hooks show a wrapper that takes *args, **kwargs and runs code around the call.

Hint 2

The wrapper has to return what the function returned, or every decorated function gives back None.

Show solutionHide solution
python
def benchmark(func):
    """
    A decorator that prints the time a function takes to execute.
    """
    def wrapper(*args, **kwargs):
        t = time.time()
        res = func(*args, **kwargs)
        print(func.__name__, time.time() - t)
        return res
    return wrapper

1b

Apply benchmark to add. Run the cell and read what it prints. subtract has no decorator.

Hint 1

The slide Decorator Syntax shows where the @ line goes.

Check your output
add 9.5367431640625e-07
7
-1

The time varies from run to run.

Show solutionHide solution
python
@benchmark
def add (i, j):
    return i + j

Step 2Logging decorator

2a

Write a decorator function logging next to benchmark. It calls the function, then prints the function's name, its positional arguments and its keyword arguments, and returns the result. It prints, and it could call the logging module.

Hint 1

args is a tuple and kwargs is a dictionary. print(func.__name__, args, kwargs) shows all three.

Show solutionHide solution
python
def logging(func):
    """
    A decorator that logs the activity of the script.
    (it prints, but it could call the logging module)
    """
    def wrapper(*args, **kwargs):
        res = func(*args, **kwargs)
        print(func.__name__, args, kwargs)
        return res
    return wrapper

2b

Stack logging on top of benchmark on add. Run the cell again. Which name does the benchmark line print, and which name does the logging line print?

Hint 1

The slide Multiple Decorators shows in which order the decorators run.

Check your output
add 9.5367431640625e-07
wrapper (3, 4) {}
7
-1

The time varies from run to run.

Show solutionHide solution
python
@logging
@benchmark
def add (i, j):
    return i + j

Step 3Function name

3a

Add print(add.__name__) at the bottom of the cell and run it. Read what the outside world sees.

Hint 1

logging is handed the function that benchmark returned. What is that function called?

Check your output
add 9.5367431640625e-07
wrapper (3, 4) {}
7
-1
wrapper
Show solutionHide solution
python
print(add.__name__)

3b

Repair both decorators with functools.wraps. Run the cell again. Every line now names add, and add.__name__ is add. Use @functools.wraps in every decorator you write from here on.

Hint 1

The slide Keeping Function Info shows where functools.wraps goes.

Hint 2

functools.wraps(func) is itself a decorator, and it goes on the wrapper inside each decorator.

Check your output
add 9.5367431640625e-07
add (3, 4) {}
7
-1
add

The time varies from run to run.

Show solutionHide solution
python
def benchmark(func):
    """
    A decorator that prints the time a function takes to execute.
    """
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        t = time.time()
        res = func(*args, **kwargs)
        print(func.__name__, time.time() - t)
        return res
    return wrapper


def logging(func):
    """
    A decorator that logs the activity of the script.
    (it prints, but it could call the logging module)
    """
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        res = func(*args, **kwargs)
        print(func.__name__, args, kwargs)
        return res
    return wrapper

Step 4Caching

Run the second cell. It times a plain recursive fib(30) and prints the time.

4a

Write a decorator memoize above the fib definitions. It remembers the result of every call and returns the stored answer when the same arguments come back. Keep the cache in a dictionary in the enclosing scope and use the arguments as the key.

Hint 1

The slide Closure shows a function that keeps a value in its enclosing scope.

Hint 2

args is a tuple, and a tuple can be a dictionary key. Look in the cache before you call the function, and store the result after.

Show solutionHide solution
python
def memoize(func):
    cache = {}

    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]

    return wrapper

4b

Define fib again below memoize, with @memoize above it. Time fib(30) with time.perf_counter and print it the way the cell prints the plain time.

Hint 1

Copy the timing lines from the top of the cell and change the label.

Check your output
fib(30), plain     : 0.0894 s
fib(30), memoize   : 0.000020 s

Both times vary from machine to machine. The memoized time is several orders of magnitude smaller.

Show solutionHide solution
python
@memoize
def fib(n):
    return n if n in [0, 1] else fib(n - 2) + fib(n - 1)


start = time.perf_counter()
fib(30)
print(f"fib(30), memoize   : {time.perf_counter() - start:.6f} s")

4c

Define fib once more, with @lru_cache(maxsize=128) above it instead of your memoize. Time fib(30) again and print it.

Hint 1

The slide Caching with functools shows lru_cache. The starter already imports it.

Check your output
fib(30), lru_cache : 0.000031 s

The time varies. It is about the same as memoize.

Show solutionHide solution
python
@lru_cache(maxsize=128)
def fib(n):
    return n if n in [0, 1] else fib(n - 2) + fib(n - 1)


start = time.perf_counter()
fib(30)
print(f"fib(30), lru_cache : {time.perf_counter() - start:.6f} s")

4d

Print fib.cache_info(). Then empty the cache with fib.cache_clear() and print fib.cache_info() again.

Hint 1

Both methods exist only on the function that lru_cache returns, not on your memoize version.

Check your output
CacheInfo(hits=28, misses=31, maxsize=128, currsize=31)
CacheInfo(hits=0, misses=0, maxsize=128, currsize=0)
Show solutionHide solution
python
print(fib.cache_info())
fib.cache_clear()
print(fib.cache_info())

4e

The arguments are the key of the cache. Call the cached fib with a list, fib([30]), inside a try, and print the TypeError that comes back.

Hint 1

A dictionary key has to be hashable. Is a list?

Check your output
a list as argument : unhashable type: 'list'
Show solutionHide solution
python
try:
    fib([30])
except TypeError as error:
    print("a list as argument :", error)

4f

Caching is only correct when the result depends on nothing but the arguments. Write a function now() under @lru_cache that returns time.perf_counter(). Call it, wait 0.1 seconds with time.sleep, call it again, and print whether the second answer equals the first.

Hint 1

A clock gives a new answer on every call. Does the cached function give one?

Check your output
an impure function returns its first answer again : True
Show solutionHide solution
python
@lru_cache
def now():
    return time.perf_counter()


first = now()
time.sleep(0.1)
print("an impure function returns its first answer again :", now() == first)

If time permits

  • Write a decorator counter that counts how often a function has been called and prints add has been used: 2x after every call. Store the count as an attribute on the wrapper. Put @counter, @logging and @benchmark on every calculation, and call add twice.
  • Write a decorator repeat(n) that takes an argument and calls the function n times. A decorator with an argument is a function that returns a decorator.
  • Write a decorator that adds exception logging to a function. It only has to deal with exceptions not caught by the function. Extend the decorator so that it accepts a logger as argument and uses that particular logger.
  • Decorate fib with @cache and read fib.cache_info(). @cache is lru_cache with no size limit.

Tried it yourself first?

The solution is a spoiler. Work through the hints first : a wrong attempt teaches more than a solution you only read.