Powerful functions

In this section we will take our first fibonacci() function and enhance it little by little as we indulge ourselves with Python features.

Functions are objects

We can treat our functions as if they were any other object like a list, a string or any other. In fact, everything in Python is an object! This means we can take our fibonacci() function,

def fibonacci(n):
    """Returns the n-th Fibonacci number."""
    a, b = 0, 1
    for i in range(n):
        a, b = b, a+b
    return a

and assign it to another variable

>>> fib = fibonacci
>>> fib(8)
>>> fib is fibonacci

A good practice when writing functions is to add a documentation string in the first statement. It can be accessed by means of the __doc__ attribute.

>>> fibonacci.__doc__
'Returns the n-th Fibonacci number.'

Functions can be stored in data structures

Given that Python functions are objects, they can be stored in data structures. Let’s take this simple function as well

def square(n):
    """Returns the squared number."""
    return n**2

and build the following list:

my_functions = [fibonacci, square]

We can now do something like this

>>> my_functions
[<function __main__.fibonacci(n)>, <function __main__.square(n)>]
>>> for function in my_functions:
...     print(function.__doc__)
Returns the n-th Fibonacci number.
Returns the squared number.
>>> my_functions[0](8)
>>> my_functions[1](8)

Functions can be passed to other functions

This characteristic allows functions to be both the input and the output of other functions, empowering us to do something like evaluating the composition of the two functions that we have just defined

def eval_composition(f, g, n):
    """Returns f(g(n))."""
    return f(g(n))

which works just as we would expect

>>> eval_composition(square, fibonacci, 8)
>>> eval_composition(square, fibonacci, 8) == square(fibonacci(8))

Functions can be nested

Python allows functions to be defined inside other functions. This opens the door to taking our composition function even further. Instead of returning $f(g(n))$ evaluated, we can return $f \circ g$ itself:

def compose(f, g):
    """Returns the composition function of f and g."""
    def nested(n):
        """Returns the evaluation of f(g(n))."""
        return f(g(n))
    return nested

The trick is that nested() does not exist outside compose():

>>> compose
<function __main__.compose(f, g)>
>>> nested
NameError: name 'nested' is not defined

It turns out that we can now call $f(g(n))$ in this natural way:

>>> my_function = compose(square, fibonacci)
>>> my_function(8)

And last but not least, let’s make it even more beautiful. Why don’t we return the composition of an arbitrary number of functions $f_1 \circ f_2 \circ \dots \circ f_k$?

def compose(functions):
    """Returns the composition of a list of functions."""
    def nested(n):
        """Magic happens."""
        for f in reversed(functions):
            n = f(n)
        return n
    return nested

In this upgraded version, we take a list of functions as the input and we loop over it in reverse order to do the composition.

>>> my_function = compose([fibonacci, square, square])
>>> my_function(2)
>>> fibonacci((2**2)**2)

Víctor Adell
Universitat Politècnica de Catalunya, 2023

Prohibit copiar. Tots els drets reservats.
No copy allowed. All rights reserved.