Classes and Functions#

This Jupyter Notebook is based on Chapter 16 of [Dow15].

(c) 2020, Mark van den Brand, Eindhoven University of Technology

The next step is to develop functions that manipulate the programmer-defined types. We will see how these types can be passed as arguments to functions and be returned as results.

In this chapter a functional programming style will be used.

Time#

We will start with introducing another programmer-defined type: the class Time.

class Time:
    """Represents the time of day.
    
    attributes: hour, minute, second
    """

We can now create Time objects and assign values to the attributes: hours, minutes, and seconds.

time : Time = Time()
time.hour : int = 11
time.minute : int = 59
time.second : int = 33

In the next cell, a new way of formatting strings is shown. See https://pyformat.info for more information.

def print_time(time : Time) -> None:
    """ prints a Time object
    """
    print('({:02d}:{:02d}:{:02d})'.format(time.hour,time.minute,time.second))

print_time(time)
(11:59:33)
def is_after(time1 : Time, time2 : Time) -> bool:
    """ gets 2 Time objects as argument and 
        returns a boolean to indicate whether time1 is greater than time2
    """
    return time1.hour > time2.hour or time1.minute > time2.minute or time1.second > time2.second

new_time : Time = Time()
new_time.hour : int = 11
new_time.minute : int = 59
new_time.second : int = 31

is_after(time,new_time)
True
# Remove this line and add your code here

Pure Functions#

We will develop functions that allows us to manipulate Time objects: pure functions and modifiers.

The strategy for developing these functions is based on prototype and patch; a way of tackling a complex problem by starting with a simple prototype and incrementally dealing with the complications.

The next cell shows a simple prototype of the add_time function.

def add_time(t1 : Time, t2 : Time) -> Time:
    """ returns a new Time object containing the sum of the 2 argument Time objects
    """
    sum : Time = Time()
    sum.hour : int = t1.hour + t2.hour
    sum.minute : int = t1.minute + t2.minute
    sum.second : int = t1.second + t2.second
    return sum

This function creates a new Time object (sum), its attributes are initialized by adding the values of the attributes of the arguments, and eventually returns the created object.

This is called a pure function because it does not have any side effects:

  • it does not modify any of the objects passed to it as arguments and

  • it has no effect, like displaying a value or getting user input, other than returning a value.

The function of the previous cell can be tested by creating and adding 2 Time objects.

start : Time = Time()
start.hour : int = 21
start.minute : int = 45
start.second : int = 0

duration : Time = Time()
duration.hour : int = 1
duration.minute : int = 35
duration.second : int = 0

finished = add_time(start, duration)
print_time(finished)
(22:80:00)

This is of course an illegal time.

The problem is caused by the fact that we did not keep track of the fact that there are only 60 seconds in a minute, 60 minutes in an hour, and 24 hours in a day, or do we allow more hours in a Time object?

What were the exact requirements?

Let us assume that there are only 24 hours in a day.

So, we are representing the time of the day, not a time frame.

We need to carry over seconds to minutes and minutes to hours.

def add_time(t1 : Time, t2 : Time) -> Time:
    """ returns a new Time object containing the sum of the 2 argument Time objects
    """
    sum : Time = Time()
    sum.hour : int = t1.hour + t2.hour
    sum.minute : int = t1.minute + t2.minute
    sum.second : int = t1.second + t2.second

# fix the seconds if greater than 60
    if sum.second >= 60:
        sum.second -= 60
        sum.minute += 1

# fix the minutes if greater than 60
    if sum.minute >= 60:
        sum.minute -= 60
        sum.hour += 1
        
# fix with hours if greater than 24
    if sum.hour >= 24:
        sum.hour -= 24
        
    return sum

This function does the job, this solution is slightly more elaborated.

The function is quite long and a more concise solution will be presented.

# Remove this line and add your code here

Modifiers#

It may be useful for a function to modify the objects it gets as parameters.

In that case, the changes are visible to the caller.

Functions that work this way are called modifiers.

In the next cell, a first version of the function increment is presented.

def increment2(time : Time, seconds : int) -> None:
    """ adds the seconds (2nd argument) to the Time object
    """
    time.second += seconds
    
# fix the seconds if greater than 60
    while time.second >= 60:
        time.second -= 60
        time.minute += 1
        
# fix the minutes if greater than 60
    while time.minute >= 60:
        time.minute -= 60
        time.hour += 1

# fix with hours if greater than 24
    while time.hour >= 24:
        time.hour -= 24 

The function starts with adding the seconds to time.second; the remainder deals with the special cases we saw before.

This function is not correct, when the amount of seconds to be added is larger than 60.

It is then necessary to add more minutes to time.minutes than just 1.

A straightforward solution is to use a while loop, but this may not be very efficient.

Anything that can be done with modifiers can also be done with pure functions.

In fact, some programming languages, also known as functional programming languages, only allow pure functions.

There is some evidence that programs that use pure functions are faster to develop and less error-prone than programs that use modifiers.

But modifiers are convenient at times, and functional programs tend to be less efficient.

It is, therefore, good practice to develop pure functions instead of modifiers.

def increment(time : Time, seconds : int) -> None:
    """ adds the seconds (2nd argument) to the Time object
    """
    (hours, seconds) = divmod(seconds, 3600)
    time.hour += hours
    
    (minutes, seconds) = divmod(seconds, 60)
    time.minute += minutes
    
    time.second += seconds
    
# fix the seconds if greater than 60
    if time.second >= 60:
        time.second -= 60
        time.minute = 1

# fix the minutes if greater than 60
    while time.minute >= 60:
        time.minute -= 60
        time.hour += 1

# fix with hours if greater than 24
    while time.hour >= 24:
        time.hour -= 24 
        
print_time(start)
increment(start, 3660)
print_time(start)
(21:45:00)
(22:46:00)
# Remove this line and add your code here

Prototyping vs. Planning#

The development strategy used so far in this chapter has been prototype and patch.

The problem with this approach is that the code becomes bulky, it may contain code to deal with a lot of corner cases.

This approach can be effective, especially if you do not have yet a deep understanding of the problem, but it may involve a lot of testing.

An alternative is designed development, in which high-level insight into the problem can make the programming much easier.

For instance, to realize that the conversion from time to seconds and back allows us just to manipulate time in terms of the amount of seconds; 1 minute is 60 seconds and 1 hour is 3600 seconds.

So, if we convert the Time object to an integer value representing seconds and back, we are done.

def time_to_int(time : Time) -> int:
    """ converts a Time object into an integer value representing seconds
    """
    minutes : int = 60 * time.hour + time.minute
    seconds : int = 60 * minutes + time.second
    return seconds

print_time(start)
print(time_to_int(start))
(22:46:00)
81960

We need now to develop the inverse function: int_to_time.

This function takes an integer value as argument and returns a Time object.

We will use a special data type: tuples. We will dive deeper into this type in coming topics, for now let us just see how to use them.

def int_to_time(seconds : int) -> Time:
    """ converts an integer value representing seconds into a Time object
    """
    time : Time = Time()
    minutes, time.second = divmod(seconds, 60)
    time.hour, time.minute = divmod(minutes, 60)
    
# fix with hours if greater than 24
    if time.hour >= 24:
        time.hour -= 24
        
    return time

One way to check that both functions are correct is by means of using a consistency check.

In this case, we will verify with the expression time_to_int(int_to_time(x)) == x.

x = 86300
time_to_int(int_to_time(x)) == x
True

However, the solutions presented in the book “Think Python” do not deal with the fact that there are only 24 hours in a day.

Thus, the proposed test, time_to_int(int_to_time(x)) == x does not work for values equal or greater than 86400.

This is a typical case of getting the right requirements.

print_time(int_to_time(86399+1))
(00:00:00)
time_to_int(int_to_time(86399+1)) == 86400
False

Once we are convinced that both functions are correct (according to our specifications), we can reimplement the add_time and increment functions.

def add_time(t1 : Time, t2 : Time) -> Time:
    """ returns a new Time object containing the sum of the 2 argument Time objects
    """
    seconds : int = time_to_int(t1) + time_to_int(t2)
    return int_to_time(seconds)
def increment(t1 : Time, s : int) -> Time:
    """ returns a new Time object containing the sum of the 2 argument Time objects
    """
    seconds : int = time_to_int(t1) + s
    return int_to_time(seconds)
# Remove this line and add your code here

Testing#

Whenever you have defined a variable or a function, verify it, before using it. Do not rely on them blindly.

  • Inspect the value of the variable

  • Apply the function to some arguments, and inspect the results

Advice: Keep such test code in your notebook, so that you can rerun it and use it as a kind of documentation.

Testing of functions#

Testing is a way of convincing yourself that the implementation of the function is correct. Testing is not equivalent to giving a formal proof.

In order to be able to test, you should first execute the function yourself in your head or on paper. Suppose you have written a function to sort elements of a list, then you could start with a non-empty list of unsorted elements, and do the sorting of the list of elements yourself resulting in a sorted list of elements. This already shows that you should start with manageable input. You should not start with a list with more than 100 elements.

  • Choose appropriate arguments

  • Apply function and gather results

  • Decide on pass or fail

Manual testing#

A simple but rather labor-intensive way of testing is manual testing. You write in one cell the function body and in a few following cells, the various tests for the function.

When testing you have to think of corner cases, for instance, empty lists, the elements at the first or last index of a list.

  • Call the function for various arguments in a code cell, such that it shows some result (either via print or as last expression in code cell).

sorted_list = sort([4, 3, 9, 1])
print(sorted_list) # This should give [1, 3, 4, 9]
  • Visually, check those results.

from typing import List
import random

def roll_dice(n: int) -> List[int]:
    """Roll n dice.
    Assumption: n >= 0.
    """
    
    rolls : List[int] = list()
    
    for _ in range(n):
        rolls.append(random.randint(1, 6))
    
    return rolls
#Test 1: boundary case
roll_dice(100)
[3,
 6,
 4,
 5,
 6,
 5,
 5,
 3,
 3,
 5,
 5,
 1,
 6,
 5,
 3,
 3,
 3,
 3,
 2,
 5,
 3,
 2,
 2,
 3,
 1,
 2,
 5,
 5,
 5,
 1,
 2,
 5,
 6,
 3,
 6,
 6,
 2,
 4,
 2,
 1,
 5,
 5,
 3,
 3,
 6,
 5,
 6,
 6,
 5,
 6,
 6,
 6,
 5,
 1,
 4,
 1,
 5,
 1,
 1,
 2,
 3,
 5,
 1,
 1,
 4,
 4,
 6,
 2,
 2,
 2,
 2,
 2,
 1,
 1,
 4,
 4,
 1,
 1,
 4,
 6,
 4,
 3,
 5,
 6,
 6,
 6,
 4,
 6,
 2,
 6,
 3,
 5,
 5,
 2,
 3,
 4,
 3,
 6,
 5,
 1]
#Test 2: test the length of the resulting list
len(roll_dice(3))
3
#Test 3: test whether all rolls are valid
rolls_bool : List[bool] = list()

for roll in roll_dice(10):
    rolls_bool.append(roll in range(1, 6 + 1)) # list of Booleans

print(rolls_bool)
all(rolls_bool) # check if all Booleans in the list are True
[True, True, True, True, True, True, True, True, True, True]
True

More on function testing#

The challenge with function testing is to convince yourself that you have dealt with all possible cases that the function needs to handle.

  • Testing a function in just one call is hardly ever enough.

  • Pick a few important arguments, for which you can check the corresponding result.

  • Boundary cases, and small typical case

    • Strive for code coverage

    • Code that is not executed during the call, is not tested

    • Cover all branches of if-elif-else

  • You do not need to check the result directly; could test it indirectly. For instance, by checking the number of elements in a list instead of inspecting the list.

Suppose you need to write a function that merges 2 lists. A sufficient test can be to check whether the length of the resulting list is the same as the length of both argument lists.

Automated testing via docstring#

A good programming practice is to add usage examples to the docstring:

  • You can do it in such a format that these examples are automatically executable and checkable

Format of examples/test cases in docstring:

>>> expression with function call
expected result
...
...
>>> expression with function call
expected result
from typing import List
import random

def roll_dice(n: int) -> List[int]:
    """Roll n dice.
    
    Assumption: n >= 0.
    
    Examples and test cases:
    >>> roll_dice(0)  # boundary case
    []
    >>> len(roll_dice(3))  # test length
    3
    >>> all(roll in range(1, 6 + 1) for roll in roll_dice(10))  # test values
    True
    """
    
    rolls : List[int] = list()
    
    for _ in range(n):
        rolls.append(random.randint(1, 6))
    
    return rolls

How to run test cases for function with docstring tests:

  • import doctest

  • doctest.run_docstring_examples(func, globals(), verbose=True, name='...')
    runs all test cases of func, reporting details

  • doctest.run_docstring_examples(func, globals(), verbose=False)
    runs all test cases, only reporting failures

import doctest
doctest.run_docstring_examples(roll_dice, globals(), verbose=True, name='roll_dice')
Finding tests in roll_dice
Trying:
    roll_dice(0)  # boundary case
Expecting:
    []
ok
Trying:
    len(roll_dice(3))  # test length
Expecting:
    3
ok
Trying:
    all(roll in range(1, 6 + 1) for roll in roll_dice(10))  # test values
Expecting:
    True
ok
doctest.run_docstring_examples(roll_dice, globals(), verbose=False, name='roll_dice')

One more example of tests in docstring:

from typing import Dict
from collections import defaultdict

def count_text(text: str) -> Dict[str, int]:
    """Return dictionary with count for each letter in text.
    
    >>> count_text("")  # boundary case
    {}
    >>> count_text("dad")
    {'d': 2, 'a': 1}
    """
    counts : dict = defaultdict(int)  # use 0 when key is not present
    
    for letter in text:
        counts[letter] += 1

    return dict(counts)
count_text("dad")
{'d': 2, 'a': 1}
doctest.run_docstring_examples(count_text, globals(), verbose=True, name='count_text')
Finding tests in count_text
Trying:
    count_text("")  # boundary case
Expecting:
    {}
ok
Trying:
    count_text("dad")
Expecting:
    {'d': 2, 'a': 1}
ok

How to run test cases for all functions with docstring tests:

  • doctest.testmod(verbose=True) runs all test cases, reporting details

  • doctest.testmod(verbose=False) runs all test cases, showing a summary

doctest.testmod(verbose=True)  # with details
Trying:
    count_text("")  # boundary case
Expecting:
    {}
ok
Trying:
    count_text("dad")
Expecting:
    {'d': 2, 'a': 1}
ok
Trying:
    roll_dice(0)  # boundary case
Expecting:
    []
ok
Trying:
    len(roll_dice(3))  # test length
Expecting:
    3
ok
Trying:
    all(roll in range(1, 6 + 1) for roll in roll_dice(10))  # test values
Expecting:
    True
ok
9 items had no tests:
    __main__
    __main__.Time
    __main__.add_time
    __main__.increment
    __main__.increment2
    __main__.int_to_time
    __main__.is_after
    __main__.print_time
    __main__.time_to_int
2 items passed all tests:
   2 tests in __main__.count_text
   3 tests in __main__.roll_dice
5 tests in 11 items.
5 passed and 0 failed.
Test passed.
TestResults(failed=0, attempted=5)
doctest.testmod(verbose=False)  # without details
TestResults(failed=0, attempted=5)

Invariants#

A Time object is well-formed if the values of minute and second are between 0 and 60 (including 0 but not 60) and if hour is between 0 and 24.

Requirements like these are called invariants because they should always be true.

To put it a different way, if they are not true, something has gone wrong.

Writing code to check invariants can help detect errors and find their causes.

The next cell contains the function valid_time(time : Time) that takes a Time object and returns False if it violates an invariant.

def valid_time(time : Time) -> bool:
    """ checks whether we are dealing with a correct Time object
    """
    if time.hour < 0 or time.minute < 0 or time.second < 0:
        return False
    if time.hour >= 24 or time.minute >= 60 or time.second >= 60:
        return False
    return True

At the beginning of each function you could check the arguments to make sure they are valid.

def add_time(t1 : Time, t2 : Time) -> Time:
    """ returns a new Time object containing the sum of the 2 argument Time objects
    """
    
    if not valid_time(t1) or not valid_time(t2):
        raise ValueError('invalid Time object in add_time')
    seconds : int = time_to_int(t1) + time_to_int(t2)
    
    return int_to_time(seconds)

Or you could use an assert statement, which checks a given invariant and raises an exception if it fails.

def add_time(t1 : Time, t2 : Time) -> Time:
    """ returns a new Time object containing the sum of the 2 argument Time objects
    """
    
    assert valid_time(t1) and valid_time(t2)
    seconds : int = time_to_int(t1) + time_to_int(t2)
    
    return int_to_time(seconds)

Assert statements are useful because they distinguish code that deals with normal conditions from code that checks for errors.