# Functions

So far in this course we've encountered a few basic **functions** such as `print`, `sum`, `len`, and some others from the `math` module. However, we've largely glossed over the details of what functions are, how they work, and how we might create our own functions.

## Function basics

A **function** is a piece of reusable code that takes some **input(s)**, manipulates it/them in a useful way, and (usually) **returns** some output(s).

::: {.callout-tip title='Reminder'}
We **call** a function by typing its name followed by a set of brackets into which we place the **inputs**. For example

```python
message = 'Here we are calling the print function!'

print(message)
```
:::

We refer to the **inputs** to a function as **arguments** - just like in mathematics. So, in the above reminder, when we use the `print` function we would describe this more correctly as **calling** the `print` function with `message` **passed** as the only **argument**.

Some functions like `print` don't **return** anything back to us - we don't get any results or new **variables**. We've previously used the `math` module to obtain values of the cosine and sine functions, and in that case we are **returned** a single value corresponding to $\cos$ or $\sin$ evaluated at our specified angle. For example, $\cos(\pi)$.

```python
import math

math.cos(math.pi)
```
```
-1.0
```

We can place the result of this **call** to `math.cos` in a **variable** using the **assignment** operator `=`.

```python
import math

value = math.cos(math.pi)
print(f'cos(π) = {value:.1f}')
```
```
cos(π) = -1.0
```

## Defining functions

Now you've been reminded of how we **call** functions, let's get on with writing some of your own.

To get started, we'll use an example to take a look at the components that make up a function, and then in later sections we'll delve into the specifics of how each works.

The following simple (and rather pointless!) `add` **function** sums two numbers

```python
def add(a, b):
    result = a + b

    return result
```

The first line starts with the `def` (short for **define**) keyword - this tells Python that we are **defining** a new function.

We then follow this with the name of our function - here we've chosen `add`.

Then we have a set of brackets containing the two arguments to the function - here we've called them `a` and `b`.

We follow this with the contents of the function - the actual code that will run when we **call** this function. Notice that the code is **indented** by one tab (or four spaces) - this tells Python that this code is part of the function.

In this function the variables `a` and `b` are added together to make the variable `result`. The `return` keyword is used to indicate what we'd like the **output** of the function to be. In this example we are **returning** the variable `result`.

::: {.callout-note icon="false" title="Syntax"}
The general syntax for defining a **function** is

```python
def name_of_function(input_argument1, input_argument2,...):
    code_inside_the_function
    some_more_code
    and_so_on...

    return return_values
```

where the code inside of the function is **indented** by one tab (or four spaces) - Jupyter usually does this automatically.
:::

To use this function we simply **call** it like any other, for example we can compute $5+6$ by imputting two `int` values.

```python
add(5, 6)
```
```
11
```

but we could also pass a pair of variables

```python
number_1 = 1.25
number_2 = 2.15

add(number_1, number_2)
```
```
3.4
``` 

You might be wondering - what about `a` and `b`, why have we used `number_1` and `number_2`? The answer to this question requires us to learn about something called **scope**.

### Scoping

Functions are machines - they take an input and process it to give an output. Within the internal workings of a function there need to be a set of names for the input variables, output variables, and any other variables that the function might create and use when it is executed. We call this "list of names" the **scope** of the function.

Let's see an example - a function which converts a frequency in Hertz to an energy in Joules.

```python
def freq_to_energy(freq_hz):

    planck = 6.626E-34 # J s

    # Energy in Joules
    # E = h * nu
    energy_j = freq_hz * planck

    return energy_j
```

The variable `planck` is Planck's constant and is used to convert from frequency to energy.

Let's use this on a frequency value, perhaps for an x-ray with $\nu=5\times10^{17}\mathrm{\ Hz}$.

```python
freq_to_energy(5E17)
```
```
3.313e-16
```

Great! Now what happens if we try to print the `planck` variable?
```python
freq_to_energy(5E17)
print(planck)
```
```python
Traceback (most recent call last):
  File "<python-input-7>", line 2, in <module>
    print(planck)
          ^^^^^^
NameError: name 'planck' is not defined
```

But, didn't we just define the `planck` variable inside our function? Yes! But `planck` only exists within the function `freq_to_energy` - more specifically we say that this is a **local variable** which exists within the **local scope** of that function. For the same reason, printing the variables `freq_hz` and `energy_j` outside of `freq_to_energy` will give an error - they exist only within the **local scope** of `freq_to_energy`.

::: {.callout-note icon="false" title="Test Yourself" collapse="true"}
Modify the definition of `freq_to_energy` so that `planck` is printed when the function is called. Why does this work?
:::

Each function has its own separate **scope**, allowing us to reuse variable names **inside of functions** without worrying about them already being defined, or changing definition.

So if a **local scope** describes the set of variables within a function, then you might wonder if there's an equivalent name for everything that exists outside of functions? Yes, we call this the **global scope** - this is where all of the objects you've defined outside of functions reside.

For example, if you define a variable (outside of a function), e.g.

```python
a = 5
```

we say that it resides in the **global scope** of the program. Simple!

What can be slightly confusing is that objects in the **global scope** are also accessible inside of a function.

Here's an example

```python
number_ten = 10

def scope_example(a, b):
    result = (a - b) / number_ten

    return result
```

We use the variable `number_ten` inside of our function but we've defined it elsewhere. This is what we call a **global variable**.

::: {.callout-warning}
The use of **global variables** is **extremely bad practice** in python - your code will rapidly become unreadable and highly prone to errors.

To see why - use a new cell and redefine `number_10` as the string `'10'`, then **call** the function `scope_example` again - what happens?
:::

There is **one** situtation in which **global variables** should not be looked upon with disdain - **constants**. In science we refer to fixed quantities as **constants**, for example the ideal gas constant $R=8.314\, \mathrm{\ J\ K^{-1}\ mol^{-1}}$ is a constant. In Python we use a similar definition: **constants** are variables which remain fixed throughout the code - they're defined once at the start of a program and used repeatedly. 

Constants are usually denoted by `CAPITAL LETTERS`, but are **not** a new type of object - this is simply a style choice that tells us not to modify this variable.

As an example, we could define the number of seconds in an hour at the start of our program.

```python
SECONDS_IN_HOUR=3600
```

and then use this constant in a function

```python
def hours_to_seconds(hours):
    return hours * SECONDS_IN_HOUR
```

### Arguments

In our previous examples we've seen that we can have different numbers of arguments to a function. We can even have zero arguments

```python
def zero_args_func():

    print('This function takes zero arguments')
    print('and returns nothing')
    return

zero_args_func()
```
```
'This function takes zero arguments'
'and returns nothing'
```

::: {.callout-warning}
You still need to include brackets when calling a function without arguments - otherwise Python will treat your command as a variable name.
:::

::: {.callout-note icon="false" title="Test Yourself" collapse="true"}
What happens if you provide more arguments to a function than it needs?

Try calling `zero_arg_funcs` with multiple arguments - what does the error message say?
:::

By default, arguments to a function are **positional** in nature - we have to give them in the order that the function expects. The following code executes successfully.

```python
def print_name_and_age(name, age):

    print(f'Your name is {name}, and you are {age:d} years old.')

print_name_and_age('Tim', 25)
```

but what happens if we switch the order of the arguments? Below we've provided an age where the name should be and vice-versa.

```python
print_name_and_age(25, 'Tim')
```
```python
Traceback (most recent call last):
  File "<python-input-10>", line 1, in <module>
    print_name_and_age(25, 'Tim')
    ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
  File "<python-input-9>", line 3, in print_name_and_age
    print(f'Your name is {name}, and you are {age:d} years old')
                                             ^^^^^^^
ValueError: Unknown format code 'd' for object of type 'str'
```
In this case we see an error - because the print statement expects age to be an `int - and we can correct our **call** to the function to have the correct argument order.

::: {.callout-note icon="false" title="Test Yourself" collapse="true"}
Consider the following function

```python
from scipy import constants

def ideal_gas_pressure(volume, moles, temperature)
    return moles * constants.R * temperature / volume
```

We want to compute the pressure of $1\ \mathrm{mol}$ of gas at $298\ \mathrm{K}$ with a volume of $5\ \mathrm{m^3}$.

which of the following calls is correct?

```python
ideal_gas_pressure(298, 1, 5)
ideal_gas_pressure(5, 298, 1)
ideal_gas_pressure(5, 1, 298)
```

Do any of these result in an error?
:::

What about if we only want to provide an argument some of the time. Then we can use a **keyword argument** which is **optional** and has a default value that is used if that keyword is not specified. 

Consider the function below, we define `moles` as a keyword argument with a default value of `1`.

```python
def ideal_gas_pressure(volume, temperature, moles=1)
    return moles * constants.R * temperature / volume

ideal_gas_constant(5, 298)
ideal_gas_constant(5, 298, moles=1)
ideal_gas_constant(5, 298, moles=25)
```

The first two calls give the same value. In the first call we don't specify a value for `moles` and so it defaults to `1`, just as we defined. In the second call we set `moles=1` and see the same result, and in the final call we set `moles=25` and see a different result.

::: {.callout-warning}
When defining a function, make sure that all positional arguments are written before any keyword arguments.

```python
def correct(a, b, c=1)
    return a*b*c
```
is correct

but the following gives an error

```python
def correct(a, b=1, c)
    return a*b*c
```
:::

Keyword arguments are particularly useful when they are combined with booleans and logic - something we'll cover in Session 4.

### Return

Functions use the `return` keyword to specify what the **output** should be. For example, our `add` function **returns** the `result` of adding `a` and `b`

```python
def add(a, b):
    result = a + b

    return result
```

but what happens if we don't `return` anything.

```python
def add(a, b):
    result = a + b
    return

add(5, 10)
```

Nothing! The calculation occurs, and is stored in `result`, but then the function ends, doesn't return anything, and `result` is lost.

What happens if we print this

```python
print(add(5, 10))
```
```
None
```

or use the `type` function

```python
type(add(5, 10))
```
```
NoneType
```

so we recieve `None` - which is Python's `type` for nothing or null and represents a lack of data. 

This might seem useless, but there are situations where a function doesn't need to return a value - `print` is one such function.

```python
type(print('The print function does not return anything!'))
```
```
The print function does not return anything!
NoneType
```

You can see this even easier with a variable

```python
print_return_value = print('This will allow us to store whatever the print function returns!')

print(print_return_value)
```
```
This will allow us to store whatever the print function returns!
None
```

We can also choose to return more than one output variable, for example this function returns the sum and difference of two numbers

```python
def add_and_subtract(a, b):
    addition = a + b
    subtraction = a - b

    return addition, subtraction

add_and_subtract(3, 9)
```
```
12 -6
```
When more than one value is returned, the function is actually returning a tuple

```python
result = add_and_subtract(3, 9)
print(result)

type(result)
```
```
(12, -6)
tuple
```

We can access the elements of a tuple using indexing, as we learned in Session 2. Alternatively, we can use multiple assignment to store each value in a separate variable.

```python
addition, subtraction = add_and_subtract(3, 9)

print(addition, subtraction)
print(type(addition), type(subtraction))
```
```
12 -6
int, int
```

# Why do we use functions?

In the first Session's synoptic exercises, you calculated the wavelength of the
first line in each of the Lyman, Balmer, and Paschen hydrogen emission series.

Notice in the answers that the same lines were repeatedly copied and pasted for
each series, resulting in quite a messy piece of code with lots of repetition.

We could instead define a function which calculates the energy of a given transition
between two states $n_\mathrm{i}$ and $n_\mathrm{f}$.

```python
import scipy

def calc_rydberg_wavelength(n_i, n_f):

    iwavelength = scipy.constants.Rydberg * (1/n_f**2 - 1/n_i**2)

    wavelength = 1 / iwavelength
    wavelength *= 10**9

    return wavelength
```

This could then be called for each transition to give a much more readable piece
of code which doesn't write the mathematical operations three times.

```python
lyman_lambda = calc_rydberg_wavelength(2, 1)
balmer_lambda = calc_rydberg_wavelength(3, 2)
paschen_lambda = calc_rydberg_wavelength(4, 3)
```
