Classes and Methods 1#

So far, we have seen a few object-oriented features of Python, but the relation between programmer-defined types and functions can be much stronger and allow for real encapsulation of the programmer-defined data and corresponding functionalities.

The next step is to change those functions into methods that make the relationships explicit.

Object-oriented Features#

Python is an object-oriented programming language, which means that it provides features that support object-oriented programming, which has these defining characteristics:

  • Programs include class and method definitions.

  • Most of the computations are expressed in terms of operations on objects.

  • Objects often represent things in the real world, and methods often correspond to the ways things in the real world interact, for instance created, destroyed, updated, etc.

The Time class corresponded to the way people consider the time of day, and the functions we defined corresponded to how people want to manipulate time.

So far, we have not taken advantage of the object-oriended features Python provides when writing code.

These features were expressed using the known language constructs, but the object-oriented alternative is more concise and more accurately conveys the structure of the program.

In case of the class Time and the functions defined for Time it can be observed that all functions take Time as an argument.

This observation is the motivation for methods; a method is a function that is associated with a particular class.

The objects representing strings, lists, dictionaries and tuples already provided methods for manipulations.

In this chapter, we will define methods for programmer-defined types.

Methods are semantically the same as functions, but there are two syntactic differences:

  • Methods are defined inside a class definition in order to make the relationship between the class and the method more explicit.

  • The syntax for invoking a method is different from the syntax for calling a function.

The functions we defined in the previous two chapters will be gradually transformed into methods.

Printing Objects#

We defined the class Time and defined a function to print the time print_time.

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

This function uses a Time object as argument.

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

print_time(start)
21:45:00

To change print_time into a method, the function has to be declared inside the class definition.

This is done by increasing the indentation.

class Time:
    """Represents the time of day."""
    
    def print_time(time) -> None:
        """ prints a Time object
        """
        print('{:02d}:{:02d}:{:02d}'.format(time.hour,time.minute,time.second))
        
start : Time = Time()
start.hour : int = 21
start.minute : int = 45
start.second : int = 0

Note that the method print_time does not have the type hint Time for its argument time anymore.

The type Time is not known within the class Time.

This holds for all methods defined in a class!

There are two ways to call print_time.

The first (and uncommon) way is to use function syntax.

Time.print_time(start)
21:45:00

Time is the name of the class, print_time the method to be executed, and start the argument to be printed.

The second way is more concise: method syntax.

start.print_time()
21:45:00

print_time is the name of the method (again), and start is the object the method is invoked on, which is called the subject.

Inside the method, the subject is assigned to the first parameter, so in this case start is assigned to time.

By convention, the first parameter of a method is called self.

The next cell shows a more common way to write print_time, using the self parameter.

class Time:
    """Represents the time of day."""
    
    def print_time(self) -> None:
        """ prints a Time object
        """
        print('{:02d}:{:02d}:{:02d}'.format(self.hour, self.minute, self.second))
start : Time = Time()
start.hour : int = 22
start.minute : int = 31
start.second : int = 0

start.print_time()
22:31:00

The reason for this convention is an implicit metaphor:

  • The syntax for a function call, print_time(start), suggests that the function is the active agent. It says something like, “Hey print_time! Here’s an object for you to print.”

  • In object-oriented programming, the objects are the active agents. A method invocation like start.print_time() says “Hey start! Please print yourself.”

Shifting responsibility from the functions onto the objects makes it possible to write more versatile functions (or methods), and makes it easier to maintain and reuse code.

# Remove this line and add your code here

Another Example#

Let us introduce a few extra useful methods to the class Time. Actually, we saw them earlier as functions.

Let us start with defining the conversion function from time to seconds time_to_int. The next cells also contain simple manual tests.

class Time:
    """Represents the time of day."""

    def print_time(self) -> None:
        """ prints a Time object
        """
        print('{:02d}:{:02d}:{:02d}'.format(self.hour, self.minute, self.second)) 
        
    def time_to_int(self) -> int:
        """ converts a Time object into an integer value respresenting seconds
        """
        minutes : int = 60 * self.hour + self.minute
        seconds : int = 60 * minutes + self.second
        return seconds
start : Time = Time()
start.hour : int = 21
start.minute : int = 45
start.second : int = 1

start.time_to_int() == 78301
True

The conversion function, int_to_time, which goes from seconds to time cannot be implemented as a method of the class Time, because it produces a Time object and takes an integer value.

Functions can be implemented as methods if they take an instantiated object as argument.

class Time:
    """Represents the time of day."""

    def print_time(self) -> None:
        """ prints a Time object
        """
        print('{:02d}:{:02d}:{:02d}'.format(self.hour, self.minute, self.second)) 
        
    def time_to_int(self) -> int:
        """ converts a Time object into an integer value respresenting seconds
        """
        minutes : int = 60 * self.hour + self.minute
        seconds : int = 60 * minutes + self.second
        return seconds

def int_to_time(seconds : int) -> Time:
    """ converts a values 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
start : Time = Time()
start.hour : int = 21
start.minute : int = 45
start.second : int = 0

int_to_time(start.time_to_int()).print_time()

# Beware the following test fails!
print(int_to_time(start.time_to_int()) == start)

# Where as the next test succeeds!
print(int_to_time(78301).time_to_int() == 78301)
21:45:00
False
True

The next cell shows an extension of the class Time with the increment method.

class Time:
    """Represents the time of day."""

    def print_time(self) -> None:
        """ prints a Time object
        """
        print('{:02d}:{:02d}:{:02d}'.format(self.hour, self.minute, self.second)) 
        
    def time_to_int(self) -> int:
        """ converts a Time object into an integer value respresenting seconds
        """
        minutes : int = 60 * self.hour + self.minute
        seconds : int = 60 * minutes + self.second
        return seconds
            
    def increment(self, seconds : int):
        """ increments a Time object with an amount of seconds (represented as an integer)
        """
        seconds += self.time_to_int()
        return int_to_time(seconds)

def int_to_time(seconds : int) -> Time:
    """ converts a values 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
start : Time = Time()
start.hour : int = 21
start.minute : int = 45
start.second : int = 0

start.print_time()
end : Time = start.increment(1337)
end.print_time()

start.time_to_int()+1337 == end.time_to_int()
21:45:00
22:07:17
True

The subject, start, gets assigned to the first parameter, self.

The argument, 1337, gets assigned to the second parameter, seconds.

This mechanism can be confusing, especially if you make an error.

If you invoke increment with two arguments, you get the following error message.

end : Time = start.increment(1337, 460)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[15], line 1
----> 1 end : Time = start.increment(1337, 460)

TypeError: increment() takes 2 positional arguments but 3 were given

The error message is initially confusing, because there are only two arguments in parentheses.

But the subject is also considered an argument, so all together that is three.

Another (Extended) Example#

The transforming the function is_after into a method is more involved, because is_after takes two Time objects as parameters.

It is conventional to name the first parameter self and the second parameter other.

class Time:
    """Represents the time of day."""

    def print_time(self) -> None:
        """ prints a Time object
        """
        print('{:02d}:{:02d}:{:02d}'.format(self.hour, self.minute, self.second)) 
        
    def time_to_int(self) -> int:
        """ converts a Time object into an integer value respresenting seconds
        """
        minutes : int = 60 * self.hour + self.minute
        seconds : int = 60 * minutes + self.second
        return seconds
            
    def increment(self, seconds : int):
        """ increments a Time object with an amount of seconds (represented as an integer)
        """
        seconds += self.time_to_int()
        return int_to_time(seconds)
    
    def is_after(self, other) -> bool:
        """ checks whether the current time is after the given time
        """
        return self.time_to_int() > other.time_to_int()

def int_to_time(seconds : int) -> Time:
    """ converts a values 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
start : Time = Time()
start.hour : int = 21
start.minute : int = 45
start.second : int = 0

end : Time = start.increment(1337)
end.is_after(start)
True

One nice thing about this syntax is that it almost reads like English: “end is after start?”

Additionally, arguments in a function of method can be either positional arguments or keyword arguments.

A positional argment does not have a parameter name. This is the case of all parameters we have introduced so far.

A keyword argument does have a parameter name. Let us consider a small example.

By the way, a positional argument is an argument that does not have a parameter name; that is, it is not a keyword argument. In this function call: sketch(parrot, cage, dead=True) parrot and cage are positional, and dead is a keyword argument.

def print_event_time(time : Time, event="None"):
    print('{:02d}:{:02d}:{:02d} {}'.format(time.hour, time.minute, time.second, event))
          
print_event_time(start, event="Log in")
print_event_time(start)
21:45:00 Log in
21:45:00 None

In this case, time is a positional argument, while event is a keyword argument.

The init Method#

The init method (short for “initialization”) is a special method that gets invoked when an object is created. Its full name is __init__ (two underscore characters, followed by init, and then two more underscores).

Its purpose is to initialize a new object, this is the constructor method. When the object is created, for instance via Time(11, 43, 51), the parameters are passed to the created object.

An init method for the Time class is shown in the next cell.

class Time:
    """Represents the time of day."""

    def __init__(self, hour=0, minute=0, second=0):
        """ creates a new Time object and initializes it
        """
        self.hour : int = hour
        self.minute : int = minute
        self.second : int = second
            
    def print_time(self) -> None:
        """ prints a Time object
        """
        print('{:02d}:{:02d}:{:02d}'.format(self.hour, self.minute, self.second)) 
        
    def time_to_int(self) -> int:
        """ converts a Time object into an integer value respresenting seconds
        """
        minutes : int = 60 * self.hour + self.minute
        seconds : int = 60 * minutes + self.second
        return seconds
            
    def increment(self, seconds : int):
        """ increments a Time object with an amount of seconds (represented as an integer)
        """
        seconds += self.time_to_int()
        return int_to_time(seconds)
    
    def is_after(self, other) -> bool:
        """ checks whether the current time is after the given time
        """
        return self.time_to_int() > other.time_to_int()

def int_to_time(seconds : int) -> Time:
    """ converts a values 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
start : Time = Time()
start.print_time()
end : Time = Time(25, 43, 51)
end.print_time()
00:00:00
25:43:51

As you can see this results in an invalid time representation!

Here are two alternative implementations for __init__.

class Time:
    """Represents the time of day."""

    def __init__(self, hour=0, minute=0, second=0) -> None:
        """ creates a new Time object and initializes it,
            if at least proper values are provided.
        """
        if (0 <= hour < 24 and 0 <= minute < 59 and 0 <= second < 59):
            self.hour : int = hour
            self.minute : int = minute
            self.second : int = second
        else:
            self.hour : int = 0
            self.minute : int = 0
            self.second : int = 0
            print("Trying to create an invalid time representation!")
            
    def print_time(self) -> None:
        """ prints a Time object
        """
        print('{:02d}:{:02d}:{:02d}'.format(self.hour, self.minute, self.second)) 
        
    def time_to_int(self) -> int:
        """ converts a Time object into an integer value respresenting seconds
        """
        minutes : int = 60 * self.hour + self.minute
        seconds : int = 60 * minutes + self.second
        return seconds
            
    def increment(self, seconds : int):
        """ increments a Time object with an amount of seconds (represented as an integer)
        """
        seconds += self.time_to_int()
        return int_to_time(seconds)
    
    def is_after(self, other) -> bool:
        """ checks whether the current time is after the given time
        """
        return self.time_to_int() > other.time_to_int()

def int_to_time(seconds : int) -> Time:
    """ converts a values 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

Or:

class Time:
    """Represents the time of day."""

    def __init__(self, hour=0, minute=0, second=0) -> None:
        """ creates a new Time object and initializes it
        """
        (ignore, self.hour) = divmod(hour, 24)
        (ignore, self.minute) = divmod(minute, 60)
        (ignore, self.second) = divmod(second, 60)

    def print_time(self) -> None:
        """ prints a Time object
        """
        print('{:02d}:{:02d}:{:02d}'.format(self.hour, self.minute, self.second)) 
                
    def time_to_int(self) -> int:
        """ converts a Time object into an integer value respresenting seconds
        """
        minutes : int = 60 * self.hour + self.minute
        seconds : int = 60 * minutes + self.second
        return seconds
            
    def increment(self, seconds : int):
        """ increments a Time object with an amount of seconds (represented as an integer)
        """
        seconds += self.time_to_int()
        return int_to_time(seconds)
    
    def is_after(self, other) -> bool:
        """ checks whether the current time is after the given time
        """
        return self.time_to_int() > other.time_to_int()

def int_to_time(seconds : int) -> Time:
    """ converts a values 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
start : Time = Time()
start.print_time()
end : Time = Time(25, 43, 51)
end.print_time()
00:00:00
01:43:51

If you provide one argument, it overrides hour:

start : Time = Time(9)
start.print_time()
09:00:00

If you provide two arguments, it overrides hour and minutes:

start : Time = Time(9, 45)
start.print_time()

If you provide all three arguments, they override all three default values.

# Remove this line and add your code here

The __str__ Method#

__str__ is a special method, like __init__, that is supposed to return a string representation of an object.

class Time:
    """Represents the time of day."""

    def __init__(self, hour=0, minute=0, second=0) -> None:
        """ creates a new Time object and initializes it
        """
        (ignore, self.hour) = divmod(hour, 24)
        (ignore, self.minute) = divmod(minute, 60)
        (ignore, self.second) = divmod(second, 60)
        
    def __str__(self) -> str:
        """ creates a string from the current Time object
        """
        return '{:02d}:{:02d}:{:02d}'.format(self.hour, self.minute, self.second)
    
    def time_to_int(self) -> int:
        """ converts a Time object into an integer value respresenting seconds
        """
        minutes : int = 60 * self.hour + self.minute
        seconds : int = 60 * minutes + self.second
        return seconds
            
    def increment(self, seconds : int):
        """ increments a Time object with an amount of seconds (represented as an integer)
        """
        seconds += self.time_to_int()
        return int_to_time(seconds)
    
    def is_after(self, other) -> bool:
        """ checks whether the current time is after the given time
        """
        return self.time_to_int() > other.time_to_int()

def int_to_time(seconds : int) -> Time:
    """ converts a values 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
start : Time = Time(25, 45, 30)
print(start)
01:45:30

When you print an object, Python invokes the str method.

It is a good habit to start a class with the __init__ method, in order to initialize, and to write __str__, for debugging.

# Remove this line and add your code here

A Bit of OO Theory#

Classes and objects are powerful programming tools. They allow good programmers to be very effective, but if wrongly used they create a mess and hard to maintain code.

Encapsulation#

Encapsulation means enclosing something in a kind of container. Encapsulation in programming means bringing data and code together in one place and hiding the details of both and the interaction between both.

Good encapsulation ensures that a programmer does not need to dive into the details of the implementation.

Polymorphism#

Polymorphism means having more than one form. In programming, it means that a function can be applied to different data types and a type may influence the behaviour of the function.

To be discussed in more detail in the following section.

Inheritance#

Polymorphism can be implemented by having a method with the same name implemented in various classes.

However, this approach is tedious and error prone: we need to copy a lot of boilerplate code. Forgetting to add such a method to your code can make it fail.

A better approach is to use another feature of object oriented programming: inheritance, which allows you to reuse code in a different way.

When you create a class, you are using inheritance: your created class inherits all attributes of the class object. This is similar to inheriting characteristics of your parents as their child.

To be discussed in more detail in the following chapter.

Operator Overloading#

By defining other special methods, you can specify the behavior of operators on programmer-defined types.

If you define a method named __add__ for the Time class, you can use the + operator on Time objects.

class Time:
    """Represents the time of day."""

    def __init__(self, hour=0, minute=0, second=0) -> None:
        """ creates a new Time object and initializes it
        """
        (ignore, self.hour) = divmod(hour, 24)
        (ignore, self.minute) = divmod(minute, 60)
        (ignore, self.second) = divmod(second, 60)
        
    def __str__(self) -> str:
        """ creates a string from the current Time object
        """
        return '{:02d}:{:02d}:{:02d}'.format(self.hour, self.minute, self.second)
    
    def time_to_int(self) -> int:
        """ converts a Time object into an integer value respresenting seconds
        """
        minutes : int = 60 * self.hour + self.minute
        seconds : int = 60 * minutes + self.second
        return seconds
            
    def increment(self, seconds : int):
        """ increments a Time object with an amount of seconds (represented as an integer)
        """
        seconds += self.time_to_int()
        return int_to_time(seconds)
    
    def is_after(self, other) -> bool:
        """ checks whether the current time is after the given time
        """
        return self.time_to_int() > other.time_to_int()
    
    def __add__(self, other):
        """ adds a Time object to the current Time object
        """
        seconds : int = self.time_to_int() + other.time_to_int()
        return int_to_time(seconds)

def int_to_time(seconds : int) -> Time:
    """ converts a values 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
start : Time = Time(9, 45, 30)
duration : Time = Time(0, 15, 30)
print(start + duration)
10:01:00

When you apply the + operator to Time objects, Python invokes __add__.

When you print the result, Python invokes __str__.

Changing the behavior of an operator so that it works with programmer-defined types is called operator overloading.

For every operator in Python there is a corresponding special method, like __add__.

For more details, see http://docs.python.org/3/reference/datamodel.html#specialnames

# Remove this line and add your code here

Type-based Dispatch#

The __add__ method introduced in the previous section, adds two Time objects.

It would be convenient to use the __add__ method to add an integer value (representing an amount of seconds) to a Time object.

The following is a version of __add__ that checks the type of other and invokes either add_time or increment.

class Time:
    """Represents the time of day."""

    def __init__(self, hour=0, minute=0, second=0) -> None:
        """ creates a new Time object and initializes it
        """
        (ignore, self.hour) = divmod(hour, 24)
        (ignore, self.minute) = divmod(minute, 60)
        (ignore, self.second) = divmod(second, 60)
        
    def __str__(self) -> str:
        """ creates a string from the current Time object
        """
        return '{:02d}:{:02d}:{:02d}'.format(self.hour, self.minute, self.second)
    
    def time_to_int(self) -> int:
        """ converts a Time object into an integer value respresenting seconds
        """
        minutes : int = 60 * self.hour + self.minute
        seconds : int = 60 * minutes + self.second
        return seconds
            
    def increment(self, seconds : int):
        """ increments a Time object with an amount of seconds (represented as an integer)
        """
        seconds += self.time_to_int()
        return int_to_time(seconds)
    
    def is_after(self, other) -> bool:
        """ checks whether the current time is after the given time
        """
        return self.time_to_int() > other.time_to_int()
    
    def add_time(self, other):
        """ adds a Time object to the current Time object
        """
        seconds : int = self.time_to_int() + other.time_to_int()
        return int_to_time(seconds)
    
    def __add__(self, other : any):
        """ adds a Time object or an amount of seconds to the current Time object
        """
        if isinstance(other, Time):
            return self.add_time(other)
        else:
            return self.increment(other)

def int_to_time(seconds : int) -> Time:
    """ converts a values 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
start : Time = Time(9, 45, 30)
print(start + 3600)
duration : Time = Time(2)
print(start+duration)
10:45:30
11:45:30

The built-in function isinstance takes a value and a class object, and returns True if the value is an instance of the class.

If other is a Time object, __add__ calls add_time.

Otherwise, it assumes that the parameter is a number and calls increment.

This operation is called a type-based dispatch because it dispatches the computation to different methods based on the type of the arguments.

In object-oriented languages this is a common concept; another name is dynamic dispatch.

# Remove this line and add your code here

Unfortunately, this implementation of addition is not commutative. If the integer is the first operand, you get an error message.

print(3630 + start)

The problem is, instead of asking the Time object to add an integer, Python is asking an integer to add a Time object, and it does not know how.

But there is a clever solution for this problem: the special method __radd__, which stands for “right-side add”.

This method is invoked when a Time object appears on the right side of the + operator.

class Time:
    """Represents the time of day."""

    def __init__(self, hour=0, minute=0, second=0) -> None:
        """ creates a new Time object and initializes it
        """
        (ignore, self.hour) = divmod(hour, 24)
        (ignore, self.minute) = divmod(minute, 60)
        (ignore, self.second) = divmod(second, 60)
        
    def __str__(self) -> str:
        """ creates a string from the current Time object
        """
        return '{:02d}:{:02d}:{:02d}'.format(self.hour, self.minute, self.second)
    
    def time_to_int(self) -> int:
        """ converts a Time object into an integer value respresenting seconds
        """
        minutes : int = 60 * self.hour + self.minute
        seconds : int = 60 * minutes + self.second
        return seconds
            
    def increment(self, seconds : int):
        """ increments a Time object with an amount of seconds (represented as an integer)
        """
        seconds += self.time_to_int()
        return int_to_time(seconds)
    
    def is_after(self, other) -> bool:
        """ checks whether the current time is after the given time
        """
        return self.time_to_int() > other.time_to_int()
    
    def add_time(self, other):
        """ adds a Time object to the current Time object
        """
        seconds : int = self.time_to_int() + other.time_to_int()
        return int_to_time(seconds)
    
    def __add__(self, other : any):
        """ adds a Time object or an amount of seconds to the current Time object
        """
        if isinstance(other, Time):
            return self.add_time(other)
        else:
            return self.increment(other)

    def __radd__(self, other : any):
        """ flips the arguments if needed
        """
        return self.__add__(other)
    
def int_to_time(seconds : int) -> Time:
    """ converts a values 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
start : Time = Time(9, 45, 30)
print(3630 + start)
print(3630 + start + 363)

Polymorphism#

Type-based dispatch is useful when it is necessary, but (fortunately) it is not always necessary.

Often you can avoid it by writing functions that work correctly for arguments with different types.

Many of the functions we wrote for strings also work for other sequence types.

In Section 11.2 we used histogram to count the number of times each letter appears in a word.

def histogram(s : list) -> dict:
    """ creates a histogram from a list of elements
    """
    d = dict()
    for c in s:
        if c not in d:
            d[c] = 1
        else:
            d[c] = d[c] + 1
    return d

The function histogram works on a list of words.

t = ['data', 'science', 'statistics', 'machine', 'learning', 'computer', 'science']
histogram(t)

Or on a list of prime numbers.

p = [3, 7, 11, 13, 3, 5, 23, 13, 3]
histogram(p)

Functions that work with several types are called polymorphic.

Polymorphism can facilitate code reuse.

For example, the built-in function sum, which adds the elements of a sequence, works as long as the elements of the sequence support addition.

Since Time objects provide an add method, they work with sum.

t1 : Time = Time(7, 43)
t2 : Time = Time(7, 41)
t3 : Time = Time(7, 37)

total_time : Time = sum([t1, t2, t3])
print(total_time)

total_value : int = sum([3, 7, 11, 13, 3, 5, 23, 13, 3])
print(total_value)

In general, if all of the operations inside a function work with a given type, the function works with that type.

The best kind of polymorphism is the unintentional kind, where you discover that a function you already wrote can be applied to a type you never planned for.

# Remove this line and add your code here

Interface and Implementation#

One of the goals of object-oriented design is to make software more maintainable, which means that you can keep the program working when other parts of the system change, and modify the program to meet new requirements.

A design principle that helps achieve that goal is to keep interfaces separate from implementations. This is called encapsulation.

For objects, that means that the methods a class provides should not depend on how the attributes are represented.

For example, in this chapter we developed a class that represents a time of the day.

Methods provided by this class include time_to_int, is_after, and add_time.

We could implement those methods in several ways.

The details of the implementation depend on how we represent time.

In this chapter, the attributes of a Time object are hour, minute, and second.

As an alternative, we could replace these attributes with a single integer representing the number of seconds since midnight.

This implementation would make some methods, like is_after, easier to write, but it makes other methods harder.

After you deploy a new class, you might discover a better implementation.

If other parts of the program are using your class, it might be time-consuming and error-prone to change the interface.

But if you designed the interface carefully, you can change the implementation without changing the interface, which means that other parts of the program do not have to change.


1

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