4. Defining and Using Python Functions#

Estimated time to complete: one to two hours.

4.1. Introduction#

Our main objective is to learn how to define our own Python functions, and see how these can help us to deal with sub-tasks done repeatedly (with variation) within a larger task.

Typically, core mathematical tasks (like evaluating a function \(f(x)\) or solving an equation \(f(x) = 0\)) will be done within Python functions designed to communicate with other parts of the Python code, rather than getting their input interactively or returning results by displaying them on the screen.

In this course, the notebook approach is prioritized; that makes it easier to produce a single document that combines text and mathematical information, Python code, and computed results, making for a more “literate”, understandable presentation.

Example A. A very simple function for computing the mean of two numbers

To illustrate the syntax, let’s start with a very simple function: computing the mean of two numbers.

def mean(a, b):
    mean_of_a_and_b = (a + b)/2
    return mean_of_a_and_b

This can be used as follows:

mean_of_2_and_6 = mean(2, 6)
print('The mean of 2 and 6 is', mean_of_2_and_6)
The mean of 2 and 6 is 4.0

Notes#

  1. The definition of a function begins with the command def

  2. This is followed by:

    • a name for the function,

    • a parenthesized, comma-seperated list of variable names, which are called the input arguments,

    • and finally a colon, which as always introduces an indented list or block of statements; in this case the statements that describe the actions performed by the function.

    • Indentation is done with four spaces. (Tabs are also legal but advised against, so please forget that I mentioned them.)

    • The end of the block of code for the function is indicated simply by the end of the indentation; there is no end line as in some other programming languages. The last line of the definiton is often a return statement, but there can also be multiple return statements at various places in the code block, or no return at all.

  3. When using the function, it is given a list of expressions (variable names or values or more elaborate formulas) and the values of these are copied to the corresponding internal variables given by the list of input arguments mentioned in the function’s def line. (However, with lists and arrays, the idea of copying will require clarification!)

  4. As soon as the function gets to a statement beginning with return, it evaluates the expression on that line, and ends, sending back this as the value of the function.

  5. The name used for a variable into which the value of a function is assigned (here, mean_of_2_and_6) does not have to be the same as the name used internally in the return statement (here, mean_of_a_and_b).

In general, what follows return is an expression (formula) that is evaluated to get the value that is then sent back; listing the name of one or more variables is just one simple way to do this. For example, the above function mean could instead be defined with

def mean(a, b):
    return (a + b)/2

Also, multiple values can be given in a return line; they are then output as a tuple

def mean_and_difference(a, b):
    return (a + b)/2, b - a
mean_and_difference(2, 5)
(3.5, 3)

4.2. Variables “created” inside a function definition are local to it#

A more subtle point: all the variables appearing in the function’s def line (here a and b) and any created inside with an assignment statement (here just mean_of_a_and_b) are purely local to the function; they do not exist outside the function. For that reason, when you call a function, you have to do something with the return value, like assign it to a variable (as done here with mean_of_a_and_b) or use it as input to another function (see below).

Aside: there is a way that a variable can be shared between a function and the rest of the file in which the function definition appears; so-called global variables, using global statements. However, it is generally good practice to avoid them as much as possible, and I will do so in this course.

To illustrate this point about local variables, let us look for the values of variables a and mean_of_a_and_b in the code after the function is called:

mean310 = mean(3, 10)
print('After using function mean:')
print(f'a = {a}')
After using function mean:
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [6], in <cell line: 2>()
      1 print('After using function mean:')
----> 2 print(f'a = {a}')

NameError: name 'a' is not defined
print('mean_of_a_and_b =', mean_of_a_and_b)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [7], in <cell line: 1>()
----> 1 print('mean_of_a_and_b =', mean_of_a_and_b)

NameError: name 'mean_of_a_and_b' is not defined
print(f'mean310 = {mean310}')
mean310 = 6.5

The same name can even be used locally in a function and also outside it in the same file; they are different objects with independent values:

def double(a):
    print(f'At the start of function "double", a = {a}')
    a = 2 * a
    print(f'A bit later in that function, a = {a}')
    return a
a = 1
print(f'Before calling function "double", a = {a}')
b = double(a)
print(f'After calling function "double", b = {b}, but a = {a} again.')
Before calling function "double", a = 1
At the start of function "double", a = 1
A bit later in that function, a = 2
After calling function "double", b = 2, but a = 1 again.

Warning about keeping indentation correct: The line a = 1 is after the function definition, not part of it, as indicated by the reduced indentation; however Python code editing tools like JuperLab and Spyder will default to indenting each new line as much as the previous one when you end a line by typing “return”. Thus, when typing in a function def, it is important to manually reduce the indentation (“dedent”) at the end of the definition. The same is true for all statements that end with a colon and so control a following block of code, like if, else, elif and for.

Example B. More on multiple output values, with tuples

Often, a function computes and returns several quantities; one example is a function version of our quadratic equation solver, which takes three input parameters and computes a pair of roots. Here is a very basic function for this, ignoring for now possible problems like division by zero:

from math import sqrt

def solve_quadratic(c_2, c_1, c_0):
    '''
    Note: c_i is the coefficient of x^i
    '''
    discriminant = c_1**2 - 4 * c_0 * c_2
    root0 = (-c_1 + sqrt(discriminant))/(2 * c_2)
    root1 = (-c_1 - sqrt(discriminant))/(2 * c_2)
    return root0, root1

Now what is returned is a tuple, and it can be stored into a single variable:

roots = solve_quadratic(2, -10, 8)
print(f'Variable "roots" has value {roots}')
print(f'One root is {roots[0]} and the other is {roots[1]}')
Variable "roots" has value (4.0, 1.0)
One root is 4.0 and the other is 1.0

However, it is often convenient to store each returned value into a separate variable, using tuple notation at left in the assignment statement:

(rootA, rootB) = solve_quadratic(2, -10, 8)
print(f'One root is {rootA} and the other is {rootB}')
One root is 4.0 and the other is 1.0

Note: With tuples, parentheses are optional#

When tuples were introduced in the section on Python Variables, Including Lists and Tuples, and Arrays from Package Numpy, they were described as “a parenthesized list of values separated by commas”, but note that above, no parentheses were used: the return line was

return root0, root1

not

return (root0, root1)

though the latter version would also work.

In fact, you can always omit the parentheses when specifying a tuple, just giving a comma-separated list of values, even on the left side of an assignment statement. Thus, the above example could also be done like this:

rootA, rootB = solve_quadratic(2, -10, 8)
print(f'One root is {rootA} and the other is {rootB}')
One root is 4.0 and the other is 1.0

4.3. Single-member tuples: not an oxymoron#

Tuples can have a single member, but then to make it clear to Python that it is a tuple, there must always be a comma after that sole element. Compare:

tuple_a = (1,)
print(tuple_a)
(1,)
tuple_b = 2,
print(tuple_b)
(2,)
not_a_tuple_c = (3)
print(not_a_tuple_c)
3

4.4. Input argument lists are also tuples#

You can also think of the collection of input arguments to a function as a tuple. This works nicely for composition of functions: the output tuple from one function is passed as the input tuple for the next, as we will see soon.

The input to and output from functions is probably the main place that we will use tuples in this course.

The ability to store the collection of output values from a function in a single tuple can also be convenient when you wish to store that collection for later use.

Here I illustrate the strategy of dividing a task into several smaller, simpler pieces, each of which can then be reused independently:

  • Step 1 is to compute the roots

  • step 2 is to print them, sorted into increasing order.

def printtwonumbersinorder(numbers):
    '''
    Print two numbers in increasing order.
    Warning: this assumes real input values,
    and is not very stylish when the two numbers are equal.
    '''
    if numbers[0] <= numbers[1]:
        # Easy; they are already in order
        print(f'The results, in order, are {numbers[0]:g} and {numbers[1]:g}')
    else:
        # swap them:
        print(f'The results, in order, are {numbers[1]:g} and {numbers[0]:g}')
bothroots = solve_quadratic(2, -10, 8)
print('The unsorted tuple of roots is', bothroots)
printtwonumbersinorder(bothroots)
The unsorted tuple of roots is (4.0, 1.0)
The results, in order, are 1 and 4

We can compose functions, and so do these two steps in one:

printtwonumbersinorder(solve_quadratic(1, 5, -6))
The results, in order, are -6 and 1

Aside: documenting functions with triple quoted comments, and help

Note that the code block for function printtwonumbersinorder starts with a comment surrounded by a triple of quote characters at each end, and this sort of comment can continue over multiple lines.

In addition to making it easier to have long comments, this sort of comment provides some self-documentation for the function — not just in the code for the function, but also in the Python help system:

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

printtwonumbersinorder(numbers)
    Print two numbers in increasing order.
    Warning: this assumes real input values,
    and is not very stylish when the two numbers are equal.

About the reference to module __main__: that is the standard name for code that is not explicitly part of any other module, such as anything defined in the current file rather than imported from elsewhere. Compare to what we get when a function comes from another module:

help(sqrt)
Help on built-in function sqrt in module math:

sqrt(x, /)
    Return the square root of x.

As you might expect, all objects provided by standard modules like math and numpy have some documentation provided; help is useful in cases like this where you cannot see the Python code that defines the function.

When a function def lacks such a self-documentation comment, help still tells us something; the syntax for using it, and where it come from:

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

mean(a, b)

Exercise A. A robust function for solving quadratics#

Refine the above function solve_quadratic for solving quadratics, and make it robust; handling all possible input triples of real numbers in a “reasonable” way. (If you have done Exercise C of the section Decision Making With if, else, and elif, this is essentially just integrating that code into a function.)

Not all choices of those coefficients will give two distinct real roots, so work out all the possibilities, and try to handle them all.

  1. Its input arguments are three real (floating point) numbers, giving the coefficients \(a\), \(b\), and \(c\) of the quadratic equation \(ax^2 + bx + c = 0\).

  2. It always returns a result (usually a pair of numerical values for the roots) for any “genuine” quadratic equation, meaning one with \(a \ne 0\).

  3. If the quadratic has real roots, these are output with a return statement — no print commands in the function.

  4. In cases where it is not a genuine quadratic, or there are no real roots, return the special value None.

  5. As an optional further refinement: in the “prohibited” case \(a = 0\), have it produce a custom error message, by “raising an exception”. We will learn more about handling “exceptional” situations later, but for now you could just use the command:

    raise ZeroDivisionError(“The coefficient ‘a’ of x^2 cannot have the value zero.”)

or

raise ValueError("The coefficient 'a' of x^2 cannot have the value zero.")

Ultimately put this in a cell in a Jupyter notebook (suggested name: “functionsExerciseA.ipynb”); if you prefer to develop it with Spyder, I suggest the filename “quadratic_solver_c.py”

Testing#

Test and demonstrate this function with a list of test cases, including:

  1. \(2x^2 - 10x + 8 = 0\)

  2. \(x^2 -2x + 1 = 0\)

  3. \(x^2 + 2 = 0\)

  4. \(x^2 + 6x + 25 = 0\)

  5. \(4x - 10 = 0\)

Notebook organization#

To start developing good notebook organizational practice:

  • As always, start with a title cell giving a title, your name and the date.

  • Follow this by an introduction cell giving a brief description of what the notebook does — see above; follow the links here!

  • Put any import statements straight after the introduction, before any other Python code.

  • Define the function in one cell (more generally, define each function in its own cell).

  • Follow the function definition[s] with the test cases, each in its own cell.

  • If anything noteworthy arises in a test case, add comments on this in a Markdown after that test case cell.

4.5. Keyword arguments: specifying input values by name#

Sometimes a function has numerous input arguments, and then it might be hard to remember what order they go in.

Even with just a few arguments, there can be room for confusion; for example, in the above function solve_quadratic do we give the coefficients in order c_2, c_1, c_0 as for \(c_2 x^2 + c_1 x + c_0 = 0\), or in order c_0, c_1, c_2 as for \(c_0 + c_1 x + c_2 x^2 = 0\)?

To improve readability and help avoid errors, Python has a nice optional feature of specifying input arguments by name; they are then called keyword arguments, and can be given in any order.

For example:

moreroots = solve_quadratic(c_1=3, c_2=1, c_0=-10)
printtwonumbersinorder(moreroots)
The results, in order, are -5 and 2

When you are specifying the parameters by name, there is no need to have them in any particular order. For example, if you like to write polynomials “from the bottom up”, as with \(-10 + 3x + x^2\), which is \(c_0 + c_1 x + c_2 x^2\), you could do this:

sameroots = solve_quadratic(c_0=-10, c_1=3, c_2=1)
printtwonumbersinorder(sameroots)
The results, in order, are -5 and 2

4.6. Functions as input to other functions#

In mathematical computing, we often wish to define a (Python) function that does something with a (mathematical) function. A simple example is implementing the basic difference quotient approximation of the derivative

\[f'(x) = Df(x) \approx \frac{f(x+h) - f(x)}{h}\]

with a function Df_approximation, whose input will include the function \(f\) as well as the two numbers \(x\) and \(h\).

Python makes this fairly easy, since Python functions, like numbers, can be the values of variables, and given as input to other functions in the same way: in fact the statement

def f(...): ...

creates variable f with this function as its value.

So here is a suitable definition:

def Df_approximation(f, x, h):
    return (f(x+h) - f(x))/h

and one way to use it is as follows:

def p(x):
    return 2*x**2 - 10*x + 8
x0 = 1
h = 1e-4

Df_x0_h = Df_approximation(p, x0, h)
print(f'Df({x0}) is approximately {Df_x0_h}')
Df(1) is approximately -5.999800000004996

A bit more about keyword arguments: they can be used together with positional arguments, but once an argument is given in keyword form, all later ones must be also. Thus it works to do this:

Dfxh = Df_approximation(p, x=x0, h=1e-6)
print(f'Df({x0}) is approximately {Dfxh}')
Df(1) is approximately -5.99999799888451

but the following fails:

Dfxh = Df_approximation(p, x=2, 0.000001)
print(f'Df(2) is approximately {Dfxh}')
  Input In [30]
    Dfxh = Df_approximation(p, x=2, 0.000001)
                                            ^
SyntaxError: positional argument follows keyword argument

4.7. Optional input arguments and default values#

Sometimes it makes sense for a function to have default values for arguments, so that not all argument values need to be specified. For example, the value \(h= 10^{-8}\) is in some sense close to “ideal”, so let us make that the default, by giving h a “suggested” value as part of the function’s (new, improved) definition:

def Df_approximation(f, x, h=1e-8):
    return (f(x+h) - f(x))/h

The value for input argument h can now optionally be omitted when the function is used, getting the same result as before:

Df_x0_h = Df_approximation(p, x0)
print(f'Df({x0}) is approximately {Df_x0_h}')
Df(1) is approximately -5.999999963535174

or we can specify a value when we want to use a different one:

big_h = 0.01
Df_x0_h = Df_approximation(p, x0, big_h)
print(f'Using h={big_h}, Df({x0}) is approximately {Df_x0_h}')
Using h=0.01, Df(1) is approximately -5.979999999999919

Arguments with default values must come after all others in the def#

When default values are given for some arguments but not all, these must appear in the function definition after all the arguments without default values, as is the case with h=1e-8 above.

Exercise B. A function with function inputs (and exceptions)#

A usually more accurate formula for approximating derivatives is the Centered Difference Rule $\(Df(x) \approx \frac{f(x+h) - f(x-h)}{2 h}\)$

  1. Write a function that uses this, with usage

Dfxh = Df_CD_approximation(f, x, h)

  1. Give \(h\) the default value \(10^{-12}\)

  2. Raise an exception if the forbidden value \(h=0\) is used.

  3. Choose and implement a few test cases.

You may do this all in the same notebook as above.

If you wish to also do this in a Python code file, use a different file than the quadratic solving exercise; maybe named “centered_differences.py”.

4.8. Optional topic: anonymous functions, a.k.a. lambda functions#

Note: Some students might be interested in “anonymous functions”, also known as “lambda functions”, so here is a brief introduction. However, this topic is not needed for this course; it is only a convenience, and if you are new to computer programming, I suggest that you skip this section for now.

One inconvenience in the above example with Df_approximation is that we had to first put the values of each input argument into three variables. Sometimes we would rather skip that step, and indeed we have seen that we could put the numerical argument values in directly:

Df_x_h = Df_approximation(p, 4, 1e-4)
print(f'The derivative is approximately {Df_x_h}')
The derivative is approximately 6.000200000002565

However, we still needed to define the function first, and give it a name, p.

If the function is only ever used this one time, we can avoid this, specifying the function directly as an input argument value to the function Df_approximation, without first naming it. This is done with what is called an anonymous function, or for mysterious historical reasons, a lambda function.

For the example above, we can do this:

Df_x_h = Df_approximation(lambda x: 2*x**2 - 10*x + 8, 4, 1e-4)
print(f'The derivative is approximately {Df_x_h}')
The derivative is approximately 6.000200000002565

We can even do it all in a single line by composing two functions, print and Df_approximation:

print(f'The derivative is approximately {Df_approximation(lambda x: 2*x**2 - 10*x + 8, 4, 1e-4)}')
The derivative is approximately 6.000200000002565

Here, the expression

lambda x: 2*x**2 - 10*x + 8

creates a function that is mathematically the same as function p above; it just has no name.

In general, the form is a single-line expression with four elements:

  • It starts with lambda

  • next is a list of input argument names, separated by commas if there are more than one (but no parentheses!?)

  • then a colon

  • and finally, a formula involving the input variables.

We can, if we want, assign a lambda function to a variable, so we could have defined p as

p = lambda x: 2*x**2 - 10*x + 8

though I am not sure if that has any advantage over doing this with def:

def p(x): return 2*x**2 - 10*x + 8

As an example of that, and also of having a lambda function that returns multiple values, here is yet another quadratic equation solver:

solve_quadratic = lambda a, b, c: ( (-b + sqrt(b**2 - 4*a*c))/(2 * a), (-b - sqrt(b**2 - 4*a*c))/(2 * a) )
print(f'The roots of 2*x**2 - 10*x + 8 are {solve_quadratic(2, -10, 8)}')
The roots of 2*x**2 - 10*x + 8 are (4.0, 1.0)

Anonymous functions have most of the fancy features of functions created with def, with the big exception that they must be defined on a single line. For example, they also allow the use of keyword arguments, allowing the input argument values to be specified by keyword in any order. It is also possible to give default values to some arguments at the end of the argument list.

To show off a few of these refinements:

printtwonumbersinorder(solve_quadratic(b=-10, a=2, c=8))
The results, in order, are 1 and 4