9. More on Functions

9.1. Recursion

Consider computing the Fibonacci number of order \(n\):

\[\begin{split} F_n := \begin{cases} F_{n-1}+F_{n-2} & n>1 \kern1em \text{(recurrence)}\\ 1 & n=1 \kern1em \text{(base case)}\\ 0 & n=0 \kern1em \text{(base case)}. \end{cases}\end{split}\]

Fibonacci numbers have practical applications in generating pseudorandom numbers.

Can we define the function by calling the function itself?

%%mytutor -r -h 450
def fibonacci(n):
    if n > 1:
        return fibonacci(n - 1) + fibonacci(n - 2)  # recursion
    elif n == 1:
        return 1
    else:
        return 0

fibonacci(2)
1

Recursion is a function that calls itself (recurs).

Exercise Write a function gcd that implements the Euclidean algorithm for the greatest common divisor:

\[\begin{split}\operatorname{gcd}(a,b)=\begin{cases}a & b=0\\ \operatorname{gcd}(b, a\operatorname{mod}b) & \text{otherwise} \end{cases}\end{split}\]
%%mytutor -r -h 550
def gcd(a, b):
    ### BEGIN SOLUTION
    return gcd(b, a % b) if b else a
    ### END SOLUTION


gcd(3 * 5, 5 * 7)
5

Is recursion strictly necessary?

No. We can always convert a recursion to an iteration.
E.g., the following computes the Fibonnacci number of order using a while loop instead.

%%mytutor -r -h 550
def fibonacci_iteration(n):
    if n > 1:
        _, F = 0, 1  # next two Fibonacci numbers
        while n > 1:
            _, F, n = F, F + _, n - 1
        return F
    elif n == 1:
        return 1
    else:
        return 0
    
fibonacci_iteration(3)
2
# more tests
for n in range(5):
    assert fibonacci(n) == fibonacci_iteration(n)

Exercise Implement gcd_iteration using a while loop instead of a recursion.

%%mytutor -r -h 550
def gcd_iteration(a, b):
    ### BEGIN SOLUTION
    while b:
        a, b = b, a % b
    return a
    ### END SOLUTION


gcd_iteration(3 * 5, 5 * 7)
5
# test
for n in range(5):
    assert fibonacci(n) == fibonacci_iteration(n)

What is the benefit of recursion?

  • Recursion is often shorter and easier to understand.

  • It is also easier to write code by wishful thinking or declarative programming.

Is recusion more efficient than iteration?

Exercise Find the smallest values of n forfibonacci(n) and fibonacci_iteration(n) respectively to run for more than a second.

# Assign n
### BEGIN SOLUTION
n = 33
### END SOLUTION
fib_recursion = fibonacci(n)
# Assign n
### BEGIN SOLUTION
n = 300000
### END SOLUTION
fib_iteration = fibonacci_iteration(n)

To see why recursion is slow, we will modify fibonacci to print each function call as follows.

def fibonacci(n):
    '''Returns the Fibonacci number of order n.'''
    print('fibonacci({!r})'.format(n))
    return fibonacci(n - 1) + fibonacci(n - 2) if n > 1 else 1 if n == 1 else 0


fibonacci(5)
fibonacci(5)
fibonacci(4)
fibonacci(3)
fibonacci(2)
fibonacci(1)
fibonacci(0)
fibonacci(1)
fibonacci(2)
fibonacci(1)
fibonacci(0)
fibonacci(3)
fibonacci(2)
fibonacci(1)
fibonacci(0)
fibonacci(1)
5

fibonacci(5) calls fibonacci(3) and fibonacci(4), which in turn call fibonacci(2) and fibonacci(3). fibonacci(3) is called twice.

9.2. Global Variables

Consider the problem of generating a sequence of Fibonacci numbers.

for n in range(5):
    print(fibonacci_iteration(n))
0
1
1
2
3

Is the above loop efficient?

No. Each call to fibonacci_iteration(n) recomputes the last two Fibonacci numbers \(F_{n-1}\) and \(F_{n-2}\) for \(n\geq 2\).

How to avoid redundant computations?

One way is to store the last two computed Fibonacci numbers.

%%mytutor -h 600
def next_fibonacci():
    '''Returns the next Fibonacci number.'''
    global _Fn, _Fn1, _n  # global declaration
    value = _Fn
    _Fn, _Fn1, _n = _Fn1, _Fn + _Fn1, _n + 1
    return value

def print_fibonacci_state():
    print('''States:
    _Fn  : Next Fibonacci number      = {}
    _Fn1 : Next next Fibonacci number = {}
    _n   : Next order                 = {}'''.format(_Fn,_Fn1,_n))

# global variables for next_fibonacci and print_fibonacci_state
_Fn, _Fn1, _n = 0, 1, 0

for n in range(5):
    print(next_fibonacci())
print_fibonacci_state()

Rules for global/local variables:

  1. A local variable must be defined within a function.

  2. An assignment defines a local variable except in a global statement.

Why global is NOT needed in print_fibonacci_state?

Without ambiguity, _Fn, _Fn1, _n in print_fibonacci_state are not local variables by Rule 1 because they are not defined within the function.

Why global is needed in next_fibonacci?

What happens otherwise:

def next_fibonacci():
    '''Returns the next Fibonacci number.'''
    # global _Fn, _Fn1, _n
    value = _Fn
    _Fn, _Fn1, _n = _Fn1, _Fn + _Fn1, _n + 1
    return value

next_fibonacci()
---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
<ipython-input-13-44c8c496bfd8> in <module>
      6     return value
      7 
----> 8 next_fibonacci()

<ipython-input-13-44c8c496bfd8> in next_fibonacci()
      2     '''Returns the next Fibonacci number.'''
      3     # global _Fn, _Fn1, _n
----> 4     value = _Fn
      5     _Fn, _Fn1, _n = _Fn1, _Fn + _Fn1, _n + 1
      6     return value

UnboundLocalError: local variable '_Fn' referenced before assignment

Why is there an UnboundLocalError?

  • The assignment defines _Fn as a local variable by Rule 2.

  • However, the assignment requires first evaluating _Fn, which is not yet defined.

Are global variables preferred over local ones?

Suppose for aesthetic reasons we remove the underscores in global variable names?

%%mytutor -h 600
def next_fibonacci():
    '''Returns the next Fibonacci number.'''
    global Fn, Fn1, n
    value = Fn
    Fn, Fn1, n = Fn1, Fn + Fn1, n + 1
    return value

def print_fibonacci_state():
    print('''States:
    Fn  : Next Fibonacci number      = {}
    Fn1 : Next next Fibonacci number = {}
    n   : Next order                 = {}'''.format(Fn,Fn1,n))

# global variables renamed without underscores
Fn, Fn1, n = 0, 1, 0

n = 0
while n < 5:
    print(next_fibonacci())
    n += 1
print_fibonacci_state()

Exercise Why does the while loop prints only 3 instead of 5 Fibonacci numbers?

There is a name collision. n is also incremented by next_fibonacci(), and so the while loop is only executed 3 times in total.

With global variables

  • codes are less predictable, more difficult to reuse/extend, and

  • tests cannot be isolated, making debugging difficult.

Is it possible to store the function states without using global variables?

Yes. We can use nested functions and nonlocal variables.

def fibonacci_closure(Fn, Fn1):
    def next_fibonacci():
        '''Returns the next (generalized) Fibonacci number starting with 
        Fn and Fn1 as the first two numbers.'''
        nonlocal Fn, Fn1, n  # declare nonlocal variables
        value = Fn
        Fn, Fn1, n = Fn1, Fn + Fn1, n + 1
        return value

    def print_fibonacci_state():
        print('''States:
        Next Fibonacci number      = {}
        Next next Fibonacci number = {}
        Next order                 = {}'''.format(Fn, Fn1, n))

    n = 0  # Fn and Fn1 specified in the function arguments
    return next_fibonacci, print_fibonacci_state


next_fibonacci, print_fibonacci_state = fibonacci_closure(0, 1)
n = 0
while n < 5:
    print(next_fibonacci())
    n += 1
print_fibonacci_state()
0
1
1
2
3
States:
        Next Fibonacci number      = 5
        Next next Fibonacci number = 8
        Next order                 = 5

The state variables Fn, Fn1, n are now encapsulated, and so
the functions returned by fibonacci_closure no longer depends on any global variables.

Another benefit of using nested functions is that we can also create different Fibonacci sequence with different base cases.

my_next_fibonacci, my_print_fibonacci_state = fibonacci_closure('cs', '1302')
for n in range(5):
    print(my_next_fibonacci())
my_print_fibonacci_state()
cs
1302
cs1302
1302cs1302
cs13021302cs1302
States:
        Next Fibonacci number      = 1302cs1302cs13021302cs1302
        Next next Fibonacci number = cs13021302cs13021302cs1302cs13021302cs1302
        Next order                 = 5

next_fibonacci and print_fibonacci_state are local functions of fibonacci_closure.

  • They can access (capture) the other local variables of fibonacci_closure by forming the so-called closures.

  • Similar to the use of global statement, a non-local statement is needed for assigning nonlocal variables.

Each local function has an attribute named __closure__ that stores the captured local variables.

def print_closure(f):
    '''Print the closure of a function.'''
    print('closure of ', f.__name__)
    for cell in f.__closure__:
        print('    {} content: {!r}'.format(cell, cell.cell_contents))


print_closure(next_fibonacci)
print_closure(print_fibonacci_state)
closure of  next_fibonacci
    <cell at 0x7f6814541050: int object at 0x56190ea45380> content: 5
    <cell at 0x7f68145410d0: int object at 0x56190ea453e0> content: 8
    <cell at 0x7f6814541150: int object at 0x56190ea45380> content: 5
closure of  print_fibonacci_state
    <cell at 0x7f6814541050: int object at 0x56190ea45380> content: 5
    <cell at 0x7f68145410d0: int object at 0x56190ea453e0> content: 8
    <cell at 0x7f6814541150: int object at 0x56190ea45380> content: 5

9.3. Generator

Another way to generate a sequence of objects one-by-one is to write a generator.

fibonacci_generator = (fibonacci_iteration(n) for n in range(3))
fibonacci_generator
<generator object <genexpr> at 0x7f6814534250>

The above uses a generator expression to define fibonacci_generator.

How to obtain items from a generator?

We can use the next function.

while True: 
    print(next(fibonacci_generator)) # raises StopIterationException eventually
0
1
1
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
<ipython-input-19-e6f031ce2829> in <module>
      1 while True:
----> 2     print(next(fibonacci_generator)) # raises StopIterationException eventually

StopIteration: 

A generator object is iterable, i.e., it implements both __iter__ and __next__ methods that are automatically called in a for loop as well as the next function.

fibonacci_generator = (fibonacci_iteration(n) for n in range(5))
for fib in fibonacci_generator:  # StopIterationException handled by for loop
    print(fib)
0
1
1
2
3

Is fibonacci_generator efficient?

No again due to redundant computations.
A better way to define the generator is to use the keyword yield:

%%mytutor -h 450
def fibonacci_sequence(Fn, Fn1, stop):
    '''Return a generator that generates Fibonacci numbers
    starting from Fn and Fn1 until stop (exclusive).'''
    while Fn < stop:
        yield Fn  # return Fn and pause execution
        Fn, Fn1 = Fn1, Fn1 + Fn


for fib in fibonacci_sequence(0, 1, 5):
    print(fib)
  1. yield causes the function to return a generator without executing the function body.

  2. Calling __next__ resumes the execution, which

    • pauses at the next yield expression, or

    • raises the StopIterationException at the end.

Exercise The yield expression yield ... is mistaken in [Halterman17] to be a statement. It is actually an expression because

  • The value of a yield expression is None by default, but

  • it can be set by the generator.send method.

Add the document string to the following function. In particular, explain the effect of calling the method send on the returned generator.

%%mytutor -r -h 500
def fibonacci_sequence(Fn, Fn1, stop):
    ### BEGIN SOLUTION
    '''Return a generator that generates Fibonacci numbers
    starting from Fn and Fn1 to stop (exclusive). 
    generator.send(value) sets next number to value.'''
    ### END SOLUTION
    while Fn < stop:
        value = yield Fn
        if value is not None: 
            Fn1 = value  # set next number to the value of yield expression
        Fn, Fn1 = Fn1, Fn1 + Fn 

9.4. Optional Arguments

How to make function arguments optional?

def fibonacci_sequence(Fn=0, Fn1=1, stop=None):
    while stop is None or Fn < stop:
        value = yield Fn
        Fn, Fn1 = Fn1, Fn1 + Fn
for fib in fibonacci_sequence(0,1,5):
    print(fib)  # with all arguments specified
0
1
1
2
3
for fib in fibonacci_sequence(stop=5):
    print(fib)  # with default Fn=0, Fn1=1
0
1
1
2
3

stop=5 is called a keyword argument. Unlike positional arguments, it specifies the name of the argument explicitly.

Exercise stop is an optional argument with the default value None. What is the behavior of the following code?

for fib in fibonacci_sequence(5):
    print(fib)
    if fib > 10:  
        break  # Will this be executed?
5
1
6
7
13

With the default value of None, the while loop becomes an infinite loop. The generator will keep generating the next Fibonacci number without any bound on the order. In particular, fibonacci_sequence(5) creates an unstoppable (default) generator with base case Fn=5 (specified) and Fn1=1 (default).

Rules for specifying arguments:

  1. Keyword arguments must be after all positional arguments.

  2. Duplicate assignments to an argument are not allowed.

E.g., the following results in error:

fibonacci_sequence(stop=10, 1)
  File "<ipython-input-27-c4b4809b18c1>", line 1
    fibonacci_sequence(stop=10, 1)
                               ^
SyntaxError: positional argument follows keyword argument
fibonacci_sequence(1, Fn=1)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-28-2db5e024912c> in <module>
----> 1 fibonacci_sequence(1, Fn=1)

TypeError: fibonacci_sequence() got multiple values for argument 'Fn'

The following shows that the behavior of range is different.

for count in range(1, 10, 2):
    print(count, end=' ')  # counts from 1 to 10 in steps of 2
print()
for count in range(1, 10):
    print(count, end=' ')  # default step=1
print()
for count in range(10):
    print(count, end=' ')  # default start=0, step=1
range(stop=10)  # fails
1 3 5 7 9 
1 2 3 4 5 6 7 8 9 
0 1 2 3 4 5 6 7 8 9 
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-29-f3395132058b> in <module>
      7 for count in range(10):
      8     print(count, end=' ')  # default start=0, step=1
----> 9 range(stop=10)  # fails

TypeError: range() takes no keyword arguments

range takes only positional arguments.
However, the first positional argument has different intepretations (start or stop) depending on the number of arguments (2 or 1).

range is indeed NOT a generator.

print(type(range),type(range(10)))
<class 'type'> <class 'range'>

9.5. Variable number of arguments

We can simulate the behavior of range by having a variable number of arguments.

def print_arguments(*args, **kwargs):
    '''Take any number of arguments and prints them'''
    print('args ({}): {}'.format(type(args),args))
    print('kwargs ({}): {}'.format(type(kwargs),kwargs))

print_arguments(0, 10, 2, start=1, stop=2)
print("{k}".format(greeting="Hello",k=8),"*"  )
args (<class 'tuple'>): (0, 10, 2)
kwargs (<class 'dict'>): {'start': 1, 'stop': 2}
8 *
  • args is a tuple of positional arguments.

  • kwargs is a dictionary of keyword arguments.

* and ** are unpacking operators for tuple/list and dictionary respectively:

args = (0, 10, 2)
kwargs = {'start': 1, 'stop': 2}
print_arguments(*args, **kwargs)
args (<class 'tuple'>): (0, 10, 2)
kwargs (<class 'dict'>): {'start': 1, 'stop': 2}

The following function converts all the arguments to a string.
It will be useful later on.

def argument_string(*args, **kwargs):
    '''Return the string representation of the list of arguments.'''
    return '({})'.format(', '.join([
        *['{!r}'.format(v) for v in args],  # arguments
        *['{}={!r}'.format(k, v)
          for k, v in kwargs.items()]  # keyword arguments
    ]))

argument_string(0, 10, 2, start=1, stop=2)
'(0, 10, 2, start=1, stop=2)'

Exercise Redefine fibonacci_sequence so that the positional arguments depend on the number of arguments:

def fibonacci_sequence(*args):
    '''Return a generator that generates Fibonacci numbers
    starting from Fn and Fn1 to stop (exclusive). 
    generator.send(value) sets next number to value.
    
    fibonacci_sequence(stop)
    fibonacci_sequence(Fn,Fn1)
    fibonacci_sequence(Fn,Fn1,stop)
    '''
    Fn, Fn1, stop = 0, 1, None  # default values

    # handle different number of arguments
    if len(args) is 1:
        ### BEGIN SOLUTION
        stop = args[0]
        ### END SOLUTION
    elif len(args) is 2:
        Fn, Fn1 = args[0], args[1]
    elif len(args) > 2:
        Fn, Fn1, stop = args[0], args[1], args[2]
    
    while stop is None or Fn < stop:
        value = yield Fn
        if value is not None: 
            Fn1 = value  # set next number to the value of yield expression
        Fn, Fn1 = Fn1, Fn1 + Fn
for fib in fibonacci_sequence(5): # default Fn=0, Fn=1
    print(fib)
0
1
1
2
3
for fib in fibonacci_sequence(1, 2): # default stop=None
    print(fib)  
    if fib>5:
        break
1
2
3
5
8
args = (1, 2, 5)
for fib in fibonacci_sequence(*args): # default stop=None
    print(fib) 
1
2
3

9.6. Decorator

What is function decoration?
Why decorate a function?

def fibonacci(n):
    '''Returns the Fibonacci number of order n.'''
    global count, depth
    count += 1
    depth += 1
    print('{:>3}: {}fibonacci({!r})'.format(count, '|' * depth, n))
    
    value = fibonacci(n - 1) + fibonacci(n - 2) if n > 1 else 1 if n == 1 else 0
    
    depth -= 1
    if depth is -1:  # recursion done
        print('Done')
        count = 0  # reset count for subsequent recursions
    return value


count, depth = 0, -1
for n in range(6):
    print(fibonacci(n))
  1: fibonacci(0)
Done
0
  1: fibonacci(1)
Done
1
  1: fibonacci(2)
  2: |fibonacci(1)
  3: |fibonacci(0)
Done
1
  1: fibonacci(3)
  2: |fibonacci(2)
  3: ||fibonacci(1)
  4: ||fibonacci(0)
  5: |fibonacci(1)
Done
2
  1: fibonacci(4)
  2: |fibonacci(3)
  3: ||fibonacci(2)
  4: |||fibonacci(1)
  5: |||fibonacci(0)
  6: ||fibonacci(1)
  7: |fibonacci(2)
  8: ||fibonacci(1)
  9: ||fibonacci(0)
Done
3
  1: fibonacci(5)
  2: |fibonacci(4)
  3: ||fibonacci(3)
  4: |||fibonacci(2)
  5: ||||fibonacci(1)
  6: ||||fibonacci(0)
  7: |||fibonacci(1)
  8: ||fibonacci(2)
  9: |||fibonacci(1)
 10: |||fibonacci(0)
 11: |fibonacci(3)
 12: ||fibonacci(2)
 13: |||fibonacci(1)
 14: |||fibonacci(0)
 15: ||fibonacci(1)
Done
5

The code decorates the fibonacci function by printing each recursive call and the depth of the call stack.
The decoration is useful in showing the efficiency of the function, but it rewrites the function definition.

How to decorate a function without changing its code?

  • What if the decorations are temporary and should be removed later?

  • Go through the source codes of all decorated functions to remove the decorations?

  • When updating a piece of code, switch back and forth between original and decorated codes?

What about defining a new function that calls and decorates the original function?

def fibonacci(n):
    '''Returns the Fibonacci number of order n.'''
    return fibonacci(n - 1) + fibonacci(n - 2) if n > 1 else 1 if n is 1 else 0

def fibonacci_decorated(n):
    '''Returns the Fibonacci number of order n.'''
    global count, depth
    count += 1
    depth += 1
    print('{:>3}: {}fibonacci({!r})'.format(count, '|' * depth, n))
    
    value = fibonacci(n)
    
    depth -= 1
    if depth is -1:  # recursion done
        print('Done')
        count = 0  # reset count for subsequent recursions
    return value


count, depth = 0, -1
for n in range(6):
    print(fibonacci_decorated(n))    
  1: fibonacci(0)
Done
0
  1: fibonacci(1)
Done
1
  1: fibonacci(2)
Done
1
  1: fibonacci(3)
Done
2
  1: fibonacci(4)
Done
3
  1: fibonacci(5)
Done
5

We want fibonacci to call fibonacci_decorated instead.
What about renaming fibonacci_decorated to fibonacci?

fibonacci = fibonacci_decorated
count, depth = 0, -1
fibonacci_decorated(10)

(If you are faint-hearted, don’t run the above code.)

We want fibonacci_decorated to call the original fibonacci.

The solution is to capture the original fibonacci in a closure:

import functools


def print_function_call(f):
    '''Return a decorator that prints function calls.'''
    @functools.wraps(f)  # give wrapper the identity of f and more
    def wrapper(*args, **kwargs):
        nonlocal count, depth
        count += 1
        depth += 1
        call = '{}{}'.format(f.__name__, argument_string(*args, **kwargs))
        print('{:>3}:{}{}'.format(count, '|' * depth, call))

        value = f(*args, **kwargs)  # wrapper calls f

        depth -= 1
        if depth is -1:
            print('Done')
            count = 0
        return value

    count, depth = 0, -1
    return wrapper  # return the decorated function

print_function_call takes in f and returns wrapper, which captures and decorates f:

  • wrapper expects the same set of arguments for f,

  • returns the same value returned by f on the arguments, but

  • can execute additional codes before and after calling f to print the function call.

By redefining fibonacci as the returned wrapper, the original fibonacci captured by wrapper calls wrapper as desired.

def fibonacci(n):
    return fibonacci(n - 1) + fibonacci(n - 2) if n > 1 else 1 if n is 1 else 0


fibonacci = print_function_call(
    fibonacci)  # so original fibonnacci calls wrapper
fibonacci(5)
  1:fibonacci(5)
  2:|fibonacci(4)
  3:||fibonacci(3)
  4:|||fibonacci(2)
  5:||||fibonacci(1)
  6:||||fibonacci(0)
  7:|||fibonacci(1)
  8:||fibonacci(2)
  9:|||fibonacci(1)
 10:|||fibonacci(0)
 11:|fibonacci(3)
 12:||fibonacci(2)
 13:|||fibonacci(1)
 14:|||fibonacci(0)
 15:||fibonacci(1)
Done
5

The redefinition does not change the original fibonacci captured by wrapper.

import inspect
for cell in fibonacci.__closure__:
    if callable(cell.cell_contents):
        print(inspect.getsource(cell.cell_contents))
def fibonacci(n):
    return fibonacci(n - 1) + fibonacci(n - 2) if n > 1 else 1 if n is 1 else 0

Python provides the syntatic sugar below to simplify the redefinition.

@print_function_call
def fibonacci(n):
    return fibonacci(n - 1) + fibonacci(n - 2) if n > 1 else 1 if n is 1 else 0


fibonacci(5)
  1:fibonacci(5)
  2:|fibonacci(4)
  3:||fibonacci(3)
  4:|||fibonacci(2)
  5:||||fibonacci(1)
  6:||||fibonacci(0)
  7:|||fibonacci(1)
  8:||fibonacci(2)
  9:|||fibonacci(1)
 10:|||fibonacci(0)
 11:|fibonacci(3)
 12:||fibonacci(2)
 13:|||fibonacci(1)
 14:|||fibonacci(0)
 15:||fibonacci(1)
Done
5

There are many techniques used in the above decorator.

Why use a variable number of arguments in wrapper

To decorate any function with possibly different number of arguments.

Why decorate the wrapper with @functools.wraps(f)?

  • Ensures some attributes (such as __name__) of the wrapper function is the same as those of f.

  • Add useful attributes. E.g., __wrapped__ stores the original function so we can undo the decoration.

fibonacci, fibonacci_decorated = fibonacci.__wrapped__, fibonacci  # recover
print('original fibonacci:')
print(fibonacci(5))

fibonacci = fibonacci_decorated  # decorate
print('decorated fibonacci:')
print(fibonacci(5))
original fibonacci:
5
decorated fibonacci:
  1:fibonacci(5)
  2:|fibonacci(4)
  3:||fibonacci(3)
  4:|||fibonacci(2)
  5:||||fibonacci(1)
  6:||||fibonacci(0)
  7:|||fibonacci(1)
  8:||fibonacci(2)
  9:|||fibonacci(1)
 10:|||fibonacci(0)
 11:|fibonacci(3)
 12:||fibonacci(2)
 13:|||fibonacci(1)
 14:|||fibonacci(0)
 15:||fibonacci(1)
Done
5

How to use decorator to improve recursion?

We can also use a decorator to make recursion more efficient by caching the return values.
cache is a dictionary where cache[n] stores the computed value of \(F_n\) to avoid redundant computations.

def caching(f):
    '''Return a decorator that caches a function with a single argument.'''
    @functools.wraps(f)
    def wrapper(n):
        if n not in cache:
            cache[n] = f(n)
        else:
            print('read from cache')
        return cache[n]

    cache = {}
    wrapper.clear_cache = lambda : cache.clear()  # add method to clear cache
    return wrapper


@print_function_call
@caching
def fibonacci(n):
    return fibonacci(n - 1) + fibonacci(n - 2) if n > 1 else 1 if n == 1 else 0
fibonacci(5)
fibonacci(5)
fibonacci.clear_cache()
fibonacci(5)
  1:fibonacci(5)
  2:|fibonacci(4)
  3:||fibonacci(3)
  4:|||fibonacci(2)
  5:||||fibonacci(1)
  6:||||fibonacci(0)
  7:|||fibonacci(1)
read from cache
  8:||fibonacci(2)
read from cache
  9:|fibonacci(3)
read from cache
Done
  1:fibonacci(5)
read from cache
Done
  1:fibonacci(5)
  2:|fibonacci(4)
  3:||fibonacci(3)
  4:|||fibonacci(2)
  5:||||fibonacci(1)
  6:||||fibonacci(0)
  7:|||fibonacci(1)
read from cache
  8:||fibonacci(2)
read from cache
  9:|fibonacci(3)
read from cache
Done
5

A method clear_cache is added to the wrapper to clear the cache.
lambda <argument list> : <expression>is called a lambda expression, which conveniently defines an anonymous function.

type(fibonacci.clear_cache), fibonacci.clear_cache.__name__
(function, '<lambda>')

9.7. Module

How to create a module?

To create a module, simply put the code in a python source file <module name>.py in

  • the current directory, or

  • a python site-packages directory in system path.

import sys
print(sys.path)
['/home/lec/ccha23/CS1302ICP/Lecture6', '/opt/anaconda/lib/python37.zip', '/opt/anaconda/lib/python3.7', '/opt/anaconda/lib/python3.7/lib-dynload', '', '/home/lec/ccha23/.local/lib/python3.7/site-packages', '/opt/anaconda/lib/python3.7/site-packages', '/opt/anaconda/lib/python3.7/site-packages/IPython/extensions', '/home/lec/ccha23/.ipython']

For example, to create a module for generating Fibonacci numbers:

%more fibonacci.py
import fibonacci as fib # as statement shortens name
help(fib)
Help on module fibonacci:

NAME
    fibonacci - Contain functions for generating fibonacci numbers.

FUNCTIONS
    fibonacci(n)
        Returns the Fibonacci number of order n.
    
    fibonacci_iteration(n)
        Returns the Fibonacci number of order n but without recursion.

FILE
    /home/lec/ccha23/CS1302ICP/Lecture6/fibonacci.py
print(fib.fibonacci(5))
print(fib.fibonacci_iteration(5))
5
5