5. Functions#

This chapter is about functions; functions you write yourself. You create therm to perform a task that needs to execute multiple times in your program (maybe from different contexts) or that maybe even needs to be called from an entire different source file.

We’ll be looking at how to design, implement and call (use) functions. The next chapter deals with an overview of some of the most-used built-in functions of the Python language.

5.1. What are functions#

A function is a reusable piece of functionality which performs (ideally) a single and well-defined task and handles error-conditions in a graceful manner.
Many functions are already provided by Python itself or one of its many external libraries.

Functions themselves usually call one or more functions within the function body.

Here is a simple example:

def say_hello():
    name = input("what shall I call you? ")
    print("Goodday to you,", name)

say_hello()

This function asks for the users’ name and serves it back with a greeting. It calls two functions itself to perform its task: input() which will read input entered using a keyboard on the command line and print() which outputs text to that same command-line (also called terminal or console).

Actually, this function does not adhere to the definition stated above “… a single and well-defined task and handles error-conditions in a graceful manner.” Can you spot the flaw(s) in the function defined above?


➥ Just tell me!
  • There is no check for the user input. What if the user enters an empty line?
  • 5.2. Creating and using functions#

    Function rules

    Functions must

    • start with the keyword def

    • have a legal name (in short: no reserved words or funny characters, and don’t start with a number).

    • have an argument list, but it may be empty: ()

    • have a function body starting after a colon, indented one level with respect to the function definition line, and with at the least the keyword pass in it.

    The Naming convention for functions states that they should be lowercase (verbs), with words separated by underscores to improve readability.”

    Here is a minimal function that is perfectly legal and perfectly useless:

    def do_nothing():
        pass
    

    The indentation level is usually 4 spaces (but this is not required), but must be the same within a single code block. You are strongly encouraged to be consistent within all your source files.

    Important

    In Python, whitespaces (space, tab and newline characters) are an essential part of the syntax!

    Note

    Actually, empty functions are a much-used construct in test-driven development (TDD), a programming paradigm in which a placeholder function is created first, together with an extensive test suite (a set of testing functions assessing the correctness and robustness of a function). Only then the function is iteratively implemented and marked finished when all tests pass. See Wikipedia

    Here are some variations that will NOT run because of a syntax error.

    This one gives IndentationError: expected an indented block because there is no indentation in the function body:

    def do_nothing():
    pass
    

    This one gives an IndentationError: unindent does not match any outer indentation level because the indentation is not the same within the function body:

    def do_nothing():
        print("one")
      print("two")
    

    This one gives a SyntaxError: invalid syntax because there is no colon to start the function body:

    def do_nothing()
        pass
    

    Often, Python will be quite specific in where things go wrong, as in the last example:

      Input In [15]
        def do_nothing()
                        ^
    SyntaxError: invalid syntax
    

    5.2.1. A more realistic example#

    Now for a slightly more realistic example: calculating distance from speed and time. Note that in the function below the parameters have been given names to communicate the units in which they should be passed. Yes this is important! A Mars orbiter crashed in 1999 because of a coding mistake involving different use of measurement units, costing $193 million (see The seven most expensive Bugs in code history). We’ll see in a later section in this chapter how to document this as well in a more human-readable manner using docstrings.

    def distance_meters(speed_km_h, time_sec):
        '''Calculates the distance in meters given a speed in km/h and
        elapsed time in seconds'''
        return (1000 / 3600) * speed_km_h * time_sec
    
    print(distance_meters(36, 5))
    print(distance_meters(100, (60*60)))                  # one hour
    print(distance_meters(time_sec = 5, speed_km_h = 36)) # using named arguments
    
    50.0
    100000.0
    50.0
    

    As you can see in the above example, calling functions is quite flexible. By using grouping parentheses you can do some in-place calculations within the argument list parentheses (yes they look exactly the same but have very different functions within the code. The only difference is their context.

    In the last example above you can see the use of named arguments. When using the parameter names to pass the arguments, the order in which they are passed does not need to match the order of the parameters in the function definition.

    Parameters versus Arguments

    A parameter is a variable in a function definition. It is a placeholder and hence does not have a concrete value. An argument is a value passed during function invocation.
    In a way, arguments fill in the place the parameters have held for them.

    5.2.2. You’ve got to adhere to the contract#

    When a function defines two parameters and no default values, you must provide exactly two values when calling the function. These three invocations fail for that reason.

    distance_meters()
    distance_meters(1)
    distance_meters(1, 2, 3)
    

    You will get an error message like this: TypeError: distance_meters() takes 2 positional arguments but 3 were given .

    Of course, if you provide values of a type other than expected you will get an error (if you’re lucky), or return values that are very strange and that are hard to debug if you are not so lucky.

    5.2.3. Default values give more flexibility#

    If you can think of a sensible default value for your parameters you can provide these. That way you can make the invocation and execution of your functions much more flexible.

    There is an important aspect to think about: only provide default values if they make sense, and of course you can provide only one default value so choose the most appropriate ones if you have multiple candidates.

    Here is the distance calculating function again, in a slightly modified version. The time_sec parameter now has a default value. This means that if this argument is omitted, you do not get an error but instead the function will execute with the default. If you do provide a value for it, this will override the default value.

    def distance_meters2(speed_km_h, time_sec = 1):
        return (1000 / 3600) * speed_km_h * time_sec
    
    print(distance_meters2(36))
    print(distance_meters2(36, 5))
    print(distance_meters2(speed_km_h = 36))
    print(distance_meters2(speed_km_h = 36, time_sec = 5))
    #print(distance_meters2(time_sec = 5)) #TypeError!
    
    10.0
    50.0
    10.0
    50.0
    

    5.2.4. Functions can only return one thing#

    Functions can only return a single value. If you want to return more than one value, you’ve got to wrap them inside a collection type or class instance:

    • list

    • tuple

    • dict

    • class instance (an object)

    • a generator function (a special kind of function)

    5.2.5. Communicate intent and details via docstring#

    So far you have seen only simple comments using a single hash #. These comments generally serve to communicate something about the code to yourself or maybe to your colleague programmer.
    However, if you’re successfull there will also be others using your code in the context of an imported library. If you wish to provide them with info on how your functions should be used, you should take some time to write a pydoc docstring. A triple-quoted string at the top of your function body will make it available though the __doc__ attribute and via the help() function.

    def distance_meters3(speed_km_h, time_sec = 1):
        '''
        Calculate the distance.
        
        The distance is calculated in meters, given a speed in km/h and elapsed time in seconds.
    
        Parameters
        ----------
        speed_km_h : float
            The speed in km per hour (km/h)
        time_sec : float
            Elapsed time, in seconds. Defaults to 1.
    
        Returns
        -------
        float
            Covered distance, in meters 
        '''
        return (1000 / 3600) * speed_km_h * time_sec
    
    #print(distance_meters3.__doc__) #gives the same info, but without context.
    help(distance_meters3)
    
    Help on function distance_meters3 in module __main__:
    
    distance_meters3(speed_km_h, time_sec=1)
        Calculate the distance.
        
        The distance is calculated in meters, given a speed in km/h and elapsed time in seconds.
        
        Parameters
        ----------
        speed_km_h : float
            The speed in km per hour (km/h)
        time_sec : float
            Elapsed time, in seconds. Defaults to 1.
        
        Returns
        -------
        float
            Covered distance, in meters
    

    5.2.6. Scope and the global keyword#

    So far I have avoided the concept of scope. Scope has to do with “visibility” of objects from within your code as well as “write access”. In the snippet below, the function reads a variable from global scope but does not attempt to change it.

    a = 12
    
    def do_it(b):
        c = b
        print(f'a has value {a}')
        print(f'b has value {b}')
        print(f'c has value {c}')
    
    do_it(5)
    print(f'a has value {a}')
    
    a has value 12
    b has value 5
    c has value 5
    a has value 12
    

    No problem here. But now the function attempts to change the value of a. We get an UnboundLocalError: local variable 'a' referenced before assignment Here, the error message is not as clear as we would possibly like it because it seems as if the variable does not exist (yet).

    a = 12
    
    def do_it(b):
        c = b
        a += 1
        print(f'a has value {a}')
        print(f'b has value {b}')
        print(f'c has value {c}')
    
    do_it(5)
    print(f'a has value {a}')
    print(f'c has value {c}')
    
    ---------------------------------------------------------------------------
    UnboundLocalError                         Traceback (most recent call last)
    Cell In[6], line 10
          7     print(f'b has value {b}')
          8     print(f'c has value {c}')
    ---> 10 do_it(5)
         11 print(f'a has value {a}')
         12 print(f'c has value {c}')
    
    Cell In[6], line 5, in do_it(b)
          3 def do_it(b):
          4     c = b
    ----> 5     a += 1
          6     print(f'a has value {a}')
          7     print(f'b has value {b}')
    
    UnboundLocalError: local variable 'a' referenced before assignment
    

    To solve this, we need to specify within the function that we are going to access the global variable a, using the global keyword. This is solved below.

    a = 12
    
    def do_it(b):
        global a
        c = b
        a += 1
        print(f'a has value {a}')
        print(f'b has value {b}')
        print(f'c has value {c}')
    
    do_it(5)
    print(f'a has value {a}')
    
    a has value 13
    b has value 5
    c has value 5
    a has value 13
    

    That’s OK again. But what if we try to do the reverse - accessing a local variable from the global context? This fails again, unless we make variable c global as well.

    a = 12
    
    def do_it(b):
        global a
        global c
        c = b
        a += 1
        print(f'a has value {a}')
        print(f'b has value {b}')
        print(f'c has value {c}')
    
    do_it(5)
    print(f'a has value {a}')
    c += 1
    print(f'c has value {c}')
    
    a has value 13
    b has value 5
    c has value 5
    a has value 13
    c has value 6
    

    When creating your own scripts you should try to avoid creating (many) global variables and especially to define global variables from a local context, as was done with variable c in the above example.

    5.3. Advanced function stuff#

    5.3.1. Varargs#

    If you ever looked at the documentation of the print() function

    print(value, ..., sep=' ', end='\n', file=sys.stdout, flush=False)

    you may have noticed the ... part. It means that print accepts any number of things to print after the first mandatory value. This concept is called “varargs”. You can create functions yourself that behave like this. The only hting you need to do is use *args, as demonstrated below.

    def print_it_all(*args, add_time = True):
        from datetime import datetime
        
        for m in args:
            if add_time:
                now = datetime.now()
                print(f'At {now.strftime("%d/%m/%Y %H:%M:%S")} you messaged {m}')
            else:
                print(f'You messaged {m}')
    
    print_it_all("foo", "bar")
    print_it_all("bye", "auf wiedersehen", add_time=False)
    
    At 14/12/2023 16:09:30 you messaged foo
    At 14/12/2023 16:09:30 you messaged bar
    You messaged bye
    You messaged auf wiedersehen
    

    The only restriction with varargs is that they can only be followed by “keyword arguments”, i.e. arguments that need to be specified by name, as also shown in the example above.

    Note

    Working with dates and times is a really interesting and surprisingly complex topic (think Time Zones…). A very minimal tutorial for working with these can be founde on this website

    5.3.2. Closures#

    Functions are also objects and can be created within another function. You can pass (references to) functions around as you can with any object in Python. Moreover, functions can be defined within, and returned from, other functions. Below is an example where the get_messenger() function returns either a top-level function (say_hello_anonymous) or a closure, say_hello_with_name that even holds the value of the function variable name after it has been returned from the creating function. Note that you do not use parentheses when passing a function object! Take a minute to figure out what is happening here. Although it is a rather short piece of code it already shows some advanced Python stuff, as well as not-yet discussed flow control with if and else.

    
    def get_messenger():
        name = input('Give me your name please: ')
        
        # this closure 'encloses' the name variable that is local to this function and 
        # keeps a reference even though it is returned from within this function context
        def say_hello_with_name():
            print(f'Howdy, {name}!')
    
        #another enclosed function
        def say_hello_anonymous():
            print("Hello unknown guest")
        
        # a non-empty name will evaluate to True
        if name.strip():
            return say_hello_with_name
        else:
            return say_hello_anonymous
        
    
    def use_messenger(messenger):
        # the argument to this function is another function!
        messenger()
        
    use_messenger(get_messenger())
    
    

    5.3.3. Design and Testing#

    When you embark on serious programming you will be creating many functions along the way. Here are some “rules” that may help you create robust, maintainable and extensible programs.

    1. Break up the problem into manageable pieces. When designing an algorithm, first think about which individual steps it involves. Then implement each step as a separate function.

    2. Have functions do only one thing. If your function apparantly does more than one thing, split it up!

    3. Create small functions. If your functions spans more than 30 lines it is probable too big, split it up! If it is indented by more than 3 levels, split it up!

    4. Use names that mean something. Use short but descriptive names. We are way past the era where function names could not be longer than 8 characters.

    5. Verify function arguments. For instance, if you get a negative elapsed time, is it still a good idea to carry on? We’ll discuss error handling and Exceptions in a later chapter.

    6. Write test code. Something out of scope for this course, but very important nevertheless. Have a look at unittest.

    Below is a small example testing the distance calculation function (this test does not work in a notebook environment; run the script at scripts/unittest_distance_calc.py from the commandline to try out)

    import unittest
    
    def distance_meters4(speed_km_h, time_sec = 1):
        return (1000 / 3600) * speed_km_h * time_sec
    
    class TestDistanceCalculations(unittest.TestCase):
    
        def test_zero(self):
            self.assertAlmostEqual(distance_meters4(4, 0), 0, places = 6)
            self.assertAlmostEqual(distance_meters4(4000000, 0), 0, places = 6)
            self.assertAlmostEqual(distance_meters4(0, 10000), 0, places = 6)
        
        def test_normal(self):
            self.assertAlmostEqual(distance_meters4(36, 5), 50, places = 6)
            self.assertAlmostEqual(distance_meters4(36), 10, places = 6)
            
    if __name__ == '__main__':
        unittest.main()
    

    This outputs (on my machine at least):

    michiel$ python3 scripts/unittest_distance_calc.py 
    ..
    ----------------------------------------------------------------------
    Ran 2 tests in 0.000s
    
    OK
    

    5.3.4. Type hints#

    In larger Python programs you should make use of type hints. That is out of scope for this course but you can read about this topic on the Python website.

    5.4. Key concepts#

    Important

    • scope: The scope of a variable (or other program element such as class or function) is the visibility range for that element; which other program elements can access and read it?

    • function: A function is a reusable piece of functionality which performs a single and well-defined task and handles error-conditions in a graceful manner.

    • parameter: A function parameter defines an input variable.