Code Structures II: Functions and Modules

1 Functions

1.1 Defining functions

Very often, we want to create reusable code. Imagine that we are given the task to calculate a very standard Cobb-Douglas utility function \(u(x_1, x_2) = x_1^{0.4} * x_2^{0.6}\) with \(x_1 = 1\) and \(x_2 = 2\). We could program it as follows.

x1 = 1
x2 = 2
u = x1**0.4 * x2**0.6
print(u)
1.515716566510398

Now, the exercise changes and you are to assume \(x_1 = 3\) and \(x_2 = 4\) instead. You could of course change your code.

x1 = 3
x2 = 4
u = x1**0.4 * x2**0.6
print(u)
3.5652049159320067

After a while this gets tedious though. May be we should define a proper function to which we can pass the arguments \(x_1\) and \(x_2\). We can define a function easily by using the def command.

def simple_utility(x1, x2):
    u = x1**0.4 * x2**0.6
    return u

simple_utility(3, 4)
3.5652049159320067

As you can see, there are basically three things to a function:

  • a name: simple_utility,

  • the function arguments: x1 and x2, and

  • a return value: u.

We can generalize this function even further by parameterising the exponents.

def simple_utility(x1, x2, a, b):
    u = x1**a * x2**b
    return u
simple_utility(3, 4, 0.4, 0.6)
3.5652049159320067

1.2 Positional and keyword arguments

If we call the function as before without assigning the passed argument explicitly to the function arguments, we have to be very careful to pass the arguments in the right order. While this is not a problem in the case of our simple function, it might get messy with more complicated ones. Alternatively, we can call our function using keywords. The order of the elements is irrelevant then.

simple_utility(x2 = 4, x1 = 3, b = 0.6, a = 0.4)
3.5652049159320067

As you can see, the function returns the same value although we have passed argument b before a.

1.3 Default values for arguments

Sometimes you want to assign default values to specific function arguments. Say you want the exponents of our utility function to be 0.5 and 0.5 by default. We can define such a function as follows.

def simple_utility(x1, x2, a = 0.5, b = 0.5):
    u = x1**a * x2**b
    return u
print(simple_utility(3, 4))
print(simple_utility(3, 4, 0.4, 0.6))
3.4641016151377544
3.5652049159320067

1.4 Variable number of input arguments

Sometimes we want to write functions but don’t know the number of arguments that get passed to it. Think of print here which accepts an arbitrary number of arguments.

print('Hello')
print('Hello', 'World')
print('Hello', 'World', '!')
Hello
Hello World
Hello World !

To deal with such cases we can use an asterisk. This ensures that all arguments that the function is called with are grouped into a tuple.

def print_philosophers(*args):
    print('The philosophers are:', args)
print_philosophers('Marx', 'Engels')
print_philosophers('Rawls', 'Nozick', 'Cohen')
The philosophers are: ('Marx', 'Engels')
The philosophers are: ('Rawls', 'Nozick', 'Cohen')

The same thing can be done with keyword arguments when you use two asterisks. In this case, variables and their values are put into a dictionary.

def print_person(**kwargs):
    print('The variable-value pairs are:', kwargs)

print_person(philosopher = 'Nozick', economist = 'Hayek')
The variable-value pairs are: {'philosopher': 'Nozick', 'economist': 'Hayek'}

1.5 Improving documentation with docstrings

Usually, we would like to document what a function does. In Python, this is very easy. Just include a string in the first line. If you want to write a longer text use three single quotes at the beginning and end. Note that you can use three single quotes in general to write text over multiple lines.

def print_word(word):
    '''This function just prints the word given as an input argument.
        Input: word 
        Output: none'''
    print(word)

If we now use the help function, we get the text that we wrote.

help(print_word)
Help on function print_word in module __main__:

print_word(word)
    This function just prints the word given as an input argument.
    Input: word 
    Output: none

You might be wondering why the output is none, since the function does something. It is true that the function prints text on the display, but it does not return any values.

1.6 Functions are objects!

Something that may surprise you as first is that functions are also objects. This means that you can assign them to variables, use them as function arguments and also return them from other functions.

Let’s say we write a functions whose sole purpose is to print out ‘Hello world!’.

def print_hw():
    print('Hello world!')

print_hw()
Hello world!

Now, so far this is stuff we already know. But we can now also write a function which runs any function that we pass to it.

def run_func(func):
    func()
run_func(print_hw)
Hello world!

What did we do here? We first created a function, print_hw, which prints Hello world!. We then defined another function, run_func, which takes a function as an argument and executes it. Note that when we called this function, we did not write run_func(print_hw()) but got rid of the parentheses instead. Without the parentheses the function works just like another object that we might pass to a function as an argument.

We can of course also extend our simple example, by adding additional arguments.

def normal_print(string1, string2):
    print(string1, string2)

def emphasize_print(string1, string2):
    print(string1.upper(), string2.upper(), '!')
    
def run_func(func, string1, string2): 
    func(string1, string2)
    
run_func(normal_print, 'hello', 'world')
run_func(emphasize_print, 'hello', 'world')
    
hello world
HELLO WORLD !

1.7 Anonymous functions

Sometimes we have a short function but do not want to go through the trouble of explicitly defining it. Say we have a list of economists.

economists = ['hayek', 'keynes', 'marshall', 'marx']

We have also created a function that applies another function to each element of the list and prints the result out to the display.

def edit_list(words, func):
    for word in words: 
        print(func(word))

We now notice that the economists names should be capitalized and emphasized (Imagine this an Econ 101 motivation seminar.). For this, we could write the following function.

def emphasize_word(word):
    return word.capitalize() + '!'

The function does what is expected of it.

edit_list(economists, emphasize_word)
Hayek!
Keynes!
Marshall!
Marx!

Yet somehow this all seems to be relatively wordy, given that our function emphasize_word does not do a lot. Can we shorten this up. Yes, we can! Using the lambda function, we could also have written.

edit_list(economists, lambda word: word.capitalize() + '!')
Hayek!
Keynes!
Marshall!
Marx!

This code does exactly the same as the preceding one but is more compact because we save time on tedious function definitions. You will notice that our function does not have name anymore. It is anonymous! Moreover, you will have noticed the lambda which indicates that we want to define an anonymous function.

While anonymous function can be practical they also often have a disadvantage: They make the code less explicit.

Finally, note that you can use lambda functions also with multiple arguments:

addition = lambda x, y: x + y
addition(2, 4)
6

1.8 Scope

The name of an object might denote different things depending on where it is placed in the code. Specifically, variables that you define in the main program are global variables, variables that you define in functions are local variables. Global variables can be accessed from within functions.

my_favorite_economist = 'Hirschman'

def print_favorite():
    print('My favorite economist is:', my_favorite_economist + '!')
print_favorite()
My favorite economist is: Hirschman!

However, you can also define a local variable my_favorite_economist.

my_favorite_economist = 'Hirschman'

def print_favorite():
    my_favorite_economist = 'Acemoglu'
    print('My favorite economist is:', my_favorite_economist + '!')

print_favorite()
print('My favorite economist is:', my_favorite_economist + '!')
My favorite economist is: Acemoglu!
My favorite economist is: Hirschman!

In this case, we have two variables. We first created the global variable with the value 'Hirschman'. Then, within the function print_favorite we created a local variable with the same name and assigned the string 'Acemoglu' to it. As you can see, in this case the print_favorite function acccesses the local namespace and therefore prints the value 'Acemoglu'. The print function in the main script however looks at the global namespace and therefore prints 'Hirschman'.

If you want to change the value of a global variable within a function you need to be explicit about it.

my_favorite_economist = 'Hirschman'

def print_favorite(): 
    global my_favorite_economist
    my_favorite_economist = 'Acemoglu'
    print('My favorite economist is:', my_favorite_economist + '!')

print_favorite()
print('My favorite economist is:', my_favorite_economist + '!')
My favorite economist is: Acemoglu!
My favorite economist is: Acemoglu!

As you can see, the two functions, print and print_favorite, now print the same value, since we have changed the value of the global variable.

As a general principle, I would recommend to stick with local variables and explicitly pass variables whose values you would like to have as return values. This way, your function is not able to mess up your global namespace.

1.9 Error handling

When an error occurs during the execution of a program, Python throws an exception and terminates the program.

my_list = list(range(0, 11))
print(my_list)
my_list[11]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
/tmp/ipykernel_2329/2918673613.py in <module>
      1 my_list = list(range(0, 11))
      2 print(my_list)
----> 3 my_list[11]

IndexError: list index out of range

Sometimes however you want the program to continue, even though an error occured. Moreover, you might want to give the user of your program a more transparent message than the one given here. To handle errors, you can use try and except.

my_list = list(range(0, 11))
try: 
    position = 11
    my_list[position]
except: 
    print('You need to provide a position between 0 and', len(my_list)-1, 'but you entered', position)
You need to provide a position between 0 and 10 but you entered 11

Since we did not qualify our except statement it works as a catch-all for errors. Usually, it makes more sense to specify the type of exception.

my_list = list(range(0, 11))
try: 
    position = 11
    my_list[position]
except IndexError: 
    print('You need to provide a position between 0 and', len(my_list)-1, 'but you entered', position)
You need to provide a position between 0 and 10 but you entered 11

We could then add further except statements to differentiate between error types. You can also just specify the try statement. The program will then try to execute the code that comes afterwards and simply continue if an error occurs.

2 Modularizing your code

2.1 Standalone programs

So far we have only interacted with the console or a jupyter notebook. As programs get more complicated, it makes sense to store code in separate .py files. But how do we call our program then? From the terminal of course!

  1. Create a new folder my_python at a location of your desire.

  2. Open your favorite text editor.

  3. Type the following code: print('Welcome to the world of standalone programs!')

  4. Save your code as the file welcome.py in your my_python folder.

  5. Use your terminal to navigate to the my_python folder.

  6. Type the following into the terminal: python3 welcome.py.

If all goes well, the code in your file welcome.py should be executed and Python should print out the line Welcome to the world of standalone programs!.

2.1 Importing modules and functions

So far, so good. But the whole point of stand-alone programs is that we can modularize our code and store it in different files. How can I then access code that is saved in another file? This is done by importing modules. The term ‘module’ is just another name for a file with python code. Let’s say we want to calculate the square root of a number. We could use the sqrt function from the math module. To have access to the function we first have to import the module using the import statement.

import math
math.sqrt(9)
3.0

Note that in order to call the function we need to use math. as a prefix. This might seem annoying but really helps us to be very clear which functions we are using. Nevertheless, these prefixes are rather wordy. We can improve on it a little by giving the imported module a new name.

import math as m
m.sqrt(9)
3.0

Of course we not always have to import a complete module if we just want to use a small subset of the functions in it. We can do this with the from statement.

from math import log
log(1)
0.0

Note that in this case we do not need the prefixes. We can of course also give the imported functions a new name.

from math import log as my_log
my_log(1)
0.0

2.2 Writing your own modules

So we have imported functions from pre-installed modules. We can however also write our own modules. It is extremely easy. Just write the functions you want to have separated in a module in a .py script and save it in the same folder as your main script. You can then import all functions from from your module by typing import filename. As with the modules above, you have to drop the .py suffix. Analogously to the import of functions from pre-installed modules, you can also import specific functions from your own modules.

2.3 Packages

You can add one more level of hierarchy by organizing modules in a package. This is done by creating a folder with the package name in your working directory. In the next step, you store the modules as .py files in the newly created folder. Finally, to import modules from packages, you have to type the following code into your program: from package_name import module_name. As you can see, working with modules is easy.

2.4 Search path

import sys
for place in sys.path:
    print(place)
/Users/jlanger/anaconda/lib/python35.zip
/Users/jlanger/anaconda/lib/python3.5
/Users/jlanger/anaconda/lib/python3.5/plat-darwin
/Users/jlanger/anaconda/lib/python3.5/lib-dynload
/Users/jlanger/anaconda/lib/python3.5/site-packages
/Users/jlanger/anaconda/lib/python3.5/site-packages/Sphinx-1.3.1-py3.5.egg
/Users/jlanger/anaconda/lib/python3.5/site-packages/aeosa
/Users/jlanger/anaconda/lib/python3.5/site-packages/setuptools-20.7.0-py3.5.egg
/Users/jlanger/anaconda/lib/python3.5/site-packages/IPython/extensions
/Users/jlanger/.ipython

These are the places where Python looks for files. You don’t see it but there is an empty string in the first line. This denotes the current working directory.

Sources

The structure of exposition was taken from Bill Lubanovic (2015): Introducing Python: Modern Computing in Simple Packages. O’Reilly: Sebastopol, CA.