Even more object-oriented programming

Even more object-oriented programming

Source: this section is heavily based on Chapter 21 of [ThinkCS].

Now that we've seen the basics of object-oriented programming and have created our own first Point and Rectangle classes, let's take things yet a step further.

MyTime

As another example of a user-defined class, we'll define a class called MyTime that records the time of day. We provide an __init__ method to ensure that every instance is created with appropriate attributes and initialisation. The class definition looks like this:

class MyTime:

    def __init__(self, hrs=0, mins=0, secs=0):
        """ Create a MyTime object initialised to hrs, mins, secs """
        self.hours = hrs
        self.minutes = mins
        self.seconds = secs

We can then create and instantiate a new MyTime object as follows:

tim1 = MyTime(11, 59, 30)

The state diagram for this object looks like this:

/syllabus/info1-theory/assets/time.png

We leave it as an exercise for the readers to add a __str__ method so that MyTime objects can print themselves decently. For example, the object above should print as 11:59:30.

Pure functions

In the next few sections, we'll write two versions of a function called add_time, which calculates the sum of two MyTime objects. They will demonstrate two kinds of functions: pure functions and modifiers.

The following is a first rough version of add_time:

def add_time(t1, t2):
    h = t1.hours + t2.hours
    m = t1.minutes + t2.minutes
    s = t1.seconds + t2.seconds
    sum_t = MyTime(h, m, s)
    return sum_t

The function creates a new MyTime object and returns a reference to the new object. This is called a pure function because it does not modify any of the objects passed to it as parameters and it has no side effects, such as updating global variables, displaying a value, or getting user input.

Here is an example of how to use this function. We'll create two MyTime objects: current_time, which contains the current time; and bread_time, which contains the amount of time it takes for a breadmaker to make bread. Then we'll use add_time to figure out when the bread will be done.

>>> current_time = MyTime(9, 14, 30)
>>> bread_time = MyTime(3, 35, 0)
>>> done_time = add_time(current_time, bread_time)
>>> print(done_time)
12:49:30

The output of this program is 12:49:30, which is correct. On the other hand, there are cases where the result is not correct. Can you think of one?

The problem is that this function does not deal with cases where the number of seconds or minutes adds up to more than sixty. When that happens, we have to carry the extra seconds into the minutes column or the extra minutes into the hours column.

Here's an improved version of the function:

def add_time(t1, t2):

    h = t1.hours + t2.hours
    m = t1.minutes + t2.minutes
    s = t1.seconds + t2.seconds

    if s >= 60:
        s -= 60
        m += 1

    if m >= 60:
        m -= 60
        h += 1

    sum_t = MyTime(h, m, s)
    return sum_t

This function is starting to get bigger, and still doesn't work for all possible cases. Later we will suggest an alternative approach that yields better code.

Modifiers

There are times when it is useful for a function to modify one or more of the objects it gets as parameters. Usually, the caller keeps a reference to the objects it passes, so any changes the function makes are visible to the caller. Functions that work this way are called modifiers.

increment, which adds a given number of seconds to a MyTime object, would be written most naturally as a modifier. A rough draft of the function looks like this:

def increment(t, secs):
    t.seconds += secs

    if t.seconds >= 60:
        t.seconds -= 60
        t.minutes += 1

    if t.minutes >= 60:
        t.minutes -= 60
        t.hours += 1

The first line performs the basic operation; the remainder deals with the special cases we saw before.

Note that this function has no return statement nor does it need to create a new object. It simply modifies the state of the Time object t that was passed as first parameter to the function.

>>> t = MyTime(10,20,30)
>>> increment(t,70)
>>> print(t)
10:21:40

Is this function correct? What happens if the parameter seconds is much greater than sixty? In that case, it is not enough to carry once; we have to keep doing it until seconds is less than sixty. One solution is to replace the if statements with while statements:

def increment(t, seconds):
    t.seconds += seconds

    while t.seconds >= 60:
        t.seconds -= 60
        t.minutes += 1

    while t.minutes >= 60:
        t.minutes -= 60
        t.hours += 1

This function is now correct when seconds is not negative, and when hours does not exceed 23, but it is still not a particularly good or efficient solution.

>>> t = MyTime(10,20,30)
>>> increment(t,100)
>>> print(t)
10:22:10

Converting increment to a method

Once again, since object-oriented programmers would prefer to put functions that work with MyTime objects directly into the MyTime class, let's convert increment to a method. To save space, we will leave out previously defined methods, but you should keep them in your version:

class MyTime:
    # Previous method definitions here...

    def increment(self, seconds):
        self.seconds += seconds

        while self.seconds >= 60:
            self.seconds -= 60
            self.minutes += 1

        while self.minutes >= 60:
            self.minutes -= 60
            self.hours += 1

The transformation is purely mechanical: we move the definition into the class definition and change the name of the first parameter (and all occurrences of that parameter in the method body) to self, to fit with Python style conventions.

Now we can invoke increment using the syntax for invoking a method.

>>> current_time = MyTime(11, 58, 30)
>>> current_time.increment(500)
>>> print(current_time)
12:6:50

Again, the object on which the method is invoked gets assigned to the first parameter, self. The second parameter, seconds gets the value 500.

An "Aha!" moment

An "Aha!" moment is that moment or instant at which the solution to a problem suddenly becomes clear. Often a high-level insight into a problem can make the programming much easier.

A three-digit number in base 10, for example the number 284, can be represented by 3 digits, the right most one (4) representing the units, the middle one (8) representing the tens, and the left-most one representing the hundreds. In other words, 284 = 2*100 + 8*10 + 4*1.

Our "Aha!" moment consists of the insight that a MyTime object is actually a three-digit number in base 60 ! The "seconds" correspond to the units, the "minutes" to the sixties, and the hours to the thirty-six hundreds. Indeed, 12h03m30s corresponds to 12*3600 + 3*60 + 30 = 43410 seconds.

When we were writing the add_time and increment functions and methods, we were effectively doing addition in base 60, which explains why we had to carry over remaining digits from one column to the next.

This observation suggests another approach to the entire problem --- we can convert a MyTime object into a single number (in base 10, representing the seconds) and take advantage of the fact that the computer knows how to do arithmetic with numbers. The following method can be added to the MyTime class to convert any instance into a corresponding number of seconds:

class MyTime:
    # ...

    def to_seconds(self):
        """ Return the total number of seconds represented by this instance
        """
        return self.hours * 3600 + self.minutes * 60 + self.seconds
>>> current_time = MyTime(11, 58, 30)
>>> seconds = current_time.to_seconds()
>>> print(current_time)
11:58:30
>> print(seconds)
43110

Now, all we need is a way to convert from an integer, representing the time in seconds, back to a MyTime object. Supposing we have tsecs seconds, some integer division and modulus operators can do this for us:

hrs = tsecs // 3600
leftoversecs = tsecs % 3600
mins = leftoversecs // 60
secs = leftoversecs % 60

You might have to think a bit to convince yourself that this technique to convert from one base to another is correct. Remember that the // operator represents integer division and that the modulus operator % calculates the remainder of integer division.

As mentioned in the previous sections, one of the main goals of object-oriented programming is to wrap together data with the operations that apply to it. So we'd like to put the above conversion logic inside the MyTime class. A good solution is to rewrite the class initialisation method __init__ so that it can cope with initial values of seconds or minutes that are outside the normalised values. (A normalised time would be something like 3 hours 12 minutes and 20 seconds. The same time, but unnormalised could be 2 hours 70 minutes and 140 seconds, where the minutes or seconds are more than the expected maximum of 60.)

Let's rewrite a more powerful initialiser for MyTime:

class MyTime:
   # ...

   def __init__(self, hrs=0, mins=0, secs=0):
       """ Create a new MyTime object initialised to hrs, mins, secs.
           In case the values of mins and secs are outside the range 0-59,
           the resulting MyTime object will be normalised.
       """

       # Calculate the total number of seconds to represent
       totalsecs = hrs*3600 + mins*60 + secs
       self.hours = totalsecs // 3600        # Split in h, m, s
       leftoversecs = totalsecs % 3600
       self.minutes = leftoversecs // 60
       self.seconds = leftoversecs % 60

Now we can rewrite add_time like this:

def add_time(t1, t2):
    secs = t1.to_seconds() + t2.to_seconds()
    return MyTime(0, 0, secs)

This version is much shorter than the original, and it is much easier to demonstrate or reason that it is correct. Notice that we didn't have to do anything for carrying over seconds or minutes that are too large; that is handled automatically by our new initialiser method now.

>>> current_time = MyTime(9, 14, 30)
>>> bread_time = MyTime(3, 35, 0)
>>> done_time = add_time(current_time, bread_time)
>>> print(done_time)
12:49:30

The final question that remains now is how we can rewrite the increment method that we wrote before, without having to reimplement the logic that we now put into our new initialiser method. The answer to this question is in the question. What if we simply try to call the __init__ method from within the increment method so as to reuse its logic. This can be done surprisingly easily:

def increment(self, seconds):
    self.__init__(self.hours,self.minutes,self.seconds+secs)

Again, the carrying over of seconds or minutes that are too large is handled automatically by the initialiser method. It is important to observe that, as opposed to the add_time method, we are not creating a new MyTime object here. We are simply calling __init__ to assign a new state to the existing instance (self).

>>> current_time = MyTime(11, 58, 30)
>>> current_time.increment(500)
>>> print(current_time)
12:6:50

Generalisation

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

However, if we have the insight to treat time objects as base 60 numbers and make the investment of writing the conversions, 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 MyTime objects to find the duration between them. The naive approach would be to implement subtraction with borrowing. Using the conversion functions would be easier and more likely to be correct.

Ironically, sometimes making a problem harder (or more general) makes the programming easier, because there are fewer special cases and fewer opportunities for error.

Specialisation versus Generalisation

Computer Scientists are generally fond of specialising their types, while mathematicians often take the opposite approach, and generalise everything.

What do we mean by this?

If we ask a mathematician to solve a problem involving weekdays, days of the century, playing cards, time, or dominoes, their most likely response is to observe that all these objects can be represented by integers. Playing cards, for example, can be numbered from 0 to 51. Days within the century can be numbered. Mathematicians will say "These things are enumerable --- the elements can be uniquely numbered (and we can reverse this numbering to get back to the original concept). So let's number them, and confine our thinking to integers. Luckily, we have powerful techniques and a good understanding of integers, and so our abstractions --- the way we tackle and simplify these problems --- is to try to reduce them to problems about integers."

Computer scientists tend to do the opposite. We will argue that there are many integer operations that are simply not meaningful for dominoes, or for days of the century. So we'll often define new specialised types, like MyTime, because we can restrict, control, and specialise the operations that are possible. Object-oriented programming is particularly popular because it gives us a good way to bundle methods and specialised data into a new type. (We call such a type an abstract data type.)

Both approaches are powerful problem-solving techniques. Often it may help to try to think about the problem from both points of view --- "What would happen if I tried to reduce everything to very few primitive types?", versus "What would happen if this thing had its own specialised type?"

Another example

The after function should compare two times, and tell us whether the first time is strictly after the second, e.g.

>>> t1 = MyTime(10, 55, 12)
>>> t2 = MyTime(10, 48, 22)
>>> after(t1, t2)             # Is t1 after t2?
True

This is slightly more complicated because it operates on two MyTime objects, not just one. But we'd prefer to write it as a method anyway, in this case, a method on the first argument. We can then invoke this method on one object and pass the other as an argument:

if current_time.after(done_time):
    print("The bread will be done before it starts!")

We can almost read the invocation like English: If the current time is after the done time, then...

To implement this method, we can again use our "Aha!" insight and and reduce both times to seconds, which yields a very compact method definition:

class MyTime:
    # Previous method definitions here...

    def after(self, time2):
        """ Return True if I am strictly greater than time2 """
        return self.to_seconds() > time2.to_seconds()

This is a great way to code this: if we want to tell if the first time is after the second time, turn them both into integers and compare the integers.

Operator overloading

Some languages, including Python, make it possible to have different meanings for the same operator when applied to different types. For example, + in Python means quite different things for integers and for strings. This feature is called operator overloading.

It is especially useful when programmers can also overload the operators for their own user-defined types.

For example, to override the addition operator +, we can provide a magic method named __add__:

class MyTime:
    # Previously defined methods here...

    def __add__(self, other):
        secs = self.to_seconds() + other.to_seconds()
        return MyTime(0, 0, secs)

As usual, the first parameter is the object on which the method is invoked. The second parameter is conveniently named other to distinguish it from self. To add two MyTime objects, we create and return a new MyTime object that contains their sum.

Now, when we apply the + operator to MyTime objects, Python invokes the __add__ method that we have written:

>>> t1 = MyTime(1, 15, 42)
>>> t2 = MyTime(3, 50, 30)
>>> t3 = t1 + t2
>>> print(t3)
05:06:12

The expression t1 + t2 is equivalent to t1.__add__(t2), but obviously more elegant. As an exercise, add a method __sub__(self, other) that overloads the subtraction operator -, and try it out.

For the next couple of exercises we'll go back to the Point class defined when we first introduced objects, and overload some of its operators. Firstly, adding two points adds their respective (x, y) coordinates:

class Point:
    # Previously defined methods here...

    def __add__(self, other):
        return Point(self.x + other.x,  self.y + other.y)
>>> p = Point(3, 4)
>>> q = Point(5, 7)
>>> r = p + q
>>> print(r)
(8, 11)

There are several ways to override the behaviour of the multiplication operator *: by defining a magic method named __mul__, or __rmul__, or both.

If the left operand of * is a Point, Python invokes __mul__, which assumes that the other operand is also a Point. In this case we compute the dot product of the two Points, defined according to the rules of linear algebra:

def __mul__(self, other):
    return self.x * other.x + self.y * other.y

If the left operand of * is a primitive type and the right operand is a Point, Python invokes __rmul__, which performs scalar multiplication:

def __rmul__(self, other):
    return Point(other * self.x,  other * self.y)

The result is a new Point whose coordinates are a multiple of the original coordinates. If other is a type that cannot be multiplied by a floating-point number, then __rmul__ will yield an error.

This example demonstrates both kinds of multiplication:

>>> p1 = Point(3, 4)
>>> p2 = Point(5, 7)
>>> print(p1 * p2)
43
>>> print(2 * p2)
(10, 14)
>>> print(p2 * 2)

But what happens if we try to evaluate p2 * 2? Since the first parameter is a Point, Python invokes __mul__ with 2 as the second argument. Inside __mul__, the program tries to access the x coordinate of other, which fails because an integer has no attributes:

>>> print(p2 * 2)
AttributeError: 'int' object has no attribute 'x'

Unfortunately, the error message is a bit opaque. This example demonstrates some of the difficulties of object-oriented programming. Sometimes it is hard enough just to figure out what code is running.

Polymorphism

Most of the methods we have written so far only work for a specific type. When we create a new object, we write methods that operate on that type. But there are certain operations that we may want to apply to many types, such as the arithmetic operators in the previous section. If many types support the same set of operations, we can write functions that work on any of those types.

For example, the multadd operation (which is common in linear algebra) takes three parameters; it multiplies the first two and then adds the third. We can write it in Python like this:

def multadd(x, y, z):
    return x * y + z

This function will work for any values of x and y that can be multiplied and for any value of z that can be added to the product.

We can invoke it with numeric values:

>>> multadd(3, 2, 1)
7

Or with Point objects:

>>> p1 = Point(3, 4)
>>> p2 = Point(5, 7)
>>> print(multadd (2, p1, p2))
(11, 15)
>>> print(multadd (p1, p2, 1))
44

In the first case, the Point p1 is multiplied by a scalar 2 and then added to another Point p2. In the second case, the dot product of p1 and p2 yields a numeric value, so the third parameter also has to be a numeric value.

A function like this that can work with arguments of different types is called polymorphic. In object-oriented programming, polymorphism (from the Greek meaning "having multiple forms") is the characteristic of being able to assign a different meaning or usage to something in different contexts. In this case, the context that varies are the types of arguments taken by the function.

As another example, consider the function front_and_back, which prints a list twice, forward and backward:

def front_and_back(front):
    import copy
    back = copy.copy(front)
    back.reverse()
    print(str(front) + str(back))

Because the reverse method is a modifier, we first make a copy of the list before reversing it. That way, this function doesn't modify the list it gets as a parameter.

Here's an example that applies front_and_back to a list:

>>> my_list = [1, 2, 3, 4]
>>> front_and_back(my_list)
[1, 2, 3, 4][4, 3, 2, 1]

Since we intended to apply this function to lists, of course it is not so surprising that it works. What would be surprising is if we could apply it to a Point.

To determine whether a function can be applied to a new type, we apply Python's fundamental rule of polymorphism, called the duck typing rule: If all of the operations inside the function can be applied to the type, the function can be applied to the type. The operations in the front_and_back function include copy, reverse, and print.

Remark: Not all programming languages define polymorphism in this way. Look up 'duck typing', and see if you can figure out why it has this name.

Since copy works on any object, and we have already written a __str__ method for Point objects, all we need to add is a reverse method to the Point class, which we define as a method that swaps the values of the x and y attributes of a point:

def reverse(self):
    (self.x , self.y) = (self.y, self.x)

After this, we can try to pass Point objects to the front_and_back function:

>>> p = Point(3, 4)
>>> front_and_back(p)
(3, 4)(4, 3)

The most interesting polymorphism is often the unintentional kind, where we discover that a function which we have already written can be applied to a type for which we never planned it.

Glossary

dot product
An operation defined in linear algebra that multiplies two points and yields a numeric value.
functional programming style
A style of program design in which the majority of functions are pure.
modifier
A function or method that changes one or more of the objects it receives as parameters. Most modifier functions are void (do not return a value).
normalized
Data is said to be normalized if it fits into some reduced range or set of rules. We usually normalize our angles to values in the range [0..360[. We normalize minutes and seconds to be values in the range [0..60[. And we'd be surprised if the local store advertised its cold drinks at "One dollar, two hundred and fifty cents".
operator overloading
Extending built-in operators ( +, -, *, >, <, etc.) so that they do different things for different types of arguments. We've seen earlier how + is overloaded for numbers and strings, and here we've shown how to further overload it for user-defined types using magic methods.
polymorphic
A function that can operate on more than one type. Notice the subtle distinction: overloading has different functions (all with the same name) for different types, whereas a polymorphic function is a single function that can work for a range of types.
pure function
A function that does not modify any of the objects it receives as parameters. Most pure functions are not void but return a value.
scalar multiplication
An operation defined in linear algebra that multiplies each of the coordinates of a Point by a numeric value.

References

[ThinkCS]How To Think Like a Computer Scientist --- Learning with Python 3

Page précédente Page suivante
<string>