Functions

Introduction

A function is a sequence of statements which performs some kind of task. We use functions to eliminate code duplication – instead of writing all the statements at every place in our code where we want to perform the same task, we define them in one place and refer to them by the function name. If we want to change how that task is performed, we will now mostly only need to change code in one place.

Here is a definition of a simple function which takes no parameters and doesn’t return any values:

def print_a_message():
    print("Hello, world!")

We use the def statement to indicate the start of a function definition. The next part of the definition is the function name, in this case print_a_message, followed by round brackets (the definitions of any parameters that the function takes will go in between them) and a colon. Thereafter, everything that is indented by one level is the body of the function.

Functions do things, so you should always choose a function name which explains as simply as accurately as possible what the function does. This will usually be a verb or some phrase containing a verb. If you change a function so much that the name no longer accurately reflects what it does, you should consider updating the name – although this may sometimes be inconvenient.

This particular function always does exactly the same thing: it prints the message "Hello, world!".

Defining a function does not make it run – when the flow of control reaches the function definition and executes it, Python just learns about the function and what it will do when we run it. To run a function, we have to call it. To call the function we use its name followed by round brackets (with any parameters that the function takes in between them):

print_a_message()

Of course we have already used many of Python’s built-in functions, such as print and len:

print("Hello")
len([1, 2, 3])

Many objects in Python are callable, which means that you can call them like functions – a callable object has a special method defined which is executed when the object is called. For example, types such as str, int or list can be used as functions, to create new objects of that type (sometimes by converting an existing object):

num_str = str(3)
num = int("3")

people = list() # make a new (empty) list
people = list((1, 2, 3)) # convert a tuple to a new list

In general, classes (of which types are a subset) are callable – when we call a class we call its constructor method, which is used to create a new object of that class. We will learn more about classes in the next chapter, but you may recall that we already called some classes to make new objects when we raised exceptions:

raise ValueError("There's something wrong with your number!")

Because functions are objects in Python, we can treat them just like any other object – we can assign a function as the value of a variable. To refer to a function without calling it, we just use the function name without round brackets:

my_function = print_a_message

# later we can call the function using the variable name
my_function()

Because defining a function does not cause it to execute, we can use an identifier inside a function even if it hasn’t been defined yet – as long as it becomes defined by the time we run the function. For example, if we define several functions which all call each other, the order in which we define them doesn’t matter as long as they are all defined before we start using them:

def my_function():
    my_other_function()

def my_other_function():
    print("Hello!")

# this is fine, because my_other_function is now defined
my_function()

If we were to move that function call up, we would get an error:

def my_function():
    my_other_function()

# this is not fine, because my_other_function is not defined yet!
my_function()

def my_other_function():
    print("Hello!")

Because of this, it’s a good idea to put all function definitions near the top of your program, so that they are executed before any of your other statements.

Exercise 1

  1. Create a function called func_a, which prints a message.

  2. Call the function.

  3. Assign the function object as a value to the variable b, without calling the function.

  4. Now call the function using the variable b.

Input parameters

It is very seldom the case that the task that we want to perform with a function is always exactly the same. There are usually minor differences to what we need to do under different circumstances. We don’t want to write a slightly different function for each of these slightly different cases – that would defeat the object of the exercise! Instead, we want to pass information into the function and use it inside the function to tailor the function’s behaviour to our exact needs. We express this information as a series of input parameters.

For example, we can make the function we defined above more useful if we make the message customisable:

def print_a_message(message):
    print(message)

More usefully, we can pass in two numbers and add them together:

def print_sum(a, b):
    print(a + b)

a and b are parameters. When we call this function, we have to pass two paramenters in, or we will get an error:

print_sum() # this won't work

print_sum(2, 3) # this is correct

In the example above, we are passing 2 and 3 as parameters to the function when we call it. That means that when the function is executed, the variable a will be given the value 2 and the variable b will be given the value 3. You will then be able to refer to these values using the variable names a and b inside the function.

In languages which are statically typed, we have to declare the types of parameters when we define the function, and we can only use variables of those types when we call the function. If we want to perform a similar task with variables of different types, we must define a separate function which accepts those types.

In Python, parameters have no declared types. We can pass any kind of variable to the print_message function above, not just a string. We can use the print_sum function to add any two things which can be added: two integers, two floats, an integer and a float, or even two strings. We can also pass in an integer and a string, but although these are permitted as parameters, they cannot be added together, so we will get an error when we actually try to add them inside the function.

The advantage of this is that we don’t have to write a lot of different print_sum functions, one for each different pair of types, when they would all be identical otherwise. The disadvantage is that since Python doesn’t check parameter types against the function definition when a function is called, we may not immediately notice if the wrong type of parameter is passed in – if, for example, another person interacting with code that we have written uses parameter types that we did not anticipate, or if we accidentally get the parameters out of order.

This is why it is important for us to test our code thoroughly – something we will look at in a later chapter. If we intend to write code which is robust, especially if it is also going to be used by other people, it is also often a good idea to check function parameters early in the function and give the user feedback (by raising exceptions) if the are incorrect.

Exercise 2

  1. Create a function called hypotenuse, which takes two numbers as parameters and prints the square root of the sum of their squares.

  2. Call this function with two floats.

  3. Call this function with two integers.

  4. Call this function with one integer and one float.

Return values

The function examples we have seen above don’t return any values – they just result in a message being printed. We often want to use a function to calculate some kind of value and then return it to us, so that we can store it in a variable and use it later. Output which is returned from a function is called a return value. We can rewrite the print_sum function to return the result of its addition instead of printing it:

def add(a, b):
    return a + b

We use the return keyword to define a return value. To access this value when we call the function, we have to assign the result of the function to a variable:

c = add(a, b)

Here the return value of the function will be assigned to c when the function is executed.

A function can only have a single return value, but that value can be a list or tuple, so in practice you can return as many different values from a function as you like. It usually only makes sense to return multiple values if they are tied to each other in some way. If you place several values after the return statement, separated by commas, they will automatically be converted to a tuple. Conversely, you can assign a tuple to multiple variables separated by commas at the same time, so you can unpack a tuple returned by a function into multiple variables:

def divide(dividend, divisor):
    quotient = dividend // divisor
    remainder = dividend % divisor
    return quotient, remainder

# you can do this
q, r = divide(35, 4)

# but you can also do this
result = divide(67, 9)
q1 = result[0]
q2 = result[1]

# by the way, you can also do this
a, b = (1, 2)
# or this
c, d = [5, 6]

What happens if you try to assign one of our first examples, which don’t have a return value, to a variable?

mystery_output = print_message("Boo!")
print(mystery_output)

All functions do actually return something, even if we don’t define a return value – the default return value is None, which is what our mystery output is set to.

When a return statement is reached, the flow of control immediately exits the function – any further statements in the function body will be skipped. We can sometimes use this to our advantage to reduce the number of conditional statements we need to use inside a function:

def divide(dividend, divisor):
    if not divisor:
        return None, None # instead of dividing by zero

    quotient = dividend // divisor
    remainder = dividend % divisor
    return quotient, remainder

If the if clause is executed, the first return will cause the function to exit – so whatever comes after the if clause doesn’t need to be inside an else. The remaining statements can simply be in the main body of the function, since they can only be reached if the if clause is not executed.

This technique can be useful whenever we want to check parameters at the beginning of a function – it means that we don’t have to indent the main part of the function inside an else block. Sometimes it’s more appropriate to raise an exception instead of returning a value like None if there is something wrong with one of the parameters:

def divide(dividend, divisor):
    if not divisor:
        raise ValueError("The divisor cannot be zero!")

    quotient = dividend // divisor
    remainder = dividend % divisor
    return quotient, remainder

Having multiple exit points scattered throughout your function can make your code difficult to read – most people expect a single return right at the end of a function. You should use this technique sparingly.

Note

in some other languages, only functions that return a value are called functions (because of their similarity to mathematical functions). Functions which have no return value are known as procedures instead.

Exercise 3

  1. Rewrite the hypotenuse function from exercise 2 so that it returns a value instead of printing it. Add exception handling so that the function returns None if it is called with parameters of the wrong type.

  2. Call the function with two numbers, and print the result.

  3. Call the function with two strings, and print the result.

  4. Call the function with a number and a string, and print the result.

The stack

Python stores information about functions which have been called in a call stack. Whenever a function is called, a new stack frame is added to the stack – all of the function’s parameters are added to it, and as the body of the function is executed, local variables will be created there. When the function finishes executing, its stack frame is discarded, and the flow of control returns to wherever you were before you called the function, at the previous level of the stack.

If you recall the section about variable scope from the beginning of the course, this explains a little more about the way that variable names are resolved. When you use an identifier, Python will first look for it on the current level of the stack, and if it doesn’t find it it will check the previous level, and so on – until either the variable is found or it isn’t found anywhere and you get an error. This is why a local variable will always take precedence over a global variable with the same name.

Python also searches the stack whenever it handles an exception: first it checks if the exception can be handled in the current function, and if it cannot, it terminates the function and tries the next one down – until either the exception is handled on some level or the program itself has to terminate. The traceback you see when an exception is printed shows the path that Python took through the stack.

Recursion

We can make a function call itself. This is known as recursion. A common example is a function which calculates numbers in the Fibonacci sequence: the zeroth number is 0, the first number is 1, and each subsequent number is the sum of the previous two numbers:

def fibonacci(n):
    if n == 0:
        return 0

    if n == 1:
        return 1

    return fibonacci(n - 1) + fibonacci(n - 2)

Whenever we write a recursive function, we need to include some kind of condition which will allow it to stop recursing – an end case in which the function doesn’t call itself. In this example, that happens at the beginning of the sequence: the first two numbers are not calculated from any previous numbers – they are constants.

What would happen if we omitted that condition from our function? When we got to n = 2, we would keep calling the function, trying to calculate fibonacci(0), fibonacci(-1), and so on. In theory, the function would end up recursing forever and never terminate, but in practice the program will crash with a RuntimeError and a message that we have exceeded the maximum recursion depth. This is because Python’s stack has a finite size – if we keep placing instances of the function on the stack we will eventually fill it up and cause a stack overflow. Python protects itself from stack overflows by setting a limit on the number of times that a function is allowed to recurse.

Writing fail-safe recursive functions is difficult. What if we called the function above with a parameter of -1? We haven’t included any error checking which guards against this, so we would skip over the end cases and try to calculate fibonacci(-2), fibonacci(-3), and keep going.

Any recursive function can be re-written in an iterative way which avoids recursion. For example:

def fibonacci(n):
    current, next = 0, 1

    for i in range(n):
        current, next = next, current + next

    return current

This function uses iteration to count up to the desired value of n, updating variables to keep track of the calculation. All the iteration happens within a single instance of the function. Note that we assign new values to both variables at the same time, so that we can use both old values to calculate both new values on the right-hand side.

Exercise 4

  1. Write a recursive function which calculates the factorial of a given number. Use exception handling to raise an appropriate exception if the input parameter is not a positive integer, but allow the user to enter floats as long as they are whole numbers.

Default parameters

The combination of the function name and the number of parameters that it takes is called the function signature. In statically typed languages, there can be multiple functions with the same name in the same scope as long as they have different numbers or types of parameters (in these languages, parameter types and return types are also part of the signature).

In Python, there can only be one function with a particular name defined in the scope – if you define another function with the same name, you will overwrite the first function. You must call this function with the correct number of parameters, otherwise you will get an error.

Sometimes there is a good reason to want to have two versions of the same function with different sets of parameters. You can achieve something similar to this by making some parameters optional. To make a parameter optional, we need to supply a default value for it. Optional parameters must come after all the required parameters in the function definition:

def make_greeting(title, name, surname, formal=True):
    if formal:
        return "Hello, %s %s!" % (title, surname)

    return "Hello, %s!" % name

print(make_greeting("Mr", "John", "Smith"))
print(make_greeting("Mr", "John", "Smith", False))

When we call the function, we can leave the optional parameter out – if we do, the default value will be used. If we include the parameter, our value will override the default value.

We can define multiple optional parameters:

def make_greeting(title, name, surname, formal=True, time=None):
    if formal:
        fullname =  "%s %s" % (title, surname)
    else:
        fullname = name

    if time is None:
        greeting = "Hello"
    else:
        greeting = "Good %s" % time

    return "%s, %s!" % (greeting, fullname)

print(make_greeting("Mr", "John", "Smith"))
print(make_greeting("Mr", "John", "Smith", False))
print(make_greeting("Mr", "John", "Smith", False, "evening"))

What if we want to pass in the second optional parameter, but not the first? So far we have been passing positional parameters to all these functions – a tuple of values which are matched up with parameters in the function signature based on their positions. We can also, however, pass these values in as keyword parameters – we can explicitly specify the parameter names along with the values:

print(make_greeting(title="Mr", name="John", surname="Smith"))
print(make_greeting(title="Mr", name="John", surname="Smith", formal=False, time="evening"))

We can mix positional and keyword parameters, but the keyword parameters must come after any positional parameters:

# this is OK
print(make_greeting("Mr", "John", surname="Smith"))
# this will give you an error
print(make_greeting(title="Mr", "John", "Smith"))

We can specify keyword parameters in any order – they don’t have to match the order in the function definition:

print(make_greeting(surname="Smith", name="John", title="Mr"))

Now we can easily pass in the second optional parameter and not the first:

print(make_greeting("Mr", "John", "Smith", time="evening"))

Mutable types and default parameters

We should be careful when using mutable types as default parameter values in function definitions if we intend to modify them in-place:

def add_pet_to_list(pet, pets=[]):
    pets.append(pet)
    return pets

list_with_cat = add_pet_to_list("cat")
list_with_dog = add_pet_to_list("dog")

print(list_with_cat)
print(list_with_dog) # oops

Remember that although we can execute a function body many times, a function definition is executed only once – that means that the empty list which is created in this function definition will be the same list for all instances of the function. What we really want to do in this case is to create an empty list inside the function body:

def add_pet_to_list(pet, pets=None):
    if pets is None:
        pets = []
    pets.append(pet)
    return pets

Exercise 4

  1. Write a function called calculator. It should take the following parameters: two numbers, an arithmetic operation (which can be addition, subtraction, multiplication or division and is addition by default), and an output format (which can be integer or floating point, and is floating point by default). Division should be floating-point division.

    The function should perform the requested operation on the two input numbers, and return a result in the requested format (if the format is integer, the result should be rounded and not just truncated). Raise exceptions as appropriate if any of the parameters passed to the function are invalid.

  2. Call the function with the following sets of parameters, and check that the answer is what you expect:

    1. 2, 3.0

    2. 2, 3.0, output format is integer

    3. 2, 3.0, operation is division

    4. 2, 3.0, operation is division, output format is integer

*args and **kwargs

Sometimes we may want to pass a variable-length list of positional or keyword parameters into a function. We can put * before a parameter name to indicate that it is a variable-length tuple of positional parameters, and we can use ** to indicate that a parameter is a variable-length dictionary of keyword parameters. By convention, the parameter name we use for the tuple is args and the name we use for the dictionary is kwargs:

def print_args(*args):
    for arg in args:
        print(arg)

def print_kwargs(**kwargs):
    for k, v in kwargs.items():
        print("%s: %s" % (k, v))

Inside the function, we can access args as a normal tuple, but the * means that args isn’t passed into the function as a single parameter which is a tuple: instead, it is passed in as a series of individual parameters. Similarly, ** means that kwargs is passed in as a series of individual keyword parameters, rather than a single parameter which is a dictionary:

print_args("one", "two", "three")
print_args("one", "two", "three", "four")

print_kwargs(name="Jane", surname="Doe")
print_kwargs(age=10)

We can use * or ** when we are calling a function to unpack a sequence or a dictionary into a series of individual parameters:

my_list = ["one", "two", "three"]
print_args(*my_list)

my_dict = {"name": "Jane", "surname": "Doe"}
print_kwargs(**my_dict)

This makes it easier to build lists of parameters programmatically. Note that we can use this for any function, not just one which uses *args or **kwargs:

my_dict = {
    "title": "Mr",
    "name": "John",
    "surname": "Smith",
    "formal": False,
    "time": "evening",
}

print(make_greeting(**my_dict))

We can mix ordinary parameters, *args and **kwargs in the same function definition. *args and **kwargs must come after all the other parameters, and **kwargs must come after *args. You cannot have more than one variable-length list parameter or more than one variable dict parameter (recall that you can call them whatever you like):

def print_everything(name, time="morning", *args, **kwargs):
    print("Good %s, %s." % (time, name))

    for arg in args:
        print(arg)

    for k, v in kwargs.items():
        print("%s: %s" % (k, v))

If we use a * expression when you call a function, it must come after all the positional parameters, and if we use a ** expression it must come right at the end:

def print_everything(*args, **kwargs):
    for arg in args:
        print(arg)

    for k, v in kwargs.items():
        print("%s: %s" % (k, v))

# we can write all the parameters individually
print_everything("cat", "dog", day="Tuesday")

t = ("cat", "dog")
d = {"day": "Tuesday"}

# we can unpack a tuple and a dictionary
print_everything(*t, **d)
# or just one of them
print_everything(*t, day="Tuesday")
print_everything("cat", "dog", **d)

# we can mix * and ** with explicit parameters
print_everything("Jane", *t, **d)
print_everything("Jane", *t, time="evening", **d)
print_everything(time="evening", *t, **d)

# none of these are allowed:
print_everything(*t, "Jane", **d)
print_everything(*t, **d, time="evening")

If a function takes only *args and **kwargs as its parameters, it can be called with any set of parameters. One or both of args and kwargs can be empty, so the function will accept any combination of positional and keyword parameters, including no parameters at all. This can be useful if we are writing a very generic function, like print_everything in the example above.

Exercise 5

  1. Rewrite the calculator function from exercise 4 so that it takes any number of number parameters as well as the same optional keyword parameters. The function should apply the operation to the first two numbers, and then apply it again to the result and the next number, and so on. For example, if the numbers are 6, 4, 9 and 1 and the operation is subtraction the function should return 6 - 4 - 9 - 1. If only one number is entered, it should be returned unmodified. If no numbers are entered, raise an exception.

Decorators

Sometimes we may need to modify several functions in the same way – for example, we may want to perform a particular action before and after executing each of the functions, or pass in an extra parameter, or convert the output to another format.

We may also have good reasons not to write the modification into all the functions – maybe it would make the function definitions very verbose and unwieldy, and maybe we would like the option to apply the modification quickly and easily to any function (and remove it just as easily).

To solve this problem, we can write a function which modifies functions. We call a function like this a decorator. Our function will take a function object as a parameter, and will return a new function object – we can then assign the new function value to the old function’s name to replace the old function with the new function. For example, here is a decorator which logs the function name and its arguments to a log file whenever the function is used:

# we define a decorator
def log(original_function):
    def new_function(*args, **kwargs):
        with open("log.txt", "w") as logfile:
            logfile.write("Function '%s' called with positional arguments %s and keyword arguments %s.\n" % (original_function.__name__, args, kwargs))

        return original_function(*args, **kwargs)

    return new_function

# here is a function to decorate
def my_function(message):
    print(message)

# and here is how we decorate it
my_function = log(my_function)

Inside our decorator (the outer function) we define a replacement function and return it. The replacement function (the inner function) writes a log message and then simply calls the original function and returns its value.

Note that the decorator function is only called once, when we replace the original function with the decorated function, but that the inner function will be called every time we use my_function. The inner function can access both variables in its own scope (like args and kwargs) and variables in the decorator’s scope (like original_function).

Because the inner function takes *args and **kwargs as its parameters, we can use this decorator to decorate any function, no matter what its parameter list is. The inner function accepts any parameters, and simply passes them to the original function. We will still get an error inside the original function if we pass in the wrong parameters.

There is a shorthand syntax for applying decorators to functions: we can use the @ symbol together with the decorator name before the definition of each function that we want to decorate:

@log
def my_function(message):
    print(message)

@log before the function definition means exactly the same thing as my_function = log(my_function) after the function definition.

We can pass additional parameters to our decorator. For example, we may want to specify a custom log file to use in our logging decorator:

def log(original_function, logfilename="log.txt"):
    def new_function(*args, **kwargs):
        with open(logfilename, "w") as logfile:
            logfile.write("Function '%s' called with positional arguments %s and keyword arguments %s.\n" % (original_function.__name__, args, kwargs))

        return original_function(*args, **kwargs)

    return new_function

@log("someotherfilename.txt")
def my_function(message):
    print(message)

Python has several built-in decorators which are commonly used to decorate class methods. We will learn about them in the next chapter.

Note

A decorator doesn’t have to be a function – it can be any callable object. Some people prefer to write decorators as classes.

Exercise 6

  1. Rewrite the log decorator example so that the decorator logs both the function name and parameters and the returned result.

  2. Test the decorator by applying it to a function which takes two arguments and returns their sum. Print the result of the function, and what was logged to the file.

Lambdas

We have already seen that when we want to use a number or a string in our program we can either write it as a literal in the place where we want to use it or use a variable that we have already defined in our code. For example, print("Hello!") prints the literal string "Hello!", which we haven’t stored in a variable anywhere, but print(message) prints whatever string is stored in the variable message.

We have also seen that we can store a function in a variable, just like any other object, by referring to it by its name (but not calling it). Is there such a thing as a function literal? Can we define a function on the fly when we want to pass it as a parameter or assign it to a variable, just like we did with the string "Hello!"?

The answer is yes, but only for very simple functions. We can use the lambda keyword to define anonymous, one-line functions inline in our code:

a = lambda: 3

# is the same as

def a():
    return 3

Lambdas can take parameters – they are written between the lambda keyword and the colon, without brackets. A lambda function may only contain a single expression, and the result of evaluating this expression is implicitly returned from the function (we don’t use the return keyword):

b = lambda x, y: x + y

# is the same as

def b(x, y):
    return x + y

Lambdas should only be used for very simple functions. If your lambda starts looking too complicated to be readable, you should rather write it out in full as a normal, named function.

Exercise 7

  1. Define the following functions as lambdas, and assign them to variables:

    1. Take one parameter; return its square

    2. Take two parameters; return the square root of the sums of their squares

    3. Take any number of parameters; return their average

    4. Take a string parameter; return a string which contains the unique letters in the input string (in any order)

  2. Rewrite all these functions as named functions.

Generator functions and yield

We have already encountered generators – sequences in which new elements are generated as they are needed, instead of all being generated up-front. We can create our own generators by writing functions which make use of the yield statement.

Consider this simple function which returns a range of numbers as a list:

def my_list(n):
    i = 0
    l = []

    while i < n:
        l.append(i)
        i += 1

    return l

This function builds the full list of numbers and returns it. We can change this function into a generator function while preserving a very similar syntax, like this:

def my_gen(n):
    i = 0

    while i < n:
        yield i
        i += 1

The first important thing to know about the yield statement is that if we use it in a function, that function will return a generator. We can test this by using the type function on the return value of my_gen. We can also try using it in a for loop, like we would use any other generator, to see what sequence the generator represents:

g = my_gen(3)

print(type(g))

for x in g:
    print(x)

What does the yield statement do? Whenever a new value is requested from the generator, for example by our for loop in the example above, the generator begins to execute the function until it reaches the yield statement. The yield statement causes the generator to return a single value.

After the yield statement is executed, execution of the function does not end – when the next value is requested from the generator, it will go back to the beginning of the function and execute it again.

If the generator executes the entire function without encountering a yield statement, it will raise a StopIteration exception to indicate that there are no more values. A for loop automatically handles this exception for us. In our my_gen function this will happen when i becomes equal to n – when this happens, the yield statement inside the while loop will no longer be executed.

Exercise 8

  1. Write a generator function which takes an integer n as a parameter. The function should return a generator which counts down from n to 0. Test your function using a for loop.

Answers to exercises

Answer to exercise 1

Here is an example program:

def func_a():
    print("This is my awesome function.")

func_a()

b = func_a

b()

Answer to exercise 2

Here is an example program:

import math

def hypotenuse(x, y):
    print(math.sqrt(x**2 + y**2))

hypotenuse(12.3, 45.6)
hypotenuse(12, 34)
hypotenuse(12, 34.5)

Answer to exercise 3

Here is an example program:

import math

def hypotenuse(x, y):
    try:
        return math.sqrt(x**2 + y**2)
    except TypeError:
        return None

print(hypotenuse(12, 34))
print(hypotenuse("12", "34"))
print(hypotenuse(12, "34"))

Answer to exercise 3

  1. Here is an example program:

    def factorial(n):
        ni = int(n)
    
        if ni != n or ni <= 0:
            raise ValueError("%s is not a positive integer." % n)
    
        if ni == 1:
            return 1
    
        return ni * factorial(ni - 1)
    

Answer to exercise 4

  1. Here is an example program:

    import math
    
    ADD, SUB, MUL, DIV = range(4)
    
    def calculator(a, b, operation=ADD, output_format=float):
        if operation == ADD:
            result = a + b
        elif operation == SUB:
            result = a - b
        elif operation == MUL:
            result = a * b
        elif operation == DIV:
            result = a / b
        else:
            raise ValueError("Operation must be ADD, SUB, MUL or DIV.")
    
        if output_format == float:
            result = float(result)
        elif output_format == int:
            result = math.round(result)
        else:
            raise ValueError("Format must be float or int.")
    
        return result
    
  2. You should get the following results:

    1. 5.0

    2. 5

    3. 0.6666666666666666

    4. 1

Answer to exercise 5

  1. Here is an example program:

    import math
    
    ADD, SUB, MUL, DIV = range(4)
    
    def calculator(operation=ADD, output_format=float, *args):
        if not args:
            raise ValueError("At least one number must be entered.")
    
        result = args[0]
    
        for n in args[1:]:
            if operation == ADD:
                result += n
            elif operation == SUB:
                result -= n
            elif operation == MUL:
                result *= n
            elif operation == DIV:
                result /= n
            else:
                raise ValueError("Operation must be ADD, SUB, MUL or DIV.")
    
        if output_format == float:
            result = float(result)
        elif output_format == int:
            result = math.round(result)
        else:
            raise ValueError("Format must be float or int.")
    
        return result
    

Answer to exercise 6

  1. Here is an example program:

    def log(original_function, logfilename="log.txt"):
        def new_function(*args, **kwargs):
            result = original_function(*args, **kwargs)
    
            with open(logfilename, "w") as logfile:
                logfile.write("Function '%s' called with positional arguments %s and keyword arguments %s. The result was %s.\n" % (original_function.__name__, args, kwargs, result))
    
            return result
    
        return new_function
    
  2. Here is an example program:

    @log
    def add(x, y):
        return x + y
    
    print(add(3.5, 7))
    
    with open("log.txt", "r") as logfile:
        print(logfile.read())
    

Answer to exercise 7

  1. Here is an example program:

    import math
    
    a = lambda x: x**2
    b = lambda x, y: math.sqrt(x**2 + y**2)
    c = lambda *args: sum(args)/len(args)
    d = lambda s: "".join(set(s))
    
  2. Here is an example program:

    import math
    
    def a(x):
        return x**2
    
    def b(x, y):
        return math.sqrt(x**2 + y**2)
    
    def c(*args):
        return sum(args)/len(args)
    
    def d(s):
        return "".join(set(s))
    

Answer to exercise 8

  1. Here is an example program:

    def my_gen(n):
        i = n
    
        while i >= 0:
            yield i
            i -= 1
    
    for x in my_gen(3):
        print(x)