More on Functions

From the materials you have covered up to this point you should have mastered the basics of writing functions.

In particular, you know that functions can contain a chunk of code that can be written once, and used multiple times from other parts of the code.

You know that you can pass values into the function, and that it can return values to the “calling” code.

Now we will dig a bit deeper down into the specifics of functions in Python:

Variable scope

Defining a function:

def fun(x, y):
    z = x + y
    return z

x, y, z are local names.

x and y because they are function parameters

z because it was “bound” inside the function.

Local vs. Global

Names bound in Python have a scope

That scope determines where a name is visible, and what value it has in a given block.

In [14]: x = 32
In [15]: y = 33
In [16]: z = 34
In [17]: def fun(y, z):
   ....:     print(x, y, z)
   ....:
In [18]: fun(3, 4)
32 3 4

x is global, while y and z are local to the function.

But, did the value of y and z change in the global scope?

In [19]: y
Out[19]: 33

In [20]: z
Out[20]: 34

No – they did not. The “y” and “z” names inside the function are completely separate from the “y” and “z” outside the function.

The ones outside the function are “global” names.

When you use a name in Python, it first checks if it’s a local name. Then, if that name is not in the local scope, it will look in the global scope for it.

NOTE: “global” in Python means global to the module (generally a single file), not global to an entire program. Which is really good, as you have little way of knowing what names are being used in packages you are using, but are not writing yourself!

In general, you should use global names mostly for constants.

The Python convention is to designate global constants by typing the names we bind to them in ALL_CAPS:

INSTALLED_APPS = ['foo', 'bar', 'baz']
CONFIGURATION_KEY = 'some secret value'
...

This is just a convention, but it’s a good one to follow.

Global Gotcha

Take a look at this function definition:

In [21]: x = 3

In [22]: def f():
   ....:     y = x
   ....:     x = 5
   ....:     print(x)
   ....:     print(y)
   ....:

What is going to happen when we call f?

Try it and see:

In [34]: f()
---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
<ipython-input-34-0ec059b9bfe1> in <module>()
----> 1 f()

<ipython-input-33-4363b2b69f73> in f()
      1 def f():
----> 2     y = x
      3     x = 5
      4     print(x)
      5     print(y)

UnboundLocalError: local variable 'x' referenced before assignment

Because you are binding the symbol x locally, it becomes a local and masks the global value already bound. So in the line that caused the error:

y = x

Python knows that x is a local name, as it is assigned on the next line. But on this line, x has not yet been given a value – hence the error.

Globals are “read only”

While you have access to the global names in side a function, you can’t change what those names are bound to. Take a look at the previous examples – when we set a new value to a name (using the equal sign), that makes the name local – so it will not change what the global name refers to.

Parameters

So far we’ve seen simple parameter lists:

def fun(x, y, z):
    print(x, y, z)

These types of parameters are called positional

When you call a function, you must provide arguments for all positional parameters in the order they are listed.

Defaults for parameters:

You can provide default values for parameters in a function definition:

In [24]: def fun(x=1, y=2, z=3):
   ....:     print(x, y, z)
   ....:

When parameters are given with default values, they become optional.

In [25]: fun()
1 2 3

You can provide arguments to a function call for optional parameters positionally:

In [26]: fun(6)
6 2 3
In [27]: fun(6, 7)
6 7 3
In [28]: fun(6, 7, 8)
6 7 8

Or, you can use the parameter name as a keyword to indicate which you mean:

In [29]: fun(y=4, x=1)
1 4 3

This allows you to specify only those optional parameters that you need to, and keep using the defaults for the rest. This is a very powerful feature of Python – you’ll find it’s common to have a pretty long optional parameter list to functions. It allows a lot of flexibility (the hard stuff is possible), while in common use, it’s easy to use (the easy stuff is easy).

Once you’ve provided a keyword argument in this way, you can no longer provide any positional arguments:

In [30]: fun(x=5, 6)
  File "<ipython-input-30-4529e5befb95>", line 1
    fun(x=5, 6)
SyntaxError: non-keyword arg after keyword arg

Recursion

You’ve seen functions that call other functions.

If a function calls itself, we call that recursion.

Like with other functions, a call within a call establishes a call stack.

With recursion, if you are not careful, this stack can get very deep.

Python has a maximum limit to how much it can recurse. This is intended to save your machine from running out of RAM.

Recursion can be Useful

Recursion is especially useful for a particular set of problems.

For example, take the case of the factorial function.

In mathematics, the factorial of an integer is the result of multiplying that integer by every integer smaller than itself down to 1.

5! == 5 * 4 * 3 * 2 * 1

We can use a recursive function nicely to model this mathematical function:

def fact(n):
    """compute the factorial of the input value, n"""
    if n == 0:
        return 1
    else:
        return n * fact(n-1)

This is a typical structure for a recursive function:

  1. It starts with a check to see if the recursive process is “done” – can it simply return a simple value.

  2. If not, then it does a computation using the same function with another value.

It is critical that the first check is there, or the function will never terminate.

Further Reading

Here’s a nice blog post about writting better functions:

https://jeffknupp.com/blog/2018/10/11/write-better-python-functions/