Chapter 14. Classes and Functions

At this point you know how to use functions to organize code and how to use built-in types to organize data. The next step is object-oriented programming (OOP), which uses programmer-defined types to organize both code and data.

Object-oriented programming is a big topic, so we will proceed gradually. In this chapter, we’ll start with code that is not idiomatic—that is, it is not the kind of code experienced programmers write—but it is a good place to start. In the next two chapters, we will use additional features to write more idiomatic code.

Attributes

An object can contain variables, which are called attributes and pronounced with the emphasis on the first syllable, as “AT-trib-ute,” rather than the second syllable, as “a-TRIB-ute.” We can create attributes using dot notation:

lunch.hour = 11
lunch.minute = 59
lunch.second = 1
       

This example creates attributes called hour, minute, and second, which contain the hours, minutes, and seconds of the time 11:59:01, which is lunchtime as far as I am concerned.

The following diagram shows the state of lunch and its attributes after these assignments:

The variable lunch refers to a Time object, which contains three attributes. Each attribute refers to an integer. A state diagram like this—which shows an object and its attributes—is called an object diagram.

You can read the value of an attribute using the dot operator:

lunch.hour
       
11
       

You can use an attribute as part of any expression:

total_minutes = lunch.hour * 60 + lunch.minute
total_minutes
       
719
       

And you can use the dot operator in an expression in an f-string:

f'{lunch.hour}:{lunch.minute}:{lunch.second}'
       
'11:59:1'
       

But notice that the previous example is not in the standard format. To fix it, we have to print the minute and second attributes with a leading zero. We can do that by extending the expressions in curly braces with a format specifier. In the following example, the format specifiers indicate that minute and second should be displayed with at least two digits and a leading zero, if needed:

f'{lunch.hour}:{lunch.minute:02d}:{lunch.second:02d}'
       
'11:59:01'
       

We’ll use this f-string to write a function that displays the value of time objects. You can pass an object as an argument in the usual way. For example, the following function takes a Time object as an argument:

def print_time(time):
    s = f'{time.hour:02d}:{time.minute:02d}:{time.second:02d}'
    print(s)
        

When we call it, we can pass lunch as an argument:

print_time(lunch)
        
11:59:01
        

Prototype and Patch

In the previous example, increment_time and add_time seem to work, but if we try another example, we’ll see that they are not quite correct.

Suppose you arrive at the theater and discover that the movie starts at 9:40, not 9:20. Here’s what happens when we compute the updated end time:

start = make_time(9, 40, 0)
end = add_time(start, 1, 32, 0)
print_time(end)
        
10:72:00
        

The result is not a valid time. The problem is that increment_time does not deal with cases where the number of seconds or minutes adds up to more than 60.

Here’s an improved version that checks whether second exceeds 60—if so, it increments minute—then checks whether minute exceeds 60—if so, it increments hour:

def increment_time(time, hours, minutes, seconds):
    time.hour += hours
    time.minute += minutes
    time.second += seconds

    if time.second >= 60:
        time.second -= 60
        time.minute += 1

    if time.minute >= 60:
        time.minute -= 60
        time.hour += 1
        

Fixing increment_time also fixes add_time, which uses it. So now the previous example works correctly:

end = add_time(start, 1, 32, 0)
print_time(end)
        
11:12:00
        

But this function is still not correct, because the arguments might be bigger than 60. For example, suppose we are given the run time as 92 minutes, rather than 1 hour and 32 minutes. We might call add_time like this:

end = add_time(start, 0, 92, 0)
print_time(end)
        
10:72:00
        

The result is not a valid time. So let’s try a different approach, using the divmod function. We’ll make a copy of start and modify it by incrementing the minute field:

end = copy(start)
end.minute = start.minute + 92
end.minute
        
132
        

Now minute is 132, which is 2 hours and 12 minutes. We can use divmod to divide by 60 and return the number of whole hours and the number of minutes left over:

carry, end.minute = divmod(end.minute, 60)
carry, end.minute
        
(2, 12)
        

Now minute is correct, and we can add the hours to hour:

end.hour += carry
print_time(end)
        
11:12:00
        

The result is a valid time. We can do the same thing with hour and second, and encapsulate the whole process in a function:

def increment_time(time, hours, minutes, seconds):
    time.hour += hours
    time.minute += minutes
    time.second += seconds
            
    carry, time.second = divmod(time.second, 60)
    carry, time.minute = divmod(time.minute + carry, 60)
    carry, time.hour = divmod(time.hour + carry, 60)
        

With this version of increment_time, add_time works correctly, even if the arguments exceed 60:

end = add_time(start, 0, 90, 120)
print_time(end)
        
11:12:00
        

This section demonstrates a program development plan I call prototype and patch. We started with a simple prototype that worked correctly for the first example. Then we tested it with more difficult examples—when we found an error, we modified the program to fix it, like putting a patch on a tire with a puncture.

This approach can be effective, especially if you don’t yet have a deep understanding of the problem. But incremental corrections can generate code that is unnecessarily complicated—since it deals with many special cases—and unreliable, since it is hard to know if you have found all the errors.

Design-First Development

An alternative plan is design-first development, which involves more planning before prototyping. In a design-first process, sometimes a high-level insight into the problem makes the programming much easier.

In this case, the insight is that we can think of a Time object as a 3-digit number in base 60—also known as sexagesimal. The second attribute is the “ones column,” the minute attribute is the “sixties column,” and the hour attribute is the “thirty-six hundreds column.” When we wrote increment_time, we were effectively doing addition in base 60, which is why we had to carry from one column to the next.

This observation suggests another approach to the whole problem—we can convert Time objects to integers and take advantage of the fact that Python knows how to do integer arithmetic.

Here is a function that converts from a Time to an integer:

def time_to_int(time):
    minutes = time.hour * 60 + time.minute
    seconds = minutes * 60 + time.second
    return seconds
        

The result is the number of seconds since the beginning of the day. For example, 01:01:01 is 1 hour, 1 minute, and 1 second from the beginning of the day, which is the sum of 3600 seconds, 60 seconds, and 1 second:

time = make_time(1, 1, 1)
print_time(time)
time_to_int(time)
        
01:01:01
        
3661
        

And here’s a function that goes in the other direction—converting an integer to a Time object—using the divmod function:

def int_to_time(seconds):
    minute, second = divmod(seconds, 60)
    hour, minute = divmod(minute, 60)
    return make_time(hour, minute, second)
        

We can test it by converting the previous example back to a Time:

time = int_to_time(3661)
print_time(time)
        
01:01:01
        

Using these functions, we can write a more concise version of add_time:

def add_time(time, hours, minutes, seconds):
    duration = make_time(hours, minutes, seconds)
    seconds = time_to_int(time) + time_to_int(duration)
    return int_to_time(seconds)
        

The first line converts the arguments to a Time object called duration. The second line converts time and duration to seconds and adds them. The third line converts the sum to a Time object and returns it.

Here’s how it works:

start = make_time(9, 40, 0)
end = add_time(start, 1, 32, 0)
print_time(end)
        
11:12:00
        

In some ways, converting from base 60 to base 10 and back is harder than just dealing with times. Base conversion is more abstract; our intuition for dealing with time values is better.

But if we have the insight to treat times as base 60 numbers—and invest the effort to write the conversion functions time_to_int and int_to_time—we get a program that is shorter, easier to read and debug, and more reliable.

It is also easier to add features later. For example, imagine subtracting two Time objects to find the duration between them. The naive approach is to implement subtraction with borrowing. Using the conversion functions is easier and more likely to be correct.

Ironically, sometimes making a problem harder—or more general—makes it easier, because there are fewer special cases and fewer opportunities for error.

Glossary

object-oriented programming (OOP): A style of programming that uses objects to organize code and data.

class: A programmer-defined type. A class definition creates a new class object.

class object: An object that represents a class—it is the result of a class definition.

instantiation: The process of creating an object that belongs to a class.

instance: An object that belongs to a class.

attribute: A variable associated with an object, also called an instance variable.

object diagram: A graphical representation of an object, its attributes, and their values.

format specifier: In an f-string, a format specifier determines how a value is converted to a string.

pure function: A function that does not modify its parameters or have any effect other than returning a value.

functional programming style: A way of programming that uses pure functions whenever possible.

prototype and patch: A way of developing programs by starting with a rough draft and gradually adding features and fixing bugs.

design-first development: A way of developing programs with more careful planning than prototype and patch.

Exercises

Ask a Virtual Assistant

There is a lot of new vocabulary in this chapter. A conversation with a virtual assistant can help solidify your understanding. Consider asking:

  • “What is the difference between a class and a type?”

  • “What is the difference between an object and an instance?”

  • “What is the difference between a variable and an attribute?”

  • “What are the pros and cons of pure functions compared to modifiers?”

Because we are just getting started with object-oriented programming, the code in this chapter is not idiomatic—it is not the kind of code experienced programmers write. If you ask a virtual assistant for help with the exercises, you will probably see features we have not covered yet. In particular, you are likely to see a method called __init__ used to initialize the attributes of an instance.

If these features make sense to you, go ahead and use them. But if not, be patient—we will get there soon. In the meantime, see if you can solve the following exercises using only the features we have covered so far.

Also, in this chapter we saw one example of a format specifier. For more information ask “What format specifiers can be used in a Python f-string?”

Exercise

Write a function called subtract_time that takes two Time objects and returns the interval between them in seconds—assuming that they are two times during the same day.

Exercise

Write a function called is_after that takes two Time objects and returns True if the second time is later in the day than the first, and False otherwise:

def is_after(t1, t2):
    """Checks whether `t1` is after `t2`.
            
    >>> is_after(make_time(3, 2, 1), make_time(3, 2, 0))
    True
    >>> is_after(make_time(3, 2, 1), make_time(3, 2, 1))
    False
    >>> is_after(make_time(11, 12, 0), make_time(9, 40, 0))
    True
    """
    return None
        

Exercise

Here’s a definition for a Date class that represents a date—that is, a year, month, and day of the month:

class Date:
    """Represents a year, month, and day"""
        
  1. Write a function called make_date that takes year, month, and day as parameters, makes a Date object, assigns the parameters to attributes, and returns the result as the new object. Create an object that represents June 22, 1933.

  2. Write a function called print_date that takes a Date object, uses an f-string to format the attributes, and prints the result. If you test it with the Date you created, the result should be 1933-06-22.

  3. Write a function called is_after that takes two Date objects as parameters and returns True if the first comes after the second. Create a second object that represents September 17, 1933, and check whether it comes after the first object.

Hint: you might find it useful to write a function called date_to_tuple that takes a Date object and returns a tuple that contains its attributes in year, month, day order.