# Define helper function
from pytest import approx
def error_message(actual, expected):
    return f'Actual {actual} != Expected {expected}'

Lecture 3 - Functions#

Today, we are going to practice functions.

def function_name(<arguments>):
    docstring
    <codeblock>
    return <value>

Remember:

  • A docstring just below the definition line. There are several formats, e.g., Google’s format or

  • The colon as last character on the definition line

  • Indentation to indicate the code block belonging to that function

  • The function often has a return statement but not always, e.g., if you have a function that only prints

A word on docstrings

To get Visual Studio Code to help autogenerate a docstring template, you can install the extension autoDocstring-python Docstring generator. https://marketplace.visualstudio.com/items?itemName=njpwerner.autodocstring

Keyboard shortcut to get the docstring template:

  • Mac: cmd+shift+2

  • Windows/Linux: ctrl+shift+2

Remember

to fill in type hinting before (see type hinting below) or you will have to manually set the data types.

In Google Docstring format, this is

"""Calculates the sum of two numbers.

    sum = a + b

    Args:
        a (float): description of argument variable a
        b (int): description of argument variable b

    Returns:
        float: sum of a and b
"""

Type hinting (Since Python 3.5)

Python is dynamically typed and does not use explicit declaration of variable types.

This means that you can do things like

name = "Nanna"
name = 6

In other words, the same variable (here name) can be used for different data types in the same piece of code. This is again possible due to the way memory is managed in Python. While this gives a lot of flexibilit, it can also be a potential source of issues.

In an attempt to alleviate this, one can use type hinting.

  • Type hinting indicates what data type is expected for a given variable

  • However, it does not do more than it says – it ONLY provides a type hint. It does neither check nor enforce any type!!!

In the example above, type hinting would look like the following

def sum(a: float, b: float)->float:
    docstring
    <codeblock>
    return <value>
# Write a function named 'sum' that takes two numbers and returns their sum
# Remember to include a docstring

def sum(a: float, b: float):
    return a + b
assert sum(4.,5.) == 9, error_message(sum(4.,5.), 9)
# Write a function that 'mymax' that accepts two numbers as parameters and that prints to the screen and returns the larger number. 
# For this elementary exercise, do not use the built-in function 'max()' - just revert to elementary expressions and statements.


def mymax(a: float, b: float)->float:
    """Returns the largest value of a and b

    Args:
        a (float): number to be compared with b
        b (float): number to be compared with a

    Returns:
        float: the largest value of a and b
    """
    if a > b:
        return a
    else:
        return b
assert mymax(2, 6) == 6, f'mymax(2, 6) -> {mymax(2, 6)} != 6'
assert mymax(5, 3) == 5, f'mymax(5, 3) -> {mymax(5, 3)} != 5'
# Write a function is_odd that takes a positive integer 'i' as input and return True if 'i' is odd, otherwise False

def is_odd(i: int)->bool:
    """Returns True if integer is odd

    Args:
        i (int): integer to check for whether odd

    Returns:
        bool: True if i is odd, otherwise False
    """    
    remainder = i % 2
    return remainder != 0
assert is_odd(3) == True, error_message(is_odd(3), False) 
# Now write the same function without a return statement. Let it just make a print statement print('without return')

def is_odd_without_return(i: int):
    """
    Args:
        i (int): a positive integer
    """
    remainder = i % 2
    print('without return')
    print(remainder != 0)
assert is_odd_without_return(1) == None, error_message(is_odd_without_return(1), None)
without return
True
# Use the 'is_odd' function together with the built-in 'range' function to loop over the numbers from 1 to 20 and print whether they are even or odd.
# In other words, use the is_odd function to avoid code duplication. Functions are great!


for i in range(1, 21):
    if is_odd(i):
        print('The integer i is odd:  ', i)
    else:
        print('The integer i is even: ', i)
The integer i is odd:   1
The integer i is even:  2
The integer i is odd:   3
The integer i is even:  4
The integer i is odd:   5
The integer i is even:  6
The integer i is odd:   7
The integer i is even:  8
The integer i is odd:   9
The integer i is even:  10
The integer i is odd:   11
The integer i is even:  12
The integer i is odd:   13
The integer i is even:  14
The integer i is odd:   15
The integer i is even:  16
The integer i is odd:   17
The integer i is even:  18
The integer i is odd:   19
The integer i is even:  20
# Write a function 'mysum' to sum the numbers in a list using a for loop. You can assume that it consists of numbers. Do not use the built-in 'sum' function.
# Call the function with the test_list_float list as argument and print the result

test_list_float = [1.,5.,7.,3.,9.,6.,2.,8.,76.]


def mysum(listofnumbers: list[float])->float:
    """Calculates the sum of the numbers in a list

    Args:
        listofnumbers (list[float]): list of numbers (should be either floats or int)

    Returns:
        float: the sum of the numbers 
    """    
    sum = 0.0
    for number in listofnumbers:
        sum += number
    return sum

print(mysum(test_list_float))
117.0
assert mysum(test_list_float) == float(117), error_message(mysum(test_list_float), float(117))

Using help() to get documentation#

# Having written the mysum function, try to print its documentation using 'help(object)'
# You should use this every time you want to learn more about anything in Python

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

mysum(listofnumbers: list) -> float
    Calculates the sum of the numbers in a list
    
    Args:
        listofnumbers (list[float]): list of numbers (should be either floats or int)
    
    Returns:
        float: the sum of the numbers
# Here applied on 'str'

help(float)
Help on class float in module builtins:

class float(object)
 |  float(x=0, /)
 |  
 |  Convert a string or number to a floating point number, if possible.
 |  
 |  Methods defined here:
 |  
 |  __abs__(self, /)
 |      abs(self)
 |  
 |  __add__(self, value, /)
 |      Return self+value.
 |  
 |  __bool__(self, /)
 |      self != 0
 |  
 |  __ceil__(self, /)
 |      Return the ceiling as an Integral.
 |  
 |  __divmod__(self, value, /)
 |      Return divmod(self, value).
 |  
 |  __eq__(self, value, /)
 |      Return self==value.
 |  
 |  __float__(self, /)
 |      float(self)
 |  
 |  __floor__(self, /)
 |      Return the floor as an Integral.
 |  
 |  __floordiv__(self, value, /)
 |      Return self//value.
 |  
 |  __format__(self, format_spec, /)
 |      Formats the float according to format_spec.
 |  
 |  __ge__(self, value, /)
 |      Return self>=value.
 |  
 |  __getattribute__(self, name, /)
 |      Return getattr(self, name).
 |  
 |  __getnewargs__(self, /)
 |  
 |  __gt__(self, value, /)
 |      Return self>value.
 |  
 |  __hash__(self, /)
 |      Return hash(self).
 |  
 |  __int__(self, /)
 |      int(self)
 |  
 |  __le__(self, value, /)
 |      Return self<=value.
 |  
 |  __lt__(self, value, /)
 |      Return self<value.
 |  
 |  __mod__(self, value, /)
 |      Return self%value.
 |  
 |  __mul__(self, value, /)
 |      Return self*value.
 |  
 |  __ne__(self, value, /)
 |      Return self!=value.
 |  
 |  __neg__(self, /)
 |      -self
 |  
 |  __pos__(self, /)
 |      +self
 |  
 |  __pow__(self, value, mod=None, /)
 |      Return pow(self, value, mod).
 |  
 |  __radd__(self, value, /)
 |      Return value+self.
 |  
 |  __rdivmod__(self, value, /)
 |      Return divmod(value, self).
 |  
 |  __repr__(self, /)
 |      Return repr(self).
 |  
 |  __rfloordiv__(self, value, /)
 |      Return value//self.
 |  
 |  __rmod__(self, value, /)
 |      Return value%self.
 |  
 |  __rmul__(self, value, /)
 |      Return value*self.
 |  
 |  __round__(self, ndigits=None, /)
 |      Return the Integral closest to x, rounding half toward even.
 |      
 |      When an argument is passed, work like built-in round(x, ndigits).
 |  
 |  __rpow__(self, value, mod=None, /)
 |      Return pow(value, self, mod).
 |  
 |  __rsub__(self, value, /)
 |      Return value-self.
 |  
 |  __rtruediv__(self, value, /)
 |      Return value/self.
 |  
 |  __sub__(self, value, /)
 |      Return self-value.
 |  
 |  __truediv__(self, value, /)
 |      Return self/value.
 |  
 |  __trunc__(self, /)
 |      Return the Integral closest to x between 0 and x.
 |  
 |  as_integer_ratio(self, /)
 |      Return integer ratio.
 |      
 |      Return a pair of integers, whose ratio is exactly equal to the original float
 |      and with a positive denominator.
 |      
 |      Raise OverflowError on infinities and a ValueError on NaNs.
 |      
 |      >>> (10.0).as_integer_ratio()
 |      (10, 1)
 |      >>> (0.0).as_integer_ratio()
 |      (0, 1)
 |      >>> (-.25).as_integer_ratio()
 |      (-1, 4)
 |  
 |  conjugate(self, /)
 |      Return self, the complex conjugate of any float.
 |  
 |  hex(self, /)
 |      Return a hexadecimal representation of a floating-point number.
 |      
 |      >>> (-0.1).hex()
 |      '-0x1.999999999999ap-4'
 |      >>> 3.14159.hex()
 |      '0x1.921f9f01b866ep+1'
 |  
 |  is_integer(self, /)
 |      Return True if the float is an integer.
 |  
 |  ----------------------------------------------------------------------
 |  Class methods defined here:
 |  
 |  __getformat__(typestr, /) from builtins.type
 |      You probably don't want to use this function.
 |      
 |        typestr
 |          Must be 'double' or 'float'.
 |      
 |      It exists mainly to be used in Python's test suite.
 |      
 |      This function returns whichever of 'unknown', 'IEEE, big-endian' or 'IEEE,
 |      little-endian' best describes the format of floating point numbers used by the
 |      C type named by typestr.
 |  
 |  __set_format__(typestr, fmt, /) from builtins.type
 |      You probably don't want to use this function.
 |      
 |        typestr
 |          Must be 'double' or 'float'.
 |        fmt
 |          Must be one of 'unknown', 'IEEE, big-endian' or 'IEEE, little-endian',
 |          and in addition can only be one of the latter two if it appears to
 |          match the underlying C reality.
 |      
 |      It exists mainly to be used in Python's test suite.
 |      
 |      Override the automatic determination of C-level floating point type.
 |      This affects how floats are converted to and from binary strings.
 |  
 |  fromhex(string, /) from builtins.type
 |      Create a floating-point number from a hexadecimal string.
 |      
 |      >>> float.fromhex('0x1.ffffp10')
 |      2047.984375
 |      >>> float.fromhex('-0x1p-1074')
 |      -5e-324
 |  
 |  ----------------------------------------------------------------------
 |  Static methods defined here:
 |  
 |  __new__(*args, **kwargs) from builtins.type
 |      Create and return a new object.  See help(type) for accurate signature.
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |  
 |  imag
 |      the imaginary part of a complex number
 |  
 |  real
 |      the real part of a complex number

Scope#

A variable is only available from inside the region it is created. This is called scope.

  • Global scope: A variable created in the main body of the Python code is a global variable and belongs to the global scope.

  • Local scope: A variable created inside a function belongs to the local scope of that function and can only be used inside that function.

    • You can change the scope of a local variable with the global keyword inside the function. You will see an example with Fibonacci numbers below.

The above means that if you operate with the same variable name inside and outside of a function, Python will treat them as two separate variables:

  • one available in the global scope (outside the function)

  • one available in the local scope (inside the function)

# Here is an example where x is used in both the local scope of a function and in the global scope outside the function.
# As described above, Python will treat these as two separate variables by their different values when printing them.

def add_one(x: int):
    """Adds one to the input argument x and print its value

    Args:
        x (int): input number
    """    
    x += 1
    print('Value of x inside function: ', x)

x = 5
print('Value of x outside function before function call: ', x)
add_one(x)
print('Value of x outside function after function call: ', x)
Value of x outside function before function call:  5
Value of x inside function:  6
Value of x outside function after function call:  5
# The same function as before but now it returns the value. If the output is assign to a different variable, then x outside the function will remain the same as before the function call.
# If we instead reassign the value of x with the output of the function, i

def add_one_with_return(x: int)->int:
    """Adds one to the input argument x and returns the new value

    Args:
        x (int): input value 

    Returns:
        int: input value with one added
    """    
    x = x + 1
    print('Value of x inside the function: ', x)
    return x

x = 3
print('Value of x outside function before function call: ', x)
z = add_one_with_return(x)
print('Value of x outside function after function call: ', x)
print('Value of z outside the function after function call: ', z)

# Now we instead reassign x to the output of the function
x = add_one_with_return(x)
print('Value of x outside function after function call but now reassigning x to the output of the function: ', x)
Value of x outside function before function call:  3
Value of x inside the function:  4
Value of x outside function after function call:  3
Value of z outside the function after function call:  4
Value of x inside the function:  4
Value of x outside function after function call but now reassigning x to the output of the function:  4
# Now to an example, where we try to access a variable outside scope. 
# Here we not pass in x as argument of the function. Rather x is defined outside the function (global variable), which means that Python can access its value inside the function BUT NOT modify it.
# To modify it, we would have to declare it a global variable inside the function

def show_x():
    """Prints value of x, adds one to it and print it again
    """
    print(x)
    x += 1
    print(x)

# declare global variable
x = 5
show_x()
print(x)
---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
Cell In[17], line 14
     12 # declare global variable
     13 x = 5
---> 14 show_x()
     15 print(x)

Cell In[17], line 8, in show_x()
      5 def show_x():
      6     """Prints value of x, adds one to it and print it again
      7     """
----> 8     print(x)
      9     x += 1
     10     print(x)

UnboundLocalError: local variable 'x' referenced before assignment
def show_x():
    """Prints value of x and print it with one added BUT without reassigning its value (Important!)!!
    """
    print('Value of x inside function:       ', x)
    print('Value of x+1 inside function:     ', x+1)

# declare global variable
x = 5
show_x()
print('Value of x outside the function:  ', x)

Recursion - a function calling itself.#

What is recursion?

  • Algorithmically: a way to design solutions by divide-and-conquer

    • The idea is to reduce a problem to simpler versions of the same problem

  • Semantically: a programming technique where a function calls itself

    • in programming, the goal is to NOT have infinite recursion

    • must have one or more base cases that are easy to solve

    • must solve the same problem on some other input with the goal of simplifying the larger problem input

Fibonacci sequence

The example we will look at an old-time classic - Fibonacci numbers making up the Fibonacci sequence.

This sequence can be written in terms of the following rule:

\( x_n = x_{n-1} + x_{n-2} \)

where

  • \(x_n\) is the current (nth) number in the sequence

  • \(x_{n-1}\) is the previous number in the sequence

  • \(x_{n-2}\) is the second number before the current in the sequence

The sequence has been known for a very long time but has its name after the Italian mathematician Leonardo of Pisa, also known as Fibonacci who introduced it in his book Liber Abaci (The Book of Calculation, 1202).

Leonardo of Pisa used it to calculate the growth in rabbit populations according to the following rules:

  • Start with one pair (one female, one male)

  • One newborn pair of rabbits (one female, one male) are put in a field

  • Rabbits mate at age of one month

  • Rabbits have a one month gestason (pregnancy) period

  • Assume rabbits never die – that a female always produces one new pair (one male, one female) every month from its second month on.

How many females are there at the end of one year? What about after two years?

You task is to fill the table below.

Month

0

1

2

3

4

5

6

7

8

9

10

11

12

24

#Pairs

1

1

?

?

?

?

?

?

?

?

?

?

?

?

#Function calls

1

1

?

?

?

?

?

?

?

?

?

?

?

?

The above rules translate into Fibonacci sequence:

females(n) = females(n-1) + females(n-2)

  • Every female alive at month n-2 will produce one female in month n

  • These can be added those alive in month n-1 to get total alive in month n

Below you will calculate the remaining numbers in the sequence.

Fun facts:

  • When we take any two successive Fibonacci Numbers, their ratio is very close to the Golden Ratio (1.61803398875…). As you might have heard about in some Art class, since some 20th century artists have proportioned their works according to this ratio.

  • If we make squares with dimensions corresponding the Fibonacci numbers, we will get a beautiful spiral. This spiraling pattern is found in nature. Check out:

  • Fibonacci spiral

  • Nature’s example

More on wikipedia:

# Write a function fibonacci and use it recursively to compute the remaining numbers to fill in the table.
# Remember the base cases given in the table, namely: females(0) = 1 and females(1) = 1

def fibonacci(x: int) -> int:
    """Calculate the next Fibonacci number for iteration n
    assuming x >= 0

    Args:
        x (int): iteration x

    Returns:
        int: Fibonacci of x
    """   
    if x == 0 or x == 1:
        return 1
    else:
        return fibonacci(x-1) + fibonacci(x-2)


for i in range(25):
    print(f'Month {i}: {fibonacci(i)}')
assert fibonacci(12) == 233, error_message(fibonacci(12), 233)
# Let's see what this looks like when plotted.
# Use matplotlib.pyplot to plot the number of females over the first two years

import matplotlib.pyplot as plt

females = [fibonacci(i) for i in range(25)]
plt.figure()
plt.plot(range(25), females, color = "red")
plt.xlim([0,25])
plt.ylim([0,max(females)])
plt.xlabel("Months")
plt.ylabel("Number of females")
# Modify your fibonacci function to accumulate the number of calls to the fibonacci function.
# Defining a global variable inside the function comes in handy here. As you learned above, this allows you to change a global variable inside a function.
# Calculate how many fibonacci function calls (variable number_fibonacci_calls) you do to get females(12). Do the same but after 2 years.
# As you will see, the number explodes quickly. In lecture 4, you will learn a more efficient means to do this by storing intermediate values.

def fibonacci(x: int) -> int:
    """Calculate the next Fibonacci number for iteration n
    assuming x >= 0

    Args:
        x (int): iteration x

    Returns:
        int: Fibonacci of x, number of fibonacci calls
    """

    global number_fibonacci_calls
    number_fibonacci_calls += 1
    if x == 0 or x == 1:
        return 1
    else:
        return fibonacci(x-1) + fibonacci(x-2)
    

number_fibonacci_calls = 0
fibonacci(12)
print('Calls to Fibonacci functions to get females(12): ',number_fibonacci_calls)

number_fibonacci_calls = 0
fibonacci(24)
print('Calls to Fibonacci functions to get females(24): ', number_fibonacci_calls)

Another classical demonstration for recursive programming considers palindromes.#

palindrome = a sequence of symbols (such as words) that reads the same forward and backward.

An example is: “A man, a plan, a canal – Panama”

In this case, we will consider recursions on strings instead of numbers.

Write a recursive function ‘is_palindrome’ that takes a string as input and returns True if the string is a palindrome, otherwise False

The components for the recursive calculation:

  • Base cases: string of length 0 or 1 is a palindrome

  • If first character of string matches last character, then it is a palindrome if the middle section is a palindrome

    • string[i] : extract character with index i from string. Index -1 means the last character in the string.

    • string[1:-1] : extract the part of the string from index 1 to the second-to-last index. In other words, we remove a character from the beginning and end of the string.

# Before considering a string, we need to make sure it is always lowercase (or uppercase). There is a built-in method for strings lower() that does this job. We also need punctuation removed.
# I have made a function that takes care of that named clean_string_and_convert_to_lowercase. Use it on the string, so it has been cleaned before checking for palindrome.

def clean_string_and_convert_to_lowercase(string: str)->str:
    """Convert string to lowercase and removes punctuation.

    Args:
        string (str): input string

    Returns:
        str: the cleaned string
    """        
    cleaned_string = ''
    for char in string.lower():
        if char in 'abcdefghijklmnopqrstuvwxyz':
            cleaned_string += char
    return cleaned_string
# Your job now is to complete the is_palindrome function
# It will be handy to recall what you have learned about boolean operators
# Make a print statement inside the function so that you can see the action of your is_palindrome algorithm

def is_palindrome(string: str)->bool:
    """Checks whether a string is a palindrome.
    
    Args:
        string (str): string to be checked

    Returns:
        bool: True if string is palindrome, else False
    """
    print(string)
    if len(string) <= 1:
        return True
    else:
        return string[0] == string[-1] and is_palindrome(string[1:-1])
    
print(is_palindrome(clean_string_and_convert_to_lowercase("Able was I, ere I saw Elba")))
assert is_palindrome(clean_string_and_convert_to_lowercase("A")) == True, error_message(is_palindrome(clean_string_and_convert_to_lowercase("A")), True)
assert is_palindrome(clean_string_and_convert_to_lowercase("A man, a plan, a canal - Panama")) == True, error_message(is_palindrome(clean_string_and_convert_to_lowercase("A man, a plan, a canal - Panama")), True)
assert is_palindrome(clean_string_and_convert_to_lowercase("Able was I, ere I saw Elba")) == True, error_message(is_palindrome(clean_string_and_convert_to_lowercase("Able was I, ere I saw Elba")), True)
assert is_palindrome(clean_string_and_convert_to_lowercase("Is this a palindrome")) == False, error_message(clean_string_and_convert_to_lowercase("Is this a palindrome"), False)