Functions

The initial stages of learning an interpreted language like Python normally involve entering statements on a command line and immediately executing them to see the result. In IPython notebooks, one can enter several lines and execute them all at once. Similarly, the lowest level of programming involves writing a moderate number of lines of code in a file, which the Python interpreter then executes from a system command line. This is useful for simple tasks like reformatting text files, but it doesn't go far beyond that.

Fortunately, programming power can be increased exponentially by writing functions. In this notebook we will illustrate the basics of functions with very simple examples chosen for pedagogical value, not as examples of functions you would be likely to write and use. The emphasis here is on showing how one gets information into, and out of, a function, not on the sorts of code one is likely to find in real functions.

The simplest case: no arguments (i.e., no inputs), nothing returned

In [1]:
def simple_message():
    print("This is the simplest function that does something.")

Notice how we defined a function by starting with def, followed by the name of the function, followed by parentheses, followed by a colon, followed by an indented block of code. Every one of these elements is needed.

Notice also that this definition is just that: a definition. After it is executed there is a new Python object in our namespace, simple_message, but it has not been executed yet. Let's execute it:

In [2]:
simple_message()
This is the simplest function that does something.

No surprise. Just remember that the parentheses are needed when executing the function as well as when defining it.

One mandatory argument

In [3]:
def print_uppercase_version(some_text):
    print(some_text.upper())
    
print_uppercase_version("using upper case can seem like shouting")
USING UPPER CASE CAN SEEM LIKE SHOUTING

Here we defined a function that takes a single mandatory argument, a text string. It calls the upper method of that string, which returns an uppercase version of it, and then it prints the result. (Remember, a method is just a function that knows how to operate on the object to the left of the dot. You can see it is a function because of the parentheses.)

More mandatory (positional) arguments

A function can be defined with any number of positional arguments:

In [4]:
def print_more_uppercase(some_text, n):
    for i in range(n):
        print(some_text.upper())
        
print_more_uppercase("repeat", 2)        
REPEAT
REPEAT

Variable number of arguments

Now things get more interesting; we can define a function that takes some fixed number of positional arguments (one, in this example) and any number of additional positional arguments:

In [5]:
def show_variable_arguments(intro, *args):
    print(intro)
    #  The built-in enumerate() function is explained below.
    for i, a in enumerate(args):
        print("additional argument #%d is %s" % (i, a))
        
show_variable_arguments("Only one:", "just this")
Only one:
additional argument #0 is just this

In [6]:
show_variable_arguments("Three this time:", "first", "second", "third")
        
Three this time:
additional argument #0 is first
additional argument #1 is second
additional argument #2 is third

Notice the *args construct: it means, "take all additional arguments, pack them in a tuple, and make it available inside the function under the name 'args'". If there are no additional arguments the tuple will be empty. If present, the *args construct must follow any mandatory arguments.

We introduced the built-in function enumerate(); it is often used in loops like this, when one needs both an item and its index. It is an iterator. Each time through the loop, it returns a tuple containing the count, starting from zero, and the corresponding item in its argument. We are automatically unpacking the tuple into the variables i and a.

In [7]:
for i, a in enumerate(['dog', 'cat', 'bird']):
    print("index is", i, "and the element is", a)
index is 0 and the element is dog
index is 1 and the element is cat
index is 2 and the element is bird

Keyword arguments

Even with the ability to have a variable number of arguments via *args, positional arguments can get clumsy to handle and hard to remember as the potential inputs to a function get more complex. Here is an illustration of the solution:

In [8]:
def print_style(some_text, n=1, format='sentence'):
    if format == 'sentence':
        text = some_text.capitalize() + "."
    elif format == 'shout':
        text = some_text.upper()
    elif format == 'plain':
        text = some_text
    else:
        print("format keyword argument must be 'sentence, 'shout', or 'plain'")
        return
    for i in range(n):
        print(text)
        
print_style("this is a sentence", n=2)
This is a sentence.
This is a sentence.

In [9]:
print_style("a bit loud", format='shout')
A BIT LOUD

In [10]:
print_style("unchanged and only once", format='plain')
unchanged and only once

In [11]:
print_style("unchanged but 3 times", format='plain', n=3)
unchanged but 3 times
unchanged but 3 times
unchanged but 3 times

In [12]:
print_style("invalid keyword argument...", format='loopy')
format keyword argument must be 'sentence, 'shout', or 'plain'

There are several things to notice:

  • The second and third arguments in the definition are keyword arguments, in which the name is followed by an equals sign and a default value.
  • These are optional arguments, and when the function is called, these arguments do not have to be specified in any particular order, or at all.
  • The keyword arguments must follow all positional arguments, of which there is only one in this example.
  • In addition to making it possible to have optional arguments with default values, keyword arguments can make the code more readable when the function is called.

Notice the return statement, to end execution after printing the error message. Of course, return can do more, as we now show.

Returning output

We are going to use a tiny bit of numpy now, so that we can operate on a sequence of numbers all at once.

In [13]:
import numpy as np

def sinsq(x):
    return np.sin(x) ** 2

print(sinsq([-0.6, -0.3, 0.3, 0.6]))
[ 0.31882112  0.08733219  0.08733219  0.31882112]

Again, no surprise: the function returns what you tell it to return with the return statement. Multiple objects can be returned as a sequence, in the following case a list:

In [14]:
def sinpows(x, n):
    """
    Return the first n powers of sin(x), starting from zero.
    """
    out = []
    for i in range(n):
        out.append(np.sin(x) ** i)
    return out

zero, first, second = sinpows(0.3, 3)

print("zero: ", zero)
print("one:  ", first)
print("two:  ", second)
zero:  1.0
one:   0.295520206661
two:   0.0873321925452

We used automatic unpacking of the returned list to give the outputs individual names. Any Python object can be returned--even a new function object that is defined inside the function. That is an advanced technique, however, so we will not illustrate it here.

Notice also that we included a docstring, a block of text immediately below the definition line, and above the body of function code.

More keyword arguments

We saw how we could have a variable number of positional arguments---that is, the number is not known when the function is defined, but it can handle any number when it is called. There is a similar ability with keyword arguments:

In [15]:
def show_kwargs_with_names(**kw):
    print("kw is an object of type", type(kw))
    print("it contains:")
    for key, value in kw.items():
        print("  key: '%s' with value: '%s'" % (key, value))
        
show_kwargs_with_names(first="the first", 
                       second="and the second", 
                       another="yet another")
kw is an object of type <class 'dict'>
it contains:
  key: 'first' with value: 'the first'
  key: 'second' with value: 'and the second'
  key: 'another' with value: 'yet another'

So, just as *args packs up remaining positional arguments in a tuple, **kw packs remaining keyword arguments up in a dictionary named 'kw' and makes it available inside the function. Because it is a dictionary, the order in which the arguments appeared on the command line is lost; but that doesn't matter, because the entries are identified by name, the dictionary key.

There is nothing special about the names 'args' and 'kw'; *stuff would pack arguments in a tuple called 'stuff', and **argdict would make a dictionary named 'argdict'. But 'args' and 'kw' or 'kwargs' are used most often by convention, and observing such conventions tends to improve readability.

**kw can directly follow *args:

In [16]:
def no_explicit_kw(pos1, *args, **kw):
    print("args is:", args)
    print("kw is:", kw)
    
no_explicit_kw("dummy", "arg1", "arg2", kw1="first", kw2="second")
args is: ('arg1', 'arg2')
kw is: {'kw2': 'second', 'kw1': 'first'}

or **kw can follow explicitly named keyword arguments, provided there is no *args:

In [17]:
def no_star_args(pos1, kw1="the first", **kw):
    print("kw1 is:", kw1)
    print("kw is a dictionary:", kw)
    
no_star_args("arg0", kw2="the second", kw3="the third")  
kw1 is: the first
kw is a dictionary: {'kw2': 'the second', 'kw3': 'the third'}

Notice that only the keyword arguments that are not included in the definition by name get packed up in the kw dictionary.

More fun with asterisks

The single and double asterisk constructs not only can be used in function definitions, they can also be used when calling functions. A single asterisk unpacks a sequence into a set of positional arguments, and a double asterisk unpacks a dictionary into a sequence of keyword arguments. Example:

In [18]:
def with_two_arguments(arg1, arg2):
    print("arguments are %s and %s" % (arg1, arg2))
    
some_tuple = ("one", "two")
with_two_arguments(*some_tuple)
arguments are one and two

and

In [19]:
def with_kwargs(kw0="x", kw1="y"):
    print("kw0 is", kw0, "and kw1 is", kw1)
    
with_kwargs() # defaults
kwdict = {"kw0":"a", "kw1":"b"}
with_kwargs(**kwdict)
kw0 is x and kw1 is y
kw0 is a and kw1 is b

Caution: watch out for side effects

Arguments are passed into Python functions by reference, not by value, so if the function modifies something that is passed in, the modification will be seen outside the function. Some Python objects can be modified---that is, they are "mutable", to use the jargon---and some cannot---they are "immutable". So if you pass a list into a function, and append an element to that list, the list will have the new element after the function has been executed:

In [20]:
def add_tail(x):
    """
    Given a single list input, append the string "tail" to the list.

    This function returns nothing, but modifies its input argument.
    """
    x.append("tail")
    
y = [1, 2, 3]
add_tail(y)

print(y)
[1, 2, 3, 'tail']

This is a valid technique---using a function to modify its argument---so long as you are doing it deliberately, and not by accident.

In [20]: