---
jupyter:
  jupytext:
    formats: ipynb,md
    text_representation:
      extension: .md
      format_name: markdown
      format_version: '1.1'
      jupytext_version: 1.2.2
  kernelspec:
    display_name: Python 3
    language: python
    name: python3
---

# Functions in Python

In this notebook, we will explore the implementation of functions in Python. 

**Learning objectives for this notebook:**

* Student is able to define functions with input parameters to execute a piece of code (use functions as "methods")
* Student is able to create and use functions that return a value (or multiple values)
* Student is able to import functions from libraries / modules
* Student is able to use Shift-Tab to bring up the help for a function from a library
* Student is able to predict if a variable name in a function refers to a local variable or a global variable

## Functions to save typing

In programming, you often want to repeat the same sequence of commands over and over again. 

One way to do this is to copy and paste the same piece of code over and over again. This is actually quite easy, but runs quickly into a problem: let's say you want to change a little bit what that code will do, then you need to change it in many places. If you change it in one place but forget in another, then your program might crash (ie. give an error). Or even worse, and even harder to <a href=https://en.wikipedia.org/wiki/Debugging>debug</a> the mistake may  may give an error message but give you the wrong answer!

For this reason (among others), programming languages allow programmers to define "functions". Functions are pieces of code that you can give a name and then enable you to them use over and over again, without having to retype the code text. 

As an example, let's say that we want to print out the value of a variables named `a` and `b` using a long sentence:

```python
a = 6
b = 4
print("The value of variable a is", a)
print("The value of variable b is", b)

a = a/2
b = 3
print("The value of variable a is", a)
print("The value of variable b is", b)

a = a+1
b = 1.5
print("The value of variable a is", a)
print("The value of variable b is", b)

a = a-20
b = -1e4
print("The value of variable a is", a)
print("The value of variable b is", b)

a = a+1j
b = 1
print("The value of variable a is", a)
print("The value of variable b is", b)
```

<!-- #region -->
To save a lot of typing, one can define a simple function to do this work for us. To define a function, you use the following syntax:

```
def function_name():
     ...
```

Here, you replace the `...` with the code you want to function to execute. The Python the code inside the function should be <a  href=https://en.wikipedia.org/wiki/Indentation_(typesetting)>indented</a> by starting each line with a <a href=https://en.wikipedia.org/wiki/Tab_key>tab</a>. By default, adding a tab will produce 4 spaces in your code. You can also "indent" your code by manually adding spaces, but you must make sure to add 4 spaces each time. The Jupyter notebook will try to detect if you make a mistake in your indentation, and will sometimes color your text in red if it detects a mistake. 

Tabs in Python are VERY IMPORTANT: python uses tabs to know which code is inside the function and which is not. If you make a mistake with the tabs in such a way that python cannot understand what you mean, it will give you an `IdentationError`.

In notebooks, you can also select a line, or multiple lines, and then use `Tab` to increase their indentation level, or use `Shift-Tab` to decrease their indentation level.
<!-- #endregion -->

```python
def test_tab_and_shift_tab():
    some code
        that is indendented
    try selecting this text
    and then pushing tab 
    and shift-tab
```

Once you have defined your function, you can execute it by using the code `function_name()`. 

Let's look at how to use a function as a "procedure" to simplify the code above: 

```python
def print_status():
    print("The value of variable a is", a)
    print("The value of variable b is", b)

a = 6
b = 4
print_status()

a = a/2
b = 3
print_status()

a = a+1
b = 1.5
print_status()

a = a-20
b = -1e4
print_status()

a = a+1j
b = 1
print_status()
```

In this example, it may not be such a big deal, but you can imagine that as the code in your function becomes more and more complicated, it will save you a lot of time. Also, imagine that I wanted to change the wording of the sentence I print: in the case with the function, I would only have to do this once, while in the example without function, I would have to manually change this at 5 different places. 


**Exercise 2.1** Write your own function that contains two lines of code. The first line should make a new variable `var2` that is `var` converted to an integer. The second line of your code should print the value of `var2`. 

Using this code, play around with the indentation (add extra tabs and spaces for example) to see how 'critical' Python is with indentation. For example: does three spaces work instead of `Tab`? Does one space work? What about `Tab` on the first line and three spaces on the second line? Can you make Python trigger an `IdentationError`? 

```python
var=3.5

# Your function here
```

<!-- #region -->
## Functions with input variables

Let's say that we wanted to print out the status of variables that we do not know the name of ahead of time, as in the example above. Say we wanted to make a function that could print out a message with the status of value of ANY variable. How could we do this? 

In the example above, our function explicitly printed out variables `a` and `b`. But this only works because I know in advance that the person using my function has defined variables `a` and `b`. But what if I want to print the value of variable `c`? 

To allow functions to be more generic, and therefore more "reusable" in general, Python allows you to define "input variables" for your function. The syntax for this is the following:

```
def function_name(x):
    ...
```

When you do this, for the code INSIDE your function, a variable `x` will be defined that will have the value given by the input value given to the function by the user. Let's look at a specific example:
<!-- #endregion -->

```python
def print_status2(x):
    print("The value passed to the function is", x)

a = 1.5
print_status2(a)

a = 1+1j
print_status2(a)

print_status2(1.5323)
```

How does this work? 

When the function `print_status(a)` is called, Python "sends" ("passes" in computer speak) the value of `a` to the function. Inside the function, Python creates a new (temporary) variable called `x`, that is defined ONLY while the function code is running. This temporary variable `x` is then assigned the value that was sent to the function, and then the code is executed. When the function is finished, the variable `x` is destroyed. (Try adding the code `print(x)` above outside the function and see what happens!)

Note, as you can see in the third example, the things you pass to functions do not even need to be variables! This is fine because the function only needs the value of the argument that is passed to the function. 


**Exercise 2.2** Copy your code from exercise 2.1 (but then with just normal indentation using tabs) into the cell below and change it such that it uses a function with input parameters to achieve the same task.

```python
# Your code here
```

<!-- #region -->
## Functions with multiple inputs 

Functions can also take multiple input variables. To do this, you put them all in between the brackets `()`, separated by commas. For example, with 3 variables, the syntax is:

```
def function_name(variable1, variable2, variable3):
    ...
```

You would then use this function in the following way:

```
function_name(argument1, argument2, argument3)
```

When you do this, inside the function, `variable1` will get assigned the value of `argument1`, `variable2` will get assigned the value of `argument2`, and `variable3` will get assigned the value of `argument3`. This matching of the position in the list is called matching by "positional order".  

Note that there are several different names used for the "input variables" of a function: often, computer scientists will also use the name "input arguments" (or just "arguements), or "input parameters" (or just "parameters"). 
<!-- #endregion -->

```python
def print_status3(x, y):
    print("The value of the first input variable is ", x)
    print("The value of the second input variable is ", y)

print_status3(1,2)
print_status3(2.5,1.5)
print_status3(a, 2*a)
```

```python

```

**Exercise 2.3** Make a  new function `print_status4()` that takes three variables as arguments and prints out messages telling the user the values of each of them (as above, but with three input variables). Test it to make sure it works. 

```python
# Your code here
```

## Functions that return a value

In addition to receiving values as inputs, functions can also send back values to the person using the function. In computer programming, this is called the "return value". 

When you create a function, you can use the `return` command to specify what value should be sent back to the person using the function. Let's look at an example:

```python
def my_formula(x):
    y = x**2 + 3
    return y
```

To "capture" the value returned by the function, you can assign it to a varible like this:

```python
result = my_formula(3.5)
print(result)
```

You can also just directly "use" the result of the function if you want:

```python
print(my_formula(4.6))
```

Note that as soon as python sees the `return` command, it stops running the function, so any code after it will not be executed:

```python
def myfunction(x):
    print("This gets printed.")
    return x**2 + 3
    print("This does not.")
    
print(myfunction(5))
```

If you want to send back more than one result to the user of your function, you can separate the results with commas when you use the `return` command. How do you make use of these two variables that you send back? You will explore this in this exercise:


**Exercise 2.4** **(a)**  Write a function that takes two real numbers as input and returns the sum and product of the two numbers. In your function, try to send *both* of the calculated numbers back as a return value. We have not taught you that yet so you will have to look it up: I recommend trying a google search for "python function return two variables". 

```python
# Your function here
def product_and_sum(...):
    ...
```

**(b)** Now USE your function to calculate the sum and product of `a` and `b`, "capturing" the sum and product in variables `s` and `p`:

```python
a=1.5
b=2.5

...some code that uses the return value of your function to set variable s and p...

print("Sum is:", s)
print("Product is:", p)
```

## Importing functions from libraries

One of the big advantages of python is that there are huge collection of libraries that include code for doing a huge number of things for you! We will make extensive use of the library `numpy` for numerical calculations in Python, and the library `matplotlib` for generating scientific plots. Beyond this, nearly anything you want to be able to do on a computer can be found in Python libraries, which is one of the reasons it is so popular. 

In order make use of these libraries of code, you need to "import" them into the "namespace" of your kernel. 

("Namespace" is Python-speak for the list of functions and variable names that you can find in the running copy of python that is connected to your notebook.)

Here, we will show you a few examples of different ways of importing code into your notebook from a library (also called a "module"). For this, we will take the example we used already in Notebook 1: in the module `time`, there is a function called `sleep()` that will perform the task of "pausing" for a number of seconds given by the its argument. 

You can find out more about the `time` module by looking at its documentation webpage:

https://docs.python.org/3/library/time.html

and specifically about the `sleep()` function here:

https://docs.python.org/3/library/time.html#time.sleep

### Importing a whole module

The simplest way to be able use the `sleep`  function of the `time` module is to import it using the following command:

```python
import time
```

You can see it has been imported by using the `%whos` command: 

```python
%whos
```

Once it has been imported, you can access all the functions of the module by adding `time.` in front of the function name (from the time module) in your code:

```python
print("Starting to sleep")
time.sleep(5)
print("Done!")
```

If you import the whole module, you will have access to all the functions in it. To see what functions are in the module for you to use type `dir(sleep)`, which will generate this list.

Sometimes, if you will be using the functions from the module a lot, you can give it a different "prefix" to save yourself some typing:

```python
import time as tm
print("Starting to sleep")
tm.sleep(5)
print("Done!")
```

We will use this a lot when using the `numpy` module, shortening its name to `np` when we import it, and also for the `matplotlib.pyplot` submodule, which we will shorten to `plt`. (These are also typically used conventions in the scientific community.)


### Importing a single function 

If you need only a single function from a library, there is also a second commonly used way to import only that single function using the following syntax:

```python
from time import sleep
```

When you do this, the function `sleep()` will be available directly in your notebook kernel "namespace" without any prefix:

```python
print("Starting to sleep")
sleep(5)
print("Done!")
```

Using `%whos`, we can now see that we have three different ways to use the `sleep()` function:

```python
%whos
```

<!-- #region -->
If you look around on the internet, you will also find people that will do the following

```
from numpy import *
```

This will import all the functions from numpy directly into the namespace of your kernel with no prefix. You might think: what a great idea, this will save me loads of typing! Instead of typing `np.sqrt()` for example, to use the square-root function, I could just type `sqrt()`. 

While true, it will save typing, it also comes with a risk: sometimes different modules have functions that have the same name, but do different things. A concrete example is the function `sqrt()`, which is available in both the `math` module and the `numpy` module. Unfortunately, `math.sqrt()` will give an error when using numpy arrays (which we will learn more about in later notebooks). 

If you import both of them, you will overwrite these functions by the second import, and if you're not careful, you will forget which one you are using, and it could cause your code to break. It will also "crowd" your notebooks namespace: using the `whos` function, you will suddenly see hundreds or even thousands of functions, instead of only just a module. 

For these reasons, it is generally advised not to use `import *`, and it is considered poor coding practice in modern python.
<!-- #endregion -->

### Shift-Tab for getting help

Like the tab completion we saw in the first notebook, Jupyter also can give you help on functions you have imported from libraries if you type `Shift-Tab`. 

Say I forgot how to use the `sleep()` function. If I type the word "sleep" and then push `Shift-Tab`, Jupyter will bring up a help window for that function.

Try it: click on any part of the word `sleep` in the following code cell and push `Shift-Tab`:

```python
sleep
```

You can also find the same help as the output of a code cell by using the `help()` function:

```python
help(sleep)
```

There are extensive online resources for many modules. The most used modules have  helpful examples on the functions and how to implement them. 


**Exercise 2.5 (a)** Find help for the built-in functions `abs`, `int`, and `input`. Which of the help functions are easy to read? Which one does not provide such useful information (compared to the online documentation page)? (Put each help command in a separate cell)

```python
# Your code here
```

```python
# Your code here
```

```python
# Your code here
```

**(b)** Import the function `glob` from the library `glob` and print its help information. What does the function `glob("../*")` do? 

```python
# run the help here
```

```python
# your code here
```

## Global variables, local variables, and variable scope

In our first functions above, we saw a couple of examples of using variables inside functions. 

In the first example, we used the variables `a` and `b` inside our function that we created outside our function, directly in our notebook. 

In the second example, we used the "temporary" variable `x` inside our function. 

These were two examples of different variable "scope". In computer programming, <a href=https://en.wikipedia.org/wiki/Scope_(computer_science)>scope</a> define the rules Python uses when it tries to look up the value of a variable. 

In the slightly simplified picture we will work with here, variables can have two different types of "scopes": **global scope** and **local scope**. 

If Python looks for a variable value, it first looks in the local scope (also called "local namespace"). If it does not find it, python will go up into the global scope (also called the "global namespace") and look for the variable there. If it does not find the varible there, it will trigger an error (a `NameError` to be precise).

How do I create a global variable? By default, if you create a variable directly in your notebook (and not in a function in your notebook), it will always be **global**. So, actually, you've already created a bunch of global variables! 

Any variables you define inside a function in your code will be a **local** variable (including the input variables automatically created if your function takes any arguments). 

If you want to create a global variable inside a function, or make sure the variable you are referring to is the global variable and not the local one, you can do this by the `global` qualifier, which we will look at in a minute. 

Let's take a look at this in more detail by analysing a few examples. 

**Example 1** Accessing a global variable inside a function

```python
a1 = 5

def my_func():
    print(a1)
    
my_func()
a1 = 6
my_func()
```

<!-- #region -->
In this example, when Python is inside the function `my_func()`, it first looks to see if there is a variable `a1` in the local scope of the function. It does not find one, so it then goes and looks in the global scope. There, it finds a variable `a1`, and so it uses this one. 


**Example 2** An example that doesn't work (unless you've run the next cell, in which case it will only fail again after you restart your kernel)
<!-- #endregion -->

```python
def my_func():
    print(b1)
    
my_func()
```

This code gives a `NameError` because there is no variable `b1` yet created in the global scope. If we run the following code cell and try the code above again, it will work. 

```python
b1 = 6
```

Here you can see one of risks of languages like python: because of the persistent memory of the kernel, code can succeed or fail depending on what code you have run before it...

If you want to see the error message above again, you can delete variable b1 using this code and run it again:

```python
del b1
```

**Example 3** Variables defined in the local scope of a function are not accessible outside the function

```python
def my_func():
    x = 5
    
my_func()

print(x)
```

**Example 4** Variables passed to functions cannot be modified by the function (more on this later when we look at more complicated data structures...sometimes this is different)

```python
def my_func(a):
    a = 6
    
a=5
my_func(a)
print(a)
```

This one is a bit subtle (mega-confusing?) because we re-used the same name `a` for the local variable in the function as the global variable outside of the function. However, the operation is quite logical. When the function code starts running, it creates a `local` variable `a` to store the value it received. And now, because there is already a local variable called `a`, using `a` in the function refers to the `local` variable `a`, not the `global` variable `a` we define before calling the function. 


**Example 5** This one is a tricky one.

```python
a = 6

def my_func():
    a = 7

print(a)
my_func()
print(a)
```

 It would seem that the function would refer to the global variable `a` and therefore change it's value. However, it is tricky since we first use `a` in the function in an assignment. An assignment in python will automatically create a variable if it does not exist, and so python creates a new variable named `a` in the local scope. The name `a` inside the function now refers to this newly created local variable, and therefore the global variable will not be changed. In fact, this guarantees that you cannot change global variables inside a function, unless you use the `global` qualifier shown in the next example.


**Example 6** If you want to make sure that the `a` inside your function is referring to the global variable `a`, you can include the line `global a` inside your function to tell python that you mean the global variable `a`. 

```python
a = 6

def my_func():
    global a
    a = 7

print(a)
my_func()
print(a)
```

Note that in general, it is considered bad programming practice to use (too many) global variables. Why? When you write longer and bigger sections of code, it is easier to understand what is going in in your function if your function uses only local variables and communicates back and forth using input parameter and return variables. Using too many global variables in a function can be confusing because they are defined in a different place in your code and so you don't have a good oversight of them. (Bigger projects can easily have  10,000+ lines of code!)  

In computer science, this is a topic of often intense debate (resulting in what nerds refer to as a <a href="https://www.urbandictionary.com/define.php?term=flame%20war">flame war</a>), with global variables being branded as "dangerous" like in this stack exchange post:  

https://stackoverflow.com/questions/423379/using-global-variables-in-a-function

But I personally agree with the comments in this post that "global variables have their place but should be used sparingly".

Summary of the rules for global and local variables:

* If a local variable of the same name exists or is created by python (by assignment, for example), then python uses the local varible
* If you try to use a variable name that does not exist locally, python checks for a global variable of the same name
* If you want to change the value of a global inside a function, then you must use the `global` statement to make it clear to python than you want that name to refer to the global variable


## Solutions to Exercises


**Exercise 2.1** 

```python
var=3.5

# Example: one  space is actually enough! But is discouraged, as you can see
# by the fact the the notebook made it red colored. 
def myfunction():
 var2 =  int(var)
 print(var2)

# Once you've started a code block though with a specific indentaton, then you cant change
# it anymore.
def myfunction():
  var2 =  int(var)
 print(var2)
```

**Exercise 2.2** 

```python
def myfunction(var):
    var2 =  int(var)
    print(var2)
```

**Exercise 2.3** 

```python
def print_status3(x, y, z):
    print("The value of the first input variable is ", x)
    print("The value of the second input variable is ", y)
    print("The value of the second input variable is ", z)

print(1,2,3)
```

**Exercise 2.4** **(a)** 

```python
# Your function here
def product_and_sum(a,b):
    return a*b, a+b
```

**(b)** 

```python
a=1.5
b=2.5

p,s = product_and_sum(a,b)

print("Sum is:", s)
print("Product is:", p)
```

**Exercise 2.5** **(a)**

```python
help(abs)
```

```python
help(int)
```

```python
help(input)
```

**(b)**

```python
from glob import glob
help(glob)
```

```python
glob("../*")
# It returns list of files and folders in the parent directory of that
# in which this notebook is stored
```