Inheritance#

Inheritance is an important principle in object-oriented programming. Although we may reach our aims without using inheritance in our own code, it’s important to know the concept and corresponding syntax constructs to understand other people’s code. We’ll meet inheritance related code in

  • the documentation of modules and packages,

  • when customizing and extending library code.

Related exercises: Object-Oriented Programming.

Principles of Object-Oriented Programming#

Up to now we only considered three of four fundamental OOP principles:

  • encapsulation: code is structured into small units (write functions for different tasks and group them together with relevant data into objects)

  • abstraction:

    • similar tasks are handled in one and the same way (instead of several unrelated objects we define a class and instantiate several objects sharing the same interface)

    • implementation details are hidden behind interfaces (a class defines an interface by providing methods and (non-private) member variables; concrete implementation of methods and usage of member variables are not of importance and invisible from outside)

  • polymorphism (functions and method accept different sets and types of arguments, that is, interfaces are flexible; something a Python programmer does not care about because it’s a very native Python feature, in contrast to C/C++, for instance)

The missing principle is

  • inheritance (create new classes by extending existing classes)

Idea and Syntax#

Inheritance is a technique to create new classes by extending and/or modifying existing ones. A new class may have a base class. The new class inherits all methods and member variables from its base class and is allowed to replace some of the methods and to add new ones. Syntax:

class NewClass(BaseClass):
    
    def additional_method(self, args):
        # do something
    
    def replacement_for_base_class_method(self, args):
        # do something

The only difference compared to usual class definitions is in the first line, where a base class can be specified. Defining methods works as before. If the method name does not exist in the base class, then a new method is created. If it already exists in the base class, the new one is used instead of the base class’ method. In addition to explicitly defined methods, the new class inherits all methods from the base class.

Inheritance saves time for implementation and leads to a well structured class hierachy. Object-oriented programming is not solely about defining classes (encapsulation and abstraction), but also about defining meaningful relations between classes, thus, to some extent mapping real world to source code.

Example#

Real-life examples of inheritance often are quite involved. For illustration we use a pathological example resampling relations between geometric objects.

Imagine a vector drawing program. Each geometric object shall be represented as object of a corresponding class. Say quadrangles are objects of type Quad, paraxial rectangles are objects of type ParRect and so on. Let’s start with class Point:

class Point:
    ''' represent a geometric point in two dimensions '''
    
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __str__(self):
        return f'({self.x}, {self.y})'

Now we define Quad:

class Quad:
    ''' represent a quadrangle '''
    
    def __init__(self, a, b, c, d):
        ''' make a quad from 4 Point objects '''
        self._a = a
        self._b = b
        self._c = c
        self._d = d
    
    def get_points(self):
        return (self._a, self._b, self._c, self._d)
    
    def __str__(self):
        return f'quadrangle with points' \
               f'({self._a.x}, {self._a.y}), ({self._b.x}, {self._b.x}), ' \
               f'({self._c.x}, {self._c.x}), ({self._d.x}, {self._d.x})'

The member variables _a, _b, _c, _d are hidden since we consider them implementation details. If the user wants access to the four points making the quadrangle, get_points should be called. This way we are free to store the quadrangle in a different format if it seems resonable in future when extending class’ functionality. This is a design decision and is in no way related to inheritance.

Here comes ParRect: Note that a paraxial rectangle is defined by two Points.

class ParRect(Quad):
    
    def __init__(self, a, c):
        ''' make a paraxial rect from two points '''
        b = Point(c.x, a.y)
        d = Point(a.x, c.y)
        super().__init__(a, b, c, d)
        
    def __str__(self):
        return f'paraxial rect with points ({self._a.x}, {self._a.y}), ({self._c.x}, {self._c.x})'
    
    def area(self):
        ''' return the rect's area '''
        return abs(self._b.x - self._a.x) * abs(self._d.y - self._a.y)

The ParRect class inherits everything from Quad. It has a new constructor with fewer arguments than in Quad, but calls the constructor of Quad.

Important

The built-in function super in principle returns self (that is, the current object), but redirects method calls to the base class.

We reimplement __str__ and add the new method area. Note that ParRect objects have member variables _a, _b, _c, _d since those are created by the Quad constructor we call in the ParRect constructor. Also the get_points method is a member of ParRect since it gets inherited from Quad.

parrect = ParRect(Point(0, 0), Point(2, 1))

print(parrect)

print('area: {}'.format(parrect.area()))

a, b, c, d = parrect.get_points()
print('all points:', a, b, c, d)
paraxial rect with points (0, 0), (2, 2)
area: 2
all points: (0, 0) (2, 0) (2, 1) (0, 1)

Type Checking#

Note that isinstance also returns True if we check against a base class of an object’s class. In other words, each object is an instance of its class and of all base classes.

print(isinstance(parrect, ParRect))
print(isinstance(parrect, Quad))
True
True

In contrast, type checking with type returns False if checked against the base class:

print(type(parrect) == ParRect)
print(type(parrect) == Quad)
True
False

Every Class is a Subclass of object#

In Python there is a built-in class object and every newly created class automatically becomes a subclass of object. The line

class my_new_class:

is equivalent to

class my_new_class(object):

and also to

class my_new_class():

by the way.

To see this in code we might use the built-in function issubclass. This function returns True if the first argument is a subclass of the second.

class MyClass:
    
    def __init__(self):
        print('Here is __init__()!')

print(issubclass(MyClass, object))
True

Alternatively, we may have a look at the __base__ member variable, which stores the base class:

print(MyClass.__base__)
<class 'object'>

Objects of type object do not have real functionality. The object class provides some auxiliary stuff used by the Python interpreter for managing classes and objects.

obj = object()
dir(obj)
['__class__',
 '__delattr__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__']

Virtual Methods#

Python does not allow for directly implementing so called virtual methods. A virtual method is a method in a base class which has to be (re-)implemented by each subclass. The typical situation is as follows: The base class implements some functionality, which for some reason has to call a method of a subclass. How to guarantee that the subclass provides the required method?

In Python a virtual method is a usual method which raises a NotImplementedError, a special exception type like ZeroDivisionError and so on. If everything is correct, this never happens, because the subclass overrides the base class’ method. But if the creator of the subclass forgets to implement the method required by the base class, an error message will be shown.

Multiple Inheritance#

A class may have several base classes. Just provide a tuple of base classes in the class definition:

class my_class(base1, base2, base3):

The new class inherits everything from all its base classes.

If two base classes provide methods with identical names, the Python interpreter has to decide which one to use for the new class. There is a well-defined algorithm for this decision. If you need this knowledge someday, watch out for method resolution order (MRO).

Exceptions Inherit from Exception#

Up to now we used built-in exceptions only, like ZeroDivisionError. But now we have gathered enough knowledge to define new exceptions. Exeptions are classes as we noted before. Each exception is a direct or indirect subclass of BaseException. Almost all exceptions also are a subclass of Exception, which itself is a direct subclass of BaseException. See Exception hierarchy for exceptions’ genealogy.

If we want to introduce a new exception, we have to create a new subclass of Exception.

class SomeError(Exception):
    
    def __init__(self, message):
        self.message = message
    
def my_function():
    print('I do something...')
    raise SomeError('Meaty error message!!!')
    
print('Entering my_function...')
try:
    my_function()
except SomeError as error:
    print('Exception SomeError: {}'.format(error.message))
Entering my_function...
I do something...
Exception SomeError: Meaty error message!!!

At first we define a new exception class SomeError. The constructor takes an error message and stores it in the member variable message. The function my_function raises SomeError. The main program catches this exception and prints the error message. The as keyword provides access to a concrete SomeError object containing the error message.

Note that except SomeBaseClass also catches all subclasses of SomeBaseClass. If we want to handle a subclass exception separately we have to place its except line above the base class’s except line. Contrary, a subclass except never handles a base class exception.