A few things about decorator functions

Viewed 84

I am trying to understand an example of a Fibonacci sequence using a decorator to store values of already calculated numbers. For example fib(5) would be calculated, and when we got to fib(6), it wouldn't calculate fib(5) again... I understand decorators a little bit, but some things just confuse me. I have a few questions about the code below.

from functools import wraps
def dec(func):
    values = {}
    @wraps(func)
    def wrap(*args):
        if args not in values:
            values[args] = func(*args)
        return values[args]
    return wrap

@dec
def fib(n):
    if n <= 2:
        return 1
    else:
        return fib(n - 1) + fib(n - 2)
  1. Why are *args used in wrap()? Isn't it supposed to just take a number n and check if the value of that is in the dictionary? Why are args in some places called with, and in some without the *?
  2. What happens when the function fib is called recursively (how does the decorator function behave). I first thought it enters that function during every recursion, but that can't be right because the values dictionary would reset? So does it then enter just the wrap() function?
  3. Why does it return wrap at the end?
3 Answers

1- You're exactly right. There is no need for "*" as you are checking only the value that is passed to the function. So simply call it "n".

2- First let's figure out what is label "fib" after you used "@dec" on top of it? Actually it's now your inner function inside your decorator(I mean "wrap" function). why ? because @dec actually doing this:

fib = dec(fib)

So "dec" decorator is called, what does it return? "wrap" function. What is "wrap" function? It's a closure which has that "values" dictionary.

Whenever you call your decorator the body of the decorator executes only one time. So there is only one "values" dictionary. What else happens during executing the body of "dec" decorator? Nothing but returning the reference to "wrap" function. that's it.

Now, when you call your "fib" function(originally "wrap" function), this closure runs normally as it's just a recursive function, except it has that extra caching functionality.

3- Because you need to have a handle to the inner function(here "wrap" function). You wanna call it later in order to calculate Fibonacci.

What is *args

*args matches all remaining arguments as a tuple. Take this example:

def f(*args):
    print(args)
f('a', 'b')
# output: ('a', 'b')

In this case, it is used to call the inner function with the exact same argument, no matter what they might be. You can also use a double star to match the keyword arguments, right now only positional arguments work.

What happens when fib is called recursively

When decorating a function with @, the reference is immediately overwritten. When fib calls fib() inside itself, it looks first in the local scope for a variable with that name. Since there is none, it will look in the next scope up, which is the global scope in this case. There, it finds a variable named fib, which is actually assigned to the wrap function from your decorator, with a "context" of the original fib being func.

Look up closures for more information about how this works.

Why does the decorator return wrap at the end

A decorator basically replaces one function with another. It calls variable after the @ like a function, then replaces the new function defined by def with the result of that call. In this case, you want to replace it with wrap, a new function that may or may not call the old function.

If you don't return anything, the variable fib will simply be set to None (the default return value), and you can't call None.

You can get a good insight into what's going on here simply by adding a few print statements, for example:

from functools import wraps
def dec(func):
    values = {}
    @wraps(func)
    def wrap(*args):
        print("args: ", args, " *args:", *args, args not in values, values)
        if args not in values:
            values[args] = func(*args)
        return values[args]
    print("Wrap", wrap)
    return wrap

@dec
def fib(n):
    if n <= 2:
        return 1
    else:
        return fib(n - 1) + fib(n - 2)

print("Answer", fib(5))

So, the output of this is:

Wrap <function fib at 0x7facac4b70d0>
args:  (5,)  *args: 5 True {}
args:  (4,)  *args: 4 True {}
args:  (3,)  *args: 3 True {}
args:  (2,)  *args: 2 True {}
args:  (1,)  *args: 1 True {(2,): 1}
args:  (2,)  *args: 2 False {(2,): 1, (1,): 1, (3,): 2}
args:  (3,)  *args: 3 False {(2,): 1, (1,): 1, (3,): 2, (4,): 3}
Answer 5

To start with the last part of your question first, as you can see from the output, the function is wrapped at the start of the program when Python executes all of the code before it gets to the print statement. This only occurs once and it is what enables your subsequent call to fib to call the already wrapped function.

In order for a wrapper function to work, it needs to know what arguments are being passed to the wrapped function, and it does so through *args to see the passed parameter, which it needs in order to memoise it and its result.

The difference between args and *args comes down to tuple unpacking. You can see from the output above that args contains, e.g. (1, ), and *args unpacks this to just 1.

So, by using args in the wrapped function, it is not actually storing the numbers as keys in the values dictionary as you might suspect, but a tuple containing the number. In this case, unpacking could be done, but it would an an unnecessary extra step.

This also gives you a good insight into the way the recursion is occurring as on the first call to fib(n), the first part of the return statement is return fib(n - 1), so that needs to be evaluated before the fib(n-2), so this immediately results in the memoisation of every value from n to 1 and then you return back up the recursion stack, evaluating fib(n-2), but all of these results can be satisfied by values with no further recursive calls to fib.

Related