Object Oriented Programming: inheritance#

Last week we learned the basic structure of python classes and introduced new semantics: classes, instances, methods, attributes… Today we learn about a very important feature of most OOP languages: inheritance.

Class inheritance: introduction#

Inheritance is a core concept of OOP. It allows a subclass (also called “child class”) to override or extend methods and attributes from a base class (also called “parent class”). In other words, child classes inherit all of the parent’s attributes and behaviors but can also specify new behaviors or replace old ones.

This is best shown with an example: let’s make the Cat and Dog class inherit from the Pet class.

class Pet:

    # Initializer
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight

    def eat_food(self, food):
        self.weight += food

    @property
    def weight_lbs(self):
        return self.weight / 0.45359237

    def say_name_loudly(self):
        return self.say_name().upper()


class Cat(Pet):

    # Class attribute
    language = 'Meow'

    # Method
    def say_name(self):
        return '{}, my name is {} and I am nice!'.format(self.language, self.name)


class Dog(Pet):

    # Class attribute
    language = 'Woof'

    # Method
    def say_name(self):
        return '{}, my name is {} and I smell funny!'.format(self.language, self.name)

Let’s advance through this example step by step.

First, let’s have a look at the Pet class. It is a standard class defined the exact same way as in the previous lecture. Therefore, it can be instantiated and will work as expected:

p = Pet('PetName', 10)
p.weight_lbs
22.046226218487757

The functionality of the class Pet however is very general, and it is unlikely to be used alone (a “pet” isn’t specific enough to most people: is it a cat, a fish, a dog?). We used this class to implement the general functionality supported by all pets: they have a name and a weight, regardless of their species.

Now comes the important part: the Cat and Dog classes make use of these functionalities by inheriting from the Pet parent class. This inheritance is formalized in the class definition class Cat(Pet). The code of the two child classes is remarkably simple: it adds only a new functionality to the ones already inherited from Pet. For example:

c = Cat('Kitty', 4)
c.say_name()
'Meow, my name is Kitty and I am nice!'

The Pet instance methods are still available:

c.eat_food(0.2)
c.weight
4.2
d = Dog('Charlie', 8)
d.say_name()
'Woof, my name is Charlie and I smell funny!'

There is a pretty straightforward rule for the behavior of child class instances: when the called method or attribute is available at the child class level, it will be used (even if also available at the parent class level: this is called overriding, and will be explained in the next unit); if not, use the parent class implementation.

This is exactly what happens in the code above: eat_food and weight are defined in the Pet class but are available for both Cat and Dog instances. say_name, however, is a child class instance method and can’t be used by Pet instances.

This relationship between parent and child classes can be formalized as following:

print('Is d a Dog?', isinstance(d, Dog))
print('Is d also a Pet?', isinstance(d, Pet))
print('Is d also a Cat?', isinstance(d, Cat))
Is d a Dog? True
Is d also a Pet? True
Is d also a Cat? False

However, a Pet is neither a Cat or a Dog:

print('Is p a Dog?', isinstance(p, Dog))
print('Or a Cat?', isinstance(p, Cat))
Is p a Dog? False
Or a Cat? False

So, what about the say_name_loudly method? Although available for Pet instances, calling it will raise an error:

p = Pet('PetName', 10)
p.say_name_loudly()  # this raises an AttributeError
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
/tmp/ipykernel_16995/349804252.py in <cell line: 2>()
      1 p = Pet('PetName', 10)
----> 2 p.say_name_loudly()  # this raises an AttributeError

/tmp/ipykernel_16995/1337416500.py in say_name_loudly(self)
     14 
     15     def say_name_loudly(self):
---> 16         return self.say_name().upper()
     17 
     18 

AttributeError: 'Pet' object has no attribute 'say_name'

What happened here? It’s correct, the class “Pet” has no say_name method!

In fact, it was intended behavior: since say_name_loudly is available to the child class instances, the method will work for them! See for instance:

d = Dog('Charlie', 8)
d.say_name_loudly()
'WOOF, MY NAME IS CHARLIE AND I SMELL FUNNY!'

This is a very typical use case for class inheritance in OOP: it allows code re-use. Here the method say_name_loudly() is the same for both Dogs and Cats, but the implementation of say_name() is different for each child class. This brings us to our next topic: interfaces.

Interfaces#

A further important use case for class inheritance in OOP is the possibility to define so-called interfaces (or protocols, which is a more general term). An interface can be seen as a set of functionalities that, once agreed upon, should be implemented by child classes. Taking the example above, let’s write the Pet class as an “interface” in python:

class Pet:

    # Initializer
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight

    def eat_food(self, food):
        self.weight += food

    @property
    def weight_lbs(self):
        return self.weight / 0.45359237

    def say_name(self):
        raise NotImplementedError('This method should be implemented by subclasses of Pet')

    def do_trick(self, treat):
        raise NotImplementedError('This method should be implemented by subclasses of Pet')

    def say_name_loudly(self):
        return self.say_name().upper()

    def do_trick_n_times(self, treats):
        for treat in treats:
            self.do_trick(treat)

The only difference to the previous example is the addition of the say_name and do_trick methods with an explicit error message. Now, if a user instantiates a Pet (which should not be allowed) and calls its functionalities, a proper error message is sent:

p = Pet('PetName', 10)
p.say_name_loudly()  # this now raises a NotImplementedError
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
/tmp/ipykernel_16995/1884487588.py in <cell line: 2>()
      1 p = Pet('PetName', 10)
----> 2 p.say_name_loudly()  # this now raises a NotImplementedError

/tmp/ipykernel_16995/1690547947.py in say_name_loudly(self)
     20 
     21     def say_name_loudly(self):
---> 22         return self.say_name().upper()
     23 
     24     def do_trick_n_times(self, treats):

/tmp/ipykernel_16995/1690547947.py in say_name(self)
     14 
     15     def say_name(self):
---> 16         raise NotImplementedError('This method should be implemented by subclasses of Pet')
     17 
     18     def do_trick(self, treat):

NotImplementedError: This method should be implemented by subclasses of Pet

The difference is subtle, but important: with this method, we define a new “contract” that subclasses will have to implement in order to be “good pets”: they have to be able to say their name (no argument) and do a trick (for one positional argument: treat). These “protocols” (or contracts) are clear: therefore, the methods say_name_loudly and do_trick_n_times make sense, even without a formal implementation of the say_name and do_trick methods. Let’s implement a class Cat which implements this Pet interface:

# This just imports a silly string -
# replace it with any string if you want to test it locally
from ascii_art import cat_trick


class Cat(Pet):

    # Class attribute
    language = 'Meow'

    # Method
    def say_name(self):
        return '{}, my name is {} and I am nice!'.format(self.language, self.name)

    def do_trick(self, treat):
        if treat > 0:
            print(cat_trick)
        else:
            print('No trick')
c = Cat('Kitty', 4)
c.do_trick_n_times([0, 1])
No trick
                               /\
                               \ \
                                \ \
                                / /
                               / /
                              _\ \_/\/\
                             /  *  \@@ =
                            |       |Y/
                            |       |~
                             \ /_\ /
                              \ //
                               |||
                              _|||_
                             ( / \ )

Take home points#

  • class inheritance allows to share code and functionality between classes which share one or more common functionalities

  • methods and attributes available at the parent class level are also available at the child class level, but not the other way around

  • parent classes can be used as “interfaces”, i.e. define protocols that child classes have to implement

This was only a (very) brief introduction to the concept of inheritance. In the next session we will discuss concrete (and more advanced) use cases for inheritance and OOP in general.