6.3. Extending Types#

6.3.1. Inheritance#

Inheritance is the process by which one class takes on the attributes and methods of another. Newly formed classes are called child classes, and the classes that child classes are derived from are called parent classes.

It’s important to note that child classes override or extend the functionality (e.g., attributes and behaviors) of parent classes. In other words, child classes inherit all of the parent’s attributes and behaviors but can also specify different behavior to follow. The most basic type of class is an object, which generally all other classes inherit as their parent.

Inheritance is a powerful feature in object oriented programming. It refers to defining a new class with little

or no modification to an existing class. The new class is called derived (or child) class and the one from which it is derived is called the base (or parent) class.

The derived class inherits all the features from the base class and can have additional features of its own.

class ParentClass:
    # class definition
    pass

class ChildClass(ParentClass):
    # class definition
    pass

In the above example, ChildClass is derived from ParentClass. The derived class ChildClass inherits all the features from the base class ParentClass.

6.3.1.1. Example: Rectangle and Square#

In this example, we have a class Rectangle and a class Square that inherits from Rectangle.

class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

    def perimeter(self):
        return 2 * (self.length + self.width)

class Square(Rectangle):

    def __init__(self, side):
        super().__init__(side, side)

6.3.2. Polymorphism#

Most of the methods we have written only work for a specific type. When you create a new object, you write methods that operate on that type.

But there are certain operations that you will want to apply to many types, such as the arithmetic operations in the previous sections. If many types support the same set of operations, you 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 method 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 Points:

p1 = Point(3, 4)
p2 = Point(5, 7)
print(multadd (2, p1, p2)), print(multadd (p1, p2, 1))
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [3], in <cell line: 1>()
----> 1 p1 = Point(3, 4)
      2 p2 = Point(5, 7)
      3 print(multadd (2, p1, p2)), print(multadd (p1, p2, 1))

NameError: name 'Point' is not defined

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

A function like this that can take parameters with different types is called polymorphic.

As another example, consider the method 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 make a copy of the list before reversing it. That way, this method doesn’t modify the list it gets as a parameter.

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

myList = [1, 2, 3, 4]
front_and_back(myList)

Of course, we intended to apply this function to lists, so it is not 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 the fundamental rule of polymorphism: 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 method include copy, reverse, and print.

copy works on any object, and we have already written a __str__ method for Points, so all we need is a reverse method in the Point class:

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

Then we can pass Points to front_and_back:

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

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

6.3.3. Object-oriented features#

Python is an object-oriented programming language, which means that it provides features that support object-oriented programming.

It is not easy to define object-oriented programming, but we have already seen some of its characteristics:

  1. Programs are made up of object definitions and function definitions, and most of the computation is expressed in terms of operations on objects.

  2. Each object definition corresponds to some object or concept in the real world, and the functions that operate on that object correspond to the ways real-world objects interact.

For example, the Time class defined in the Classes and functions chapter corresponds to the way people record the time of day, and the functions we defined correspond to the kinds of things people do with times. Similarly, the Point and Rectangle classes correspond to the mathematical concepts of a point and a rectangle.

So far, we have not taken advantage of the features Python provides to support object-oriented programming. Strictly speaking, these features are not necessary. For the most part, they provide an alternative syntax for things we have already done, but in many cases, the alternative is more concise and more accurately conveys the structure of the program.

For example, in the Time program, there is no obvious connection between the class definition and the function definitions that follow. With some examination, it is apparent that every function takes at least one Time object as a parameter.

This observation is the motivation for methods. We have already seen some methods, such as keys and values, which were invoked on dictionaries. Each method is associated with a class and is intended to be invoked on instances of that class.

Methods are just like functions, with two differences:

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

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

In the next few sections, we will take the functions from previous chapters and transform them into methods. This transformation is purely mechanical; you can do it simply by following a sequence of steps. If you are comfortable converting from one form to another, you will be able to choose the best form for whatever you are doing.

6.3.5. Another example#

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 Time(object):
    #previous method definitions here...

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

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

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

The transformation is purely mechanical - we move the method definition into the class definition and change the name of the first parameter.

Now we can invoke increment as a method.

current_time.increment(500)

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

6.3.6. A more complicated example#

The after function is slightly more complicated because it operates on two Time objects, not just one. We can only convert one of the parameters to self; the other stays the same:

class Time(object):
    #previous method definitions here...

    def after(self, time2):
        if self.hour > time2.hour:
            return True
        if self.hour < time2.hour:
            return False

        if self.minute > time2.minute:
            return True
        if self.minute < time2.minute:
            return False

        if self.second > time2.second:
            return True
        return False

We invoke this method on one object and pass the other as an argument:

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

You can almost read the invocation like English: If the done-time is after the current-time, then…

6.3.7. Optional arguments#

We have seen built-in functions that take a variable number of arguments. For example, string.find can take two, three, or four arguments.

It is possible to write user-defined functions with optional argument lists. For example, we can upgrade our own version of find to do the same thing as string.find.

This is the original version:

def find(str, ch):
    index = 0
    while index < len(str):
        if str[index] == ch:
            return index
        index = index + 1
    return -1

This is the new and improved version:

def find(str, ch, start=0):
    index = start
    while index < len(str):
        if str[index] == ch:
            return index
        index = index + 1
    return -1

The third parameter, start, is optional because a default value, 0, is provided. If we invoke find with only two arguments, we use the default value and start from the beginning of the string:

find("apple", "p")

If we provide a third parameter, it overrides the default:

find("apple", "p", 2), find("apple", "p", 3)

6.3.8. The initialization method#

The initialization method is a special method that is invoked when an object is created. The name of this method is __init__ (two underscore characters, followed by init, and then two more underscores). An initialization method for the Time class looks like this:

class Time(object):
    def __init__(self, hours=0, minutes=0, seconds=0):
        self.hours = hours
        self.minutes = minutes
        self.seconds = seconds

There is no conflict between the attribute self.hours and the parameter hours. Dot notation specifies which variable we are referring to.

When we invoke the Time constructor, the arguments we provide are passed along to init:

current_time = Time(9, 14, 30)
current_time.print_time()

Because the parameters are optional, we can omit them:

current_time = Time()
current_time.print_time()

Or provide only the first parameter:

current_time = Time (9)
current_time.print_time()

Or the first two parameters:

current_time = Time (9, 14)
current_time.print_time()

Finally, we can provide a subset of the parameters by naming them explicitly:

current_time = Time(seconds = 30, hours = 9)
current_time.print_time()

6.3.9. Points revisited#

Let’s rewrite the Point class from the Dictionaries chapter in a more object- oriented style:

class Point(object):
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __str__(self):
        return '(' + str(self.x) + ', ' + str(self.y) + ')'

The initialization method takes x and y values as optional parameters; the default for either parameter is 0.

The next method, __str__, returns a string representation of a Point object. If a class provides a method named __str__, it overrides the default behavior of the Python built-in str function.

p = Point(3, 4)
str(p)

Printing a Point object implicitly invokes __str__ on the object, so defining __str__ also changes the behavior of print:

p = Point(3, 4)
print(p)

When we write a new class, we almost always start by writing __init__, which makes it easier to instantiate objects, and __str__, which is almost always useful for debugging.


6.3.10. Glossary#

object-oriented language#

A language that provides features, such as user-defined classes and inheritance, that facilitate object-oriented programming.

object-oriented programming#

A style of programming in which data and the operations that manipulate it are organized into classes and methods.

method#

A function that is defined inside a class definition and is invoked on instances of that class. :override:: To replace a default. Examples include replacing a default parameter with a particular argument and replacing a default method by providing a new method with the same name.

initialization method#

A special method that is invoked automatically when a new object is created and that initializes the object’s attributes.

operator overloading#

Extending built-in operators ( +, -, *, >, <, etc.) so that they work with user-defined types.

dot product#

An operation defined in linear algebra that multiplies two Points and yields a numeric value.

scalar multiplication#

An operation defined in linear algebra that multiplies each of the coordinates of a Point by a numeric value.

polymorphic#

A function that can operate on more than one type. If all the operations in a function can be applied to a type, then the function can be applied to a type.

6.3.11. Exercises#

  1. Convert the function convertToSeconds:

def convertToSeconds(t):
    minutes = t.hours * 60 + t.minutes
    seconds = minutes * 60 + t.seconds
    return seconds

to a method in the Time class.

  1. Add a fourth parameter, end, to the find function that specifies where to stop looking. Warning: This exercise is a bit tricky. The default value of end should be len(str), but that doesn’t work. The default values are evaluated when the function is defined, not when it is called. When find is defined, str doesn’t exist yet, so you can’t find its length.