Object Oriented Programming: inheritance#
In the last unit 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
is, however, very general, and it is unlikely to be used alone (a “pet” is not 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 it is 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 cannot 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 nor 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)
Cell In[8], line 2
1 p = Pet('PetName', 10)
----> 2 p.say_name_loudly() # this raises an AttributeError
Cell In[1], line 16, in Pet.say_name_loudly(self)
15 def say_name_loudly(self):
---> 16 return self.say_name().upper()
AttributeError: 'Pet' object has no attribute 'say_name'
What happened here? It is correct, the class “Pet” has no say_name
method!
In fact, this 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)
Cell In[11], line 2
1 p = Pet('PetName', 10)
----> 2 p.say_name_loudly() # this now raises a NotImplementedError
Cell In[10], line 22, in Pet.say_name_loudly(self)
21 def say_name_loudly(self):
---> 22 return self.say_name().upper()
Cell In[10], line 16, in Pet.say_name(self)
15 def say_name(self):
---> 16 raise NotImplementedError('This method should be implemented by subclasses of Pet')
NotImplementedError: This method should be implemented by subclasses of Pet
The difference is subtle, but important: with this method, we define a new “contract” of what 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
). With these “protocols” (or contracts) 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”, that is, 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.