4.4. Functions#

4.4.1. What are (Python) Functions?#

In this class, we will often talk about functions.

So what is a function?

We like to think of a function as a production line in a manufacturing plant: we pass zero or more things to it, operations take place in a set linear sequence, and zero or more things come out.

We use functions for the following purposes:

  • Re-usability: Writing code to do a specific task just once, and reuse the code by calling the function.

  • Organization: Keep the code for distinct operations separated and organized.

  • Sharing/collaboration: Sharing code across multiple projects or sharing pieces of code with collaborators.

4.4.2. How to Define (Python) Functions?#

The basic syntax to create our own function is as follows:

def function_name(inputs):
    # step 1
    # step 2
    # ...
    return outputs

Here we see two new keywords: def and return.

  • def is used to tell Python we would like to define a new function.

  • return is used to tell Python what we would like to return from a function.

Let’s look at an example and then discuss each part:

def mean(numbers):
    total = sum(numbers)
    N = len(numbers)
    answer = total / N
    #print(answer)
    return answer

Here we defined a function mean that has one input (numbers), does three steps, and has one output (answer).

Let’s see what happens when we call this function on the list of numbers [1, 2, 3, 4].

numbers=[1,2,3,4,5,6]
del numbers
x = [1, 2, 3]
the_mean = mean(x)
print(the_mean)
2.0

Additionally, as we saw in the control flow lecture, indentation controls blocks of code (along with the scope rules).

To see this, compare a function with no inputs or return values.

def f():
    print("1")
    print("2")
f()
1
2

With the following change of indentation…

def f():
    print("1")
print("2")
f()
2
1

4.4.2.1. Scope#

Notice that we named the input to the function x and we called the output the_mean.

When we defined the function, the input was called numbers and the output answer… what gives?

This is an example of a programming concept called variable scope.

In Python, functions define their own scope for variables.

In English, this means that regardless of what name we give an input variable (x in this example), the input will always be referred to as numbers inside the body of the mean function.

It also means that although we called the output answer inside of the function mean, that this variable name was only valid inside of our function.

To use the output of the function, we had to give it our own name (the_mean in this example).

Another point to make here is that the intermediate variables we defined inside mean (total and N) are only defined inside of the mean function – we can’t access them from outside. We can verify this by trying to see what the value of total is:

def mean(numbers):
    total = sum(numbers)
    N = len(numbers)
    answer = total / N
    return answer # or directly return total / N

# uncomment the line below and execute to see the error
# total

This point can be taken even further: the same name can be bound to variables inside of blocks of code and in the outer “scope”.

x = 4
print(x)
def f():
    # this function does this and that
    x = 5 # a different "x"
    print(x)
    
f() # calls function
print(x)
4
5
4

The final point we want to make about scope is that function inputs and output don’t have to be given a name outside the function.

mean([10, 20, 30])
20.0

4.4.2.2. Documentation#

To provide help information, we need to add what Python programmers call a “docstring” to our functions.

This is done by putting a string (not assigned to any variable name) as the first line of the body of the function (after the line with def).

Below is a new version of the template we used to define functions.

def function_name(inputs):
    """
    Docstring
    """
    # step 1
    # step 2
    # ...
    return outputs

Let’s re-define our portfolio_returns function to include a docstring.

def portfolio_returns(w, r):
    """
    Computes the returns of a portfolio that invests w on the asset with return r and (1-w) in the risk-free rate

    Takes the form F(w, r,rf) = w*r+(1-w)rf, here we fix the rf to 0.01

    """
    return w*r + (1-w)*0.01

Now when we have Jupyter evaluate portfolio_returns?, our message is displayed (or use the Contextual Help window with Jupyterlab and Ctrl-I or Cmd-I).

portfolio_returns?
Signature: portfolio_returns(w, r)
Docstring:
Computes the returns of a portfolio that invests w on the asset with return r and (1-w) in the risk-free rate

Takes the form F(w, r,rf) = w*r+(1-w)rf, here we fix the rf to 0.01
File:      c:\users\alan.moreira\documents\github\textbook\chapters\essentials\<ipython-input-8-4efdb59d826b>
Type:      function

We recommend that you always include at least a very simple docstring for nontrivial functions.

This is in the same spirit as adding comments to your code — it makes it easier for future readers/users (including yourself) to understand what the code does.

Evaluate the function

portfolio_returns(0.5, 0.05)
0.030000000000000002

4.4.2.3. Default and Keyword Arguments#

Functions can have optional arguments.

To accomplish this, we must these arguments a default value by saying name=default_value instead of just name as we list the arguments.

To demonstrate this functionality, let’s now make \(rf \) arguments to our portfolio_return function!

def portfolio_returns(w, r,rf=0.01):
    """
    Computes the returns of a portfolio that invests w on the asset with return r and (1-w) in the risk-free rate

    Takes the form F(w, r,rf) = w*r+(1-w)rf

    """
    return w*r + (1-w)*rf

We can now call this function by passing in just w and r. Notice that it will produce same result as earlier because rf are the same as earlier.

portfolio_returns(0.5, 0.05)
0.030000000000000002

However, we can also set the other arguments of the function by passing more than just w and r.

portfolio_returns(0.5, 0.05,0.03)
0.04

In the example above, we used w= 0.5, r= 0.05, and rf=0.03

We can also refer to function arguments by their name, instead of only their position (order).

To do this, we would write func_name(arg=value) for as many of the arguments as we want.

Here’s how to do that with our portfolio_returns example.

portfolio_returns(0.5, 0.05,rf=0.02)
0.035

In terms of variable scope, the z name within the function is different from any other z in the outer scope.

To be clear,

x = 5
def f(x):
    return x
f(x) # "coincidence" that it has the same name
5

This is also true with named function arguments, above.

rf = 0.03
portfolio_returns(0.5, 0.05,rf=rf)# no problem!
0.04

In that example, the rf on the left hand side of rf = rf refers to the local variable name in the function whereas the rf on the right hand side refers to the rf in the outer scope.

This is an appealing feature of functions for avoiding coding errors: names of variables within the function are localized and won’t clash with those on the outside.

Importantly, when Python looks for variables matching a particular name, it begins in the most local scope.

That is, note that having an rf in the outer scope does not impact the local one.

print(portfolio_returns(0.5, 0.05,rf=0.03))
rf = 0.02
portfolio_returns(0.5, 0.05,0.03)# no problem!
0.04
0.04

A crucial element of the above function is that the rf variable was available in the local scope of the function.

Consider the alternative where it is not. We have removed the rf function parameter as well as the local definition of rf.

def portfolio_returns(w, r):
    """
    Computes the returns of a portfolio that invests w on the asset with return r and (1-w) in the risk-free rate
    net of fees fee

    Takes the form F(w, r,rf) = w*r+(1-w)rf-fee
     I set fees to zero
    """
    fee=0
    return w*r + (1-w)*rf-fee

rf = 0.02 # in the outer scope
print(f"rf = {rf} gives {portfolio_returns(0.5, 0.05)}")
rf = 0.03
print(f"rf = {rf} gives {portfolio_returns(0.5, 0.05)}")
rf = 0.02 gives 0.035
rf = 0.03 gives 0.04

The intuition of scoping does not apply only for the “global” vs. “function” naming of variables, but also for nesting.

For example, we can define a version of portfolio_returns which is also missing a fee in its inner-most scope, then put the function inside of another function.

rf = 0.01
def return_given_fee(fee):
    # Scoping logic:
    # 1. local function name doesn't clash with global one
    # 2. fee comes from the function parameter
    # 3. rf comes from the outer global scope
    def portfolio_returns(w, r):
        return w*r + (1-w)*rf-fee

    # using this function
    return portfolio_returns(0.5, 0.05)

fee = 100 # this will not matter
fees = [0.02, 0.03, 0.05]
# comprehension variables also have local scope
# and don't clash with the alpha = 100
[return_given_fee(fee) for fee in fees]
[0.010000000000000002, 3.469446951953614e-18, -0.02]

4.4.2.4. Methods#

As we learned earlier, all variables in Python have a type associated with them.

Different types of variables have different functions or operations defined for them.

For example, I can divide one number by another or make a string uppercase.

It wouldn’t make sense to divide one string by another or make a number uppercase.

When certain functionality is closely tied to the type of an object, it is often implemented as a special kind of function known as a method.

For now, you only need to know two things about methods:

  1. We call them by doing variable.method_name(other_arguments) instead of function_name(variable, other_arguments).

  2. A method is a function, even though we call it using a different notation.

When we introduced the core data types, we saw many methods defined on these types.

Let’s revisit them for the str, or string type.

Notice that we call each of these functions using the dot syntax described above.

s = "This is my handy string!"
s.upper()
'THIS IS MY HANDY STRING!'
s.title()
'This Is My Handy String!'