Using functions to wrap code

Using functions to wrap code#

When developing software you will surely reach the point where you recognize that you need the same code to solve a problem over and over again. The most straightforward approach to organize this is to use functions for that. Functions offer a way to wrap a piece of code into a re-usable block which can be given a name. Furthermore it offers an interface for use. You can define parameters to be specified at call time to make the function work properly and functions can return results after doing all the necessary work. We’ve used that concept before throughout this book. Actually most Python core functionality is organized and offered that way.

Let’s have a look at how functions work with the minimum possible example of a function definition.

def my_function():
    pass

Functions are defined by the keyword def following by the name of the function. The round brackets offer space for defining parameters required for the function. In our simple case no parameters are necessary, hence the brackets are empty here. As usually a colon ends the function header to introduce an indented block acting as the function body. The only statement that should be executed when calling our example function is the pass statement, which essentially will do nothing.

Now that the function is properly defined, we can already use it.

my_function()

As expected, nothing happens. Under the hood a lot happened already, so we can use our well known standard tools to investigate what our function has to offer. Using help for example reveals some default documentation on the function and how to properly call it.

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

my_function()

Tip

Use the dir function to learn more on the default capabilities a function has to offer.

Functions become more usable however if they actually do something, take arguments to modify parameters of an algorithm implemented in the functions body and return results. Python offers very flexible ways to define the arguments for a function. Let’s investigate the possible options in a very abstract way first and later create an example where the use of all these options might actually make sense.

Let’s start with a simple function definition, which takes a necessary argument and simply returns it.

def my_function(a):
    return a

a is a necessary, non-optional argument in this case. It has to be specified for the function to work, leaving it out will throw an error. So let’s call it properly.

my_function(1)
1

The number of arguments can be extended as necessary to make the algorithm work and more flexible. Let’s redefine the function to take 6 arguments and return the sum of them, when called using numbers as arguments.

def my_function(a, b, c, d, e, f):
    return a + b + c + d + e + f
my_function(1, 2, 3, 4, 5, 6)
21

Depending on the algorithm wrapped in the function, it might not be desirable to always provide all six parameters, as for example four of them rarely change. In this case it makes sense to redefine our function to use default values for some of the arguments making the use of them optional. The arguments that have default values however have to follow the non-optional arguments. They cannot be mixed.

def my_function(a, b, c=3, d=4, e=5, f=6):
    return a + b + c + d + e + f

By doing so our function can now be called with just two arguments to return a result.

my_function(10, 20)
48

Defining all possible arguments though is still possible.

my_function(10, 20, 30, 40, 50, 60)
210

Even defining any number of arguments between the minimal set of parameters and the maximum number works. The defaults for the arguments will be overwritten in the order they occur in this case.

my_function(10, 20, 30, 40)
111

If selected default values shall be replaced, this will work as well by directly naming the argument.

my_function(10, 20, f=60)
102

This gained quite some more flexibility already. Bit this is not the end.

There might as well be situations, where the real number of arguments is not known beforehand. Think of processing a list like set of arguments. Python offers a neat concept for this as well. When an argument in a function definition is preceeded with an asterisk *, it will take all remaining arguments in the function call, aggregate them and offer them as a tuple via the arguments name. It is common practice to call that variable args.

When using this pattern for our function definition, the resulting code to return the same result would look like this.

def my_function(a, b, *args):
    result = a + b
    for arg in args:
        result += arg

    return result
my_function(1, 2, 3, 4, 5, 6)
21

Not specifying the arguments considered optional before now returns a different result.

my_function(1, 2)
3

On the other hand, the number of arguments is now no longer limited.

my_function(1, 2, 3, 4, 5, 6, 7, 8, 9)
45

And a final type for arguments exists, where you can define optional arguments using arbitrarily chosen names. An argument preceeded by two asterisks ** will be treated as a dictionary within the function and takes all optional named arguments specified from that argument location on. The usually chosen name for this type of argument is kwargs, which stands for keyword arguments.

To use this feature our function definition might become

def my_function(a, b, *args, **kwargs):
    result = a + b
    for arg in args:
        result += arg
    for value in kwargs.values():
        result += value

    return result

The three function calls before still work as expected

my_function(1, 2, 3, 4, 5, 6)
21
my_function(1, 2)
3
my_function(1, 2, 3, 4, 5, 6, 7, 8, 9)
45

And this will be now possible as well.

my_function(1, 2, 3, 4, 5, 6, 7, 8, 9, ten=10, eleven=11, twelve=12)
78
my_function(1, 2, ten=10, eleven=11, twelve=12)
36

So to wrap up a complete but still abstract function definition using all possible options might look like the following. In this case it will just print out the arguments contents to show which variable holds the specified parameters within the function body.

Note that the *args and **kwargs concept can only be used once in the function definition, which makes sense if you think about it. Furthermore the argument handling concepts can only be used in that specific order.

def my_function(a, b, c=1, *args, **kwargs):
    print(f"{a=}")
    print(f"{b=}")
    print(f"{c=}")
    print(f"{args=}")
    print(f"{kwargs=}")
my_function(1, 2, 3, 4, 5, 6, 7, 8, 9, ten=10, eleven=11, twelve=12)
a=1
b=2
c=3
args=(4, 5, 6, 7, 8, 9)
kwargs={'ten': 10, 'eleven': 11, 'twelve': 12}

Tip

Play around with several argument assignments to practice the concept. This is considered to be a very important concept for defining interfaces for accessing functionality. Designing a clean and usable interface for an algorithm is a key feature for its successful use.

Scopes of variables#

Another important thing to note here is that functions create a local variable namespace distinct from the global one that is used for top level execution. That means, variables that have been defined prior to a function definition will be visible to the function and used in a read only fashion. Modifying these variables is not possible without further attention and should be avoided.

Let’s investigate this with another abstract example. Let’s say we would want to create a simple function working on a number, say increment it. We’ll use a function definition and code like the following, showing a pre-initialized variable before the function, a variable of the same name within the function that gets modified and the state of the variable after the function call.

def increment_a():
    a = 10
    print(f"Variable within function before increment: {a=}")
    a = a + 1
    print(f"Variable within function after increment: {a=}")


a = 1
print(f"Variable before function call: {a=}")
increment_a()
print(f"Variable after function call: {a=}")
Variable before function call: a=1
Variable within function before increment: a=10
Variable within function after increment: a=11
Variable after function call: a=1

We can see that the variable a, despite the fact that it has been seemingly modified twice in the function by assigning it a different value and incrementing it by 1, has the original value after the function call again. What happened was that the variable has effectively been defined in two distinct scopes. One variable with the name a has been defined into the local namespace of the function. This variable has been assigned the value 10 and incremented by one afterwards. That very variable creation only exists while the function is executed.

In the main program a variable a has been created as well. This variable lives in the global scope of the program. As the function defines its own variable a, which is completely distinct of the globals one, it is not modified by the function call.

Technically there are ways to make this work as possibly expected with the use of the global keyword in the function definition. However, as it is considered to be bad practice to write code like this, I won’t show how this actually works.

The better implementation for this is to take a as a right hand side argument to the function call and provide the incremented value as a left hand side argument by returning it. Effectively our code for this becomes

def increment(value):
    print(f"Variable within function before increment: {value=}")
    result = value + 1
    print(f"Variable within function after increment: {result=}")
    return result

a = 1
print(f"Variable before function call: {a=}")
a = increment(a)
print(f"Variable after function call: {a=}")
Variable before function call: a=1
Variable within function before increment: value=1
Variable within function after increment: result=2
Variable after function call: a=2

This is the expected result, with a clean implementation.

A reality example#

To show the usage of various argument types using less abstract more real world example, let’s have a look at our trivial sorting algorithm. Let’s design the interface in a way that it takes an iterable of items to sort, which should be numbers in this case. Furthermore we think, that this algorithm will be used most often to sort the numbers in ascending order, but descending order should be supported as well. We will handle this by introducing a cmp argument, which stands for the comparison strategy in this case. A “lt” will use a lower than test to compare two values, a “gt” for example indicates you would like to receive the items in descending order. For some very rare case there should be an option to return every nth element only from the sorted result. This we handle by checking the keys and values of the optional **kwargs dict.

def sort(items, cmp="lt", **kwargs):
    result = items[:]
    for _ in range(len(result)-1):
        for index in range(len(result)-1):
            left, right = result[index], result[index+1]
            if cmp == "lt":
                if right < left:
                    result[index] = right
                    result[index+1] = left
            else:
                if right > left:
                    result[index] = right
                    result[index+1] = left
                    
    if "every" in kwargs.keys():
        return result[::kwargs["every"]]
    return result

Let’s now test several possible usage options. First of all call it the default way.

items = [2, 1, 5, 4, 6, 7, 8, 2]
sort(items)
[1, 2, 2, 4, 5, 6, 7, 8]

Next change the default argument to change the sort order.

sort(items, cmp="gt")
[8, 7, 6, 5, 4, 2, 2, 1]

And lastly add an optional keyword argument to only return every nth element

sort(items, every=2)
[1, 2, 5, 7]