Preparation#

Reading material#

In the Think Python (TP) book, inheritance is covered in Chapter 17 Inheritance. Operator overloading is covered in one section of Chapter 15 Classes and Methods.

Looking at the lecture notes for the CS50 course, this week is covered in two sections of Lecture 8 Object-Oriented Programming, sections on inheritance and operator overloading.

Copy-and-Run#

Prep 11.1: Inheritance#

Try to figure out what the following class is intended to do.

class NameTracker:

    def __init__(self):
        self.longest = ''
        self.shortest = ''
    
    def add(self, name):
        if len(name) > len(self.longest):
            self.longest = name
        if len(name) < len(self.shortest) or self.shortest=='':
            self.shortest = name
    
    def print(self):
        print('Names tracked so far')
        print('  Longest :', self.longest)
        print('  Shortest:', self.shortest)
    

Now try it out using the code below. You should place both the class definition and the code using the class in the same script. In general this week, we will often only provide code for you to add or modify. This is to avoid having long code cells in the exercises.

girls = NameTracker()
girls.add('Mary')
girls.print()
girls.add('Augusta')
girls.print()
girls.add('Josephine')
girls.print()

What would happen if you added 'Pippilotta Rullegardinia' to girls?

Imagine that you would like the name tracker which perform some checks, before assigning a name to one of its attributes. For example, you want to ignore the names with a space or a hyphen in them. But, you want to keep the functionality of NameTracker unchanged. Look now carefully at the following code, and execute it.

class CarefulNameTracker(NameTracker):
    
    def add(self, name):
        if not (' ' in name or '-' in name):
            if len(name) > len(self.longest):
                self.longest = name
            if len(name) < len(self.shortest) or self.shortest=='':
                self.shortest = name
            
girls_first = NameTracker()
girls_second = CarefulNameTracker()

for name in ['Mary', 'Augusta', 'Josephine', 'Pippilotta Rullegardinia', 'I-I']:
    girls_first.add(name)
    girls_second.add(name)

girls_first.print()
print()
girls_second.print()

This is an example of inheritance. We call the one class a parent class (or superclass), and the other a child class (or subclass).

Since inheritance involves two classes, the examples on inheritance contain more than just few lines of code. But let’s try to identify the key elements of inheritance in the code above. Try to answer the following questions.

  1. What is the parent class? What is the child class? And where in the code do we say to Python that one class is the parent of the other?

  2. When calling girls_first.add(name) which method is called? How does Python know which method to call?

  3. When calling girls_second.add(name) which method is called? How does Python know which method to call?

  4. When calling girls_first.print() which method is called? How does Python know which method to call?

  5. When calling girls_second.print() which method is called? How does Python know which method to call?

  6. When girls_first was created, which constructor is called? Why?

  7. When girls_second was created, which constructor is called? Why?

You can add the print statements to the code to see which methods are called. For example, add print('Method add from NameTracker called!') as the first line of the method, and similar for the other methods. Than add just one name at a time.

In the example above, we say that the add method in the CarefulNameTracker class overrides the add method in the NameTracker class.

Prep 11.2: Function super()#

In the code above the add method from CarefulNameTracker contains a copy of the code from the add method in NameTracker. An experienced programmer would make sure to reuse as much code as possible. Try to use this method in the CarefulNameTracker class. Make sure to indent the code correctly.

    def add(self, name):
        if not (' ' in name or '-' in name):
            super().add(name)            

Does the code work as expected? Did the CarefulNameTracker have any names added to it, and were those name as expected?

Try to answer the following questions:

  1. Which method is called when girls_second.add(name) is executed? Why?

  2. Did the add method in the NameTracker got executed? How do you know? Which code made it happen?

  3. What do you thing the super() function does?

The same principle can be applied to the constructor __init__. It is very common that child class has some additional attributes that need to be initialized.

Imagine that we want the CarefulNameTracker to have an additional attribute forbidden_characters, which is a list of characters that are not allowed in the names. We want to initialize this list to [' ', '-'] in the constructor. Look now at this code, and add it to the class definition, of the CarefulNameTracker class.

    def __init__(self, forbidden_characters):
        self.forbidden_characters = forbidden_characters
        super().__init__()

Initialize a name tracker as girls_second = CarefulNameTracker([' ', '-', '_', '&']). Try to access the forbidden_characters attribute of the girls_second object. Try to access the attributes longest and shortest.

Try removing the super().__init__() line from the constructor of CarefulNameTracker. Can you still access the longest and shortest attributes of the girls_second object? You were able to access them before adding __init__ to the CarefulNameTracker class. What do you think happened?

Try adding the names 'Pippi', 'Pippilotta Rullegardinia', and 'Pippi_Longstocking' to the girls_second object. Which names are added to the tracker? Why?

You have probably realized that we have not yet implemented the check for the forbidden characters. Modify the add method of the CarefulNameTracker as show below.

    def add(self, name):
        valid = True
        for char in self.forbidden_characters:
            if char in name:
                valid = False
        if valid:
            super().add(name)    

You have seen that child class inherits the methods from the parent class. Let’s investigate whether the opposite is true. Add this method to the CarefulNameTracker class.

    def print_lengths(self, name):
        print('Lengths of names tracked so far')
        print('  Longest :', len(self.longest))
        print('  Shortest:', len(self.shortest))

Try now to call this method from the parent class.

girls_test = NameTracker()
girls_test.add('Mary')
girls_test.print_lengths()

Does inheritance work in both directions?

Prep 11.3: Special Methods#

Look at this definition for the Time class.

class Time:
    
    def __init__(self, hours, minutes):
        self.hours = hours
        self.minutes = minutes
    
    def increment_hours(self, ):
        self.hours += 1
        if self.hours == 24:
            self.hours = 0
    
    def increment_minutes(self, ):
        self.minutes += 1
        if self.minutes == 60:
            self.minutes = 0
            self.increment_hours()
    
    def print(self):
        print(f"{self.hours:02}:{self.minutes:02}")
        

Predict what the following code will do, assuming that the Time class has been defined as shown above. Run the code to verify your prediction.

my_time = Time(13, 55)
for i in range(10):
    my_time.increment_minutes()
    my_time.print()

Now try to run the following code, again assuming that you have defined Time and my_time as shown above.

print(my_time)
print(str(my_time))

You have already seen that the text displayed by the build-in print function seems somewhat useless, and so does the string obtained when we call str(my_time). We made our own print method, but it is not used when we call print(my_time).

It turns out that the build-in print and str() functions both call a __str__() method, which is built into all classes in Python. The default behavior of the __str__() method for the Time class is not very useful for us.

Add the following method to the Time class, and make sure to indent it correctly.

def __str__(self):
        return f"{self.hours:02d}:{self.minutes:02d}"

Now try printing my_time again. Try also to print str(my_time). What is the result now?

Now, the str() function has a customized behavior for the Time class. This is sometimes called overloading.

Remove the print method from the definition of the Time. What happens now if you ask for my_time.print()? What happens if you ask for print(my_time)

Prep 11.4: Operator Overloading#

You have seen that by overriding (customizing) __str__() method, we can change the behavior of the str() function. It turns out that you can do similar to change the behavior of operators like +, -, *, /, ==, <, >.

Let’s start by investigating what == gives for two Time objects.

first_time = Time(13, 45)
second_time = Time(13, 45)
print(first_time==first_time)
print(first_time==second_time)
print(second_time==second_time)

This is not useful. The default behavior checks whether the memory addresses for the two objects are equal.

Add this method to the definition of Time.

    def __eq__(self, other):
        return (self.hours == other.hours) and (self.minutes == other.minutes)

Try now comparing first_time and second_time. Does it work differently than before? Define third_time = Time(8, 15) and compare that against first_time.

Let’s now try to overload operator +. Check the default behavior.

print(first_time + second_time)

Is there a default behavior for the addition of two Time objects?

Add this method to the class Time.

    def __add__(self, other):
        minutes = self.minutes + other.minutes
        hours = self.hours + other.hours
        if minutes >= 60:
            minutes = minutes - 60
            hours = hours + 1
        if hours >= 24:
            hours = hours - 24
        return Time(hours, minutes)

Now try adding two Time objects. Does it work as expected?

Self quiz#

Question 11.1#

Which of the following is the correct way to define a class Dog that inherits from a class Animal?

Question 11.2#

What is the relationship between the class Bird and the class Parrot in the following code snippet?

class Bird:
    pass
class Parrot(Bird):
    pass

Question 11.3#

What is printed when the following code is executed?

class Animal:
    def sound(self):
        return "Some sound"
class Dog(Animal):
    def sound(self):
        return "Bark"
print(Dog().sound())

Question 11.4#

What is printed when the following code is executed?

class Animal:
    def sound(self):
        return "Some sound"
class Dog(Animal):
    def sound(self):
        return "Bark"
print(Animal().sound())

Question 11.5#

What is printed when the following code is executed?

class Animal:
    def sound(self):
        return "Some sound"
class Dog(Animal):
    pass
print(Dog().sound())

Question 11.6#

What is printed when the following code is executed?

class Animal:
    def sound(self):
        return "Some sound"
class Dog(Animal):
    pass
print(Animal().sound())

Question 11.7#

What is printed when the following code is executed?

class Animal:
    pass
class Dog(Animal):
    def sound(self):
        return "Bark"
print(Dog().sound())

Question 11.8#

What is printed when the following code is executed?

class Animal:
    pass
class Dog(Animal):
    def sound(self):
        return "Bark"
print(Animal().sound())

Question 11.9#

What is printed when the following code is executed?

class One:
    def say(self):
        print('Hi')
class Two(One):
    def say(self):
        super().say()
        super().say()
Two().say()

Question 11.10#

What is printed when the following code is executed?

class Single:
    def __init__(self):
        self.one = 1
class Double(Single):
    def __init__(self):
        self.two = 2
print(Double().one)

Question 11.11#

What is printed when the following code is executed?

class Single:
    def __init__(self):
        self.one = 1
class Double(Single):
    def __init__(self):
        self.one = 2
        super().__init__()
print(Double().one)

Question 11.12#

Which special method should be defined to customize how class objects are printed by the print() function?

Question 11.13#

What is printed when the following code is executed?

class Star:
    def __init__(self, name):
        self.name = name
    def __str__(self):
        return f'Name: {self.name}'
sun = Star('Sun')
print(sun)

Question 11.14#

In what order will the boolean values be printed when the following code is executed?

class Number:
    def __init__(self, nr):
        self.value = nr
a = Number(5)
b = Number(6)
c = Number(5)
print(a == a)
print(a == b)
print(a == c)

Question 11.15#

In what order will the boolean values be printed when the following code is executed?

class Number:
    def __init__(self, nr):
        self.value = nr
    def __eq__(self, other):
        return self.value == other.value
a = Number(5)
b = Number(6)
c = Number(5)
print(a == a)
print(a == b)
print(a == c)

Question 11.16#

In what order will the boolean values be printed when the following code is executed?

class Number:
    def __init__(self, nr):
        self.value = nr
    def __eq__(self, other):
        return abs(self.value - other.value) < 3
a = Number(5)
b = Number(6)
c = Number(5)
print(a == a)
print(a == b)
print(a == c)

Question 11.17#

What is printed when the following code is executed?

class Number:
    def __init__(self, nr):
        self.value = nr
a = Number(5)
b = Number(6)
c = a + b
print(c.value)

Question 11.18#

Which method is used to overload the + operator in Python?

Question 11.19#

What is printed when the following code is executed?

class Number:
    def __init__(self, nr):
        self.value = nr
    def __add__(self, other):
        return self.value + other.value
a = Number(5)
b = Number(6)
c = a + b
print(c.value)

Question 11.20#

What is printed when the following code is executed?

class Number:
    def __init__(self, nr):
        self.value = nr
    def __add__(self, other):
        return Number(self.value + other.value)
a = Number(5)
b = Number(6)
c = a + b
print(c.value)