Functional Programming

You are not logged in.

Please Log In for full access to the web site.
Note that this link will take you to an external site (https://shimmer.mit.edu) to authenticate, and then you will be redirected back to this page.

This reading is relatively new, and your feedback will help us improve it! If you notice mistakes (big or small), if you have questions, if anything is unclear, if there are things not covered here that you'd like to see covered, or if you have any other suggestions; please get in touch during office hours or open lab hours, or via e-mail at 6.101-help@mit.edu.

1) Introduction§

Functions were one of the first Python features that we studied this semester, but they are a remarkably versatile feature that deserves more attention. Functional programming is a classic programming style heavily focused on functions. As recently as the mid-2000s, functional programming was seen by industry as a niche academic idea, but, in the mean time, it went quite mainstream. While it was originally associated with distinct programming languages like Haskell, many of its best ideas were adopted by better-known languages like Python. We've been using those features (like lambda) all along this semester, and this reading will show you even more handy patterns in functional programming.

As it happens, one of the first programming languages that worked on multiple different kinds of computers was LISP, and it arguably introduced functional programming as a practical style, way back in 1958. This week's lab will lead you through implementing your own subset of LISP. Getting a better understanding of functions and their uses is also directly relevant to that experience.

2) More Iteration to Recursion§

One interesting feature of LISP is that it does not feature any looping structures (there are no for or while loops). But it does have functions, and, as we've seen before, it's possible to use functions to implement loops. One of the first examples we looked at, factorial, could always be written with a loop like so:

def factorial(n):
    result = 1
    for i in range(1, n+1):
        result *= i
    return result

But we also learned that it can be written recursively as a one-liner.

def factorial(n):
    return 1 if n <= 1 else n * factorial(n-1)

In the LISP, every function needs to be written in this style, effectively as a single return with an expression that does all the heavy lifting!

Consider this example, which capitalizes all words in a list:

def all_caps_list(words):
    return [w.upper() for w in words]

We can apply another of our recursion patterns, the first/rest decomposition, to write a recursive version.

def all_caps_list(words):
    if len(words) == 1:
        return words[0].upper()
    else:
        return [words[0].upper()] + all_caps_list(words[1:])

How does this recursive version behave on lists of strings?

It may be surprising how many loops can be turned into instances of recursion. Consider this basic one.

for i in range(n):
    print('hello', i)

This structure might seem a little bit trickier to implement using recursion, but we'll see that we can indeed do it, using a higher-order function (meaning a function that takes a function as input). Here, we encode what was the body of our loop (i.e., the thing we want to repeat multiple times) as a function; then we call that function repeatedly, using recursion to keep track of how many times we still need to run that code.

def repeat_n_times(n, func):
    if n == 0:
        return
    func(n)
    repeat_n_times(n-1, func)

What happens when we call repeat_n_times(20, lambda i: print('hello', i))?

3) Classes to Functions§

Object-oriented programming, e.g. with classes in Python, is commonly used in industry as a way to organize complex code bases. However, essentially any use of classes can be reimplemented with functions in Python. Let's look at two examples.

We can build a simple "bank" class that helps us maintain multiple named numeric balances.

class Bank:
    def __init__(self):
        self.accounts = {}

    def balance_of(self, account):
        return self.accounts.get(account, 0)

    def deposit(self, account, amount):
        self.accounts[account] = self.balance_of(account) + amount
>>> b = Bank()
>>> b.deposit('Steve', 3)
>>> b.deposit('Magdalena', 4)
>>> b.deposit('Steve', 5)
>>> b.balance_of('Steve')
8
>>> b.balance_of('Magdalena')
4

The chance to encapsulate the details of balance maintenance within a class pays off when it comes to writing and maintaining programs. However, functions also provide us all the ingredients we need to the same end. Consider the following code:

def bank():
    accounts = {}

    def balance_of(account):
        return accounts.get(account, 0)

    def deposit(account, amount):
        accounts[account] = balance_of(account) + amount

    return balance_of, deposit
>>> balance_of, deposit = bank()
>>> deposit('Steve', 3)
>>> deposit('Magdalena', 4)
>>> deposit('Steve', 5)
>>> balance_of('Steve')
8
>>> balance_of('Magdalena')
4

It's important that the two functions that bank() returns, balance_of and deposit, share a freshly created enclosing frame with a new accounts dictionary. This frame is equivalent to the object in the object-oriented version. If the details there are not clear, try drawing an environment diagram.

3.1) Aside: nonlocal§

So far it looks like we'll have smooth sailing converting classes into functions. Here's a simpler example than the bank -- a counter that we can keep incrementing by one. But it reveals a subtle issue in Python with using variables from an enclosing frame to store information.

def counter():
    tally = 0

    def increment():
        tally += 1
        return tally

    return increment

Consider running inc = counter(), and then repeatedly calling inc(). What happens when we do this?

This code exhibits a common pitfall in Python. Namely, variable tally in increment is being treated as local to that function rather than inherited from the enclosing frame. Why?

Because when a function modifies a variable, Python forces the variable to be present directly in the function's frame... but here tally was not initialized locally, so Python complains that tally does not exist.

But Python gives us a way to control this. In particular, if we want to use the variable tally from the enclosing frame inside of increment, we can accomplish this using the nonlocal keyword:

def counter():
    tally = 0
    
    def increment():
        nonlocal tally
        tally += 1
        return tally

    return increment

The line nonlocal tally tells Python that whenever we refer to the name tally inside of increment (whether we're just looking up the value, or setting/changing it), we should always be looking for the variable in one of our parent frames, instead of in the local frame for calling increment.

Check Yourself:

Look back at the bank example from above:

def bank():
    accounts = {}

    def balance_of(account):
        return accounts.get(account, 0)

    def deposit(account, amount):
        accounts[account] = balance_of(account) + amount

    return balance_of, deposit

The functions balance_of and deposit are using accounts from their enclosing frame to update the balances of bank accounts.

What's the difference between the way bank uses a variable from an enclosing frame, and the way that counter does? Why does counter need nonlocal tally, but bank doesn't need to say nonlocal accounts?

The functions in bank only read the value of the accounts variable, to reach the dictionary of accounts. The dictionary is mutated by deposit, but this doesn't involve reassigning the accounts variable itself.

By contrast, counter needs to reassign the tally variable itself. So you need to use nonlocal when you will assign to a variable in an enclosing frame, but not when you just need to use the variable's value.

4) Memoization§

We can also use higher-order functions to improve the performance of naive code. Here's a classic of recursion, a calculator for Fibonacci numbers.

def fib(n):
    if n < 2:
        return n
    return fib(n-2) + fib(n-1)
Check Yourself:

Roughly how long will it take to compute fib(100)?

It turns out that this will take a really long time! Not only will it not be finished if we step out to get a coffee or a sandwich, but it won't be finished if we come back in a month, or a year, or even a decade. In fact, we expect this program, as written, to take longer than the estimated remaining life of the universe, even if we had a really fast computer.

What's causing this to be so slow? Read on to find out!

We can start to see why this is so slow by looking at a tree showing the recursive calls that are made when computing fib(100). We start with a single top-level call:

That then makes two recursive calls:

Each of those makes two recursive calls:

And this pattern continues:

How many times will we ultimately make a call computing the 94th Fibonacci number, like fib(94), in the structure above? Enter your answer as an integer below:

But there's something interesting here, which we can exploit: even though we're calling fib(94) multiple times, the result is always the same. But in order to find that result (as things are currently written), Python needs to explore the entire subtree of the drawing above that's rooted at 94, which takes a long time.

We can see some of this repeated structure by highlighting the subtrees associated with any given number, in this case 96:

Any time we call fib(96) (multiple times shown here), Python will need to explore an entire subtree to find that result, and the result will always be the same.

What we'll try next is exploiting this repeated structure in the tree above by remembering Fibonacci numbers as we compute them, so that we can reuse those results later. So, for example, when we call fib(96), we'll first ask: have I already computed this result? If so, let's avoid repeating the work that took, and just return the result I've remembered. Otherwise, let's do the computation normally. In that way, we can avoid exploring large swathes of the tree. For example, just remembering the result of fib(96) let's us cut out big parts of the tree:

But then we can do the same of fib(97):

and fib(98):

In fact, when we cross out all subtrees that are redundant in this way, we are left with a remaining tree that, in general for fib(n), only has about n nodes that aren't crossed out. Now that is much more tractable to compute.

Here's a Python realization of the idea:

cache = {}

def fib(n):
    if n not in cache:
        if n < 2:
            cache[n] = n
        else:
            cache[n] = fib(n-2) + fib(n-1)
    return cache[n]

What's up with the word "cache"? The English word referred originally to something like the hiding place where a pirate stashed his treasure, but in computer science, a cache stores values that we think we will be consulting again soon.

We call the new fib memoized. No, that's not a typo for "memorized." "Memoization" is a computer-science term for saving and reusing intermediate results, particularly in problems with a lot of repeated structure like this one.

This code runs much more quickly, but it has a downside: it uses a global variable. All sorts of things can go wrong in larger code bases that include global variables. For instance, what if we wanted to use the same trick for a factorial function?

cache = {}

def factorial(n):
    if n not in cache:
        if n < 2:
            cache[n] = 1
        else:
            cache[n] = n * factorial(n-1)
    return cache[n]

Whoops, now both functions are sharing the same cache variable, and havoc ensues! (The initial assignment overwrites the existing global variable rather than creating a new one.) We would rather not force the authors of the two functions to coordinate on variable-name selection.

Here's a version that avoids that issue by putting cache inside of an enclosing frame:

def fib(n):
    cache = {}

    def _actual_fib(n):
        if n not in cache:
            if n < 2:
                cache[n] = n
            else:
                cache[n] = _actual_fib(n-2) + _actual_fib(n-1)
        return cache[n]

    return _actual_fib(n)

How does this version behave?

OK, one major complaint handled. However, you probably also noticed strong similarities between the memoized Fibonacci and factorial functions. Can we factor the common parts out into some reusable ingredient?

One way to implement this would be to use a class, like so:

class MemoizedFunction:
    def __init__(self, func):
        self.func = func
        self.cache = {}

    def __call__(self, *args):
        if args not in self.cache:
            self.cache[args] = self.func(*args)
        return self.cache[args]

We've already seen most of the ingredients used here, though one new piece of magic is the __call__ method. Whenever code tries to call some class instance like a function, Python calls that instance's __call__ method. In other words, we can define our own new function-like things by explaining what it means to call them.

Another new piece of magic is the * syntax applied to a formal parameter, here called *args. This allows the function to be called with any number of actual arguments, which are bundled up together into a tuple and bound to the formal parameter args. (Note that there is nothing special about the name args. It's just conventionally used for variable-length arguments like this, just as self is conventionally used for the self parameter. And there's nothing special about using it in the __call__ dunder method. You can use * to handle variable-length arguments in any function you define.)

Finally, the code also uses *args in its body, but we've seen that before -- it's the unpacking operator, so it unpacks the tuple back into individual arguments for the wrapped function func().

Now we can add memoization to the original, naive Fibonacci function, like so.

def fib(n):
    if n < 2:
        return n
    return fib(n-2) + fib(n-1)
fib = MemoizedFunction(fib)

But, as we saw earlier on, it turns out we don't need classes to implement generic memoization. Functions are more than capable, too!

def memoize(func):
    cache = {}

    def _mfunc(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]

    return _mfunc

Again, naive Fibonacci is easily memoized.

def fib(n):
    if n < 2:
        return n
    return fib(n-2) + fib(n-1)
fib = memoize(fib)

This pattern is so common, of using a library function to augment a function definition, that Python has special syntax for it. We indicate what's called a decorator by putting an "at" sign and a function name before another function's definition.

@memoize
def fib(n):
    if n < 2:
        return n
    return fib(n-2) + fib(n-1)

You may remember that we used the same trick in recitation, to instrument functions to log all recursive calls to the screen. Now you know that it's not magic, and you are free to use the same trick in your own code!