Inheritance
https://www.youtube.com/watch?v=JWzeEHlNu7k
Consider this...
We want to create a class for Cat
(with properties like claw_sharpness
, and methods like knead()
).
We also want to create a class for Lion
, which has all the functionality of Cat
, but additional things like a roar()
method.
Subclasses and superclasses
A subclass (or child class) is a more specific version of a superclass (parent class). The subclass inherits all the methods and attributes from the superclass and then adds more that are specific to it.
The syntax is, when declaring the subclass, we put the superclass's name in parentheses: class Lion(Cat):
(The superclass's syntax is unchanged.)
For example, we might have a superclass Student
with subclasses UndergraduateStudent
and GraduateStudent
. The Student
class will have methods like attend_lab()
and register_classes()
. The UndergraduateStudent
class will inherit all of those, and it will add methods like switch_major()
, which are specific to undergraduate students.
class Student():
def __init__(self, student_id: str, major: str):
self.id = student_id
self.major = major
self.courses: Set[str] = set()
def attend_lab(self, course_id: str) -> None:
if course_id in self.courses:
print(f'Attending {course_id}\' lab')
def register_courses(self, courses: Set[str]) -> None:
self.courses |= courses
class UndergraduateStudent(Student):
def change_major(self, new_major: str) -> None:
self.major = new_major
Subclasses override methods from their superclasses
A subclass inherits all of the methods and instance variables from its superclass.
- It can add more (like
UndergraduateStudent
addingchange_major()
orLion
addingroar()
) - It can override the ones that it inherits from the superclass
- Actually, it inherits all of the methods and instance variables except those that are "private" with two underscores. (Public ones and those named with a single underscore are inherited.)
Here, we see that all cats knead and eat tuna or chicken, but lions can also roar and eat zebras:
class Cat:
def __init__(self, name: str):
self.name = name
self.food: List[str] = ['tuna', 'chicken']
def knead(self) -> None:
print('Kneading')
def eat(self, food: str) -> None:
if food in self.food:
print(f'Eating {food}')
class Lion(Cat):
def roar(self) -> None:
print('Roaring')
def eat(self, food: str) -> None:
if food in self.food + ['zebra']:
print(f'Eating {food}')
class HouseCat(Cat):
def purr(self) -> None:
print('Purring')
Poll: What happens?
class Button:
def __init__(self, fancy: bool):
self.fancy = fancy
class Shirt:
def __init__(self, size: int):
self.size = size
self.buttons: List[Button] = []
def add_button(self, button: Button) -> None:
self.buttons.append(button)
def fold(self) -> None:
print('Folding')
class FormalShirt(Shirt):
def add_button(self, button: Button) -> None:
if button.fancy:
self.buttons.append(button)
s = FormalShirt(500)
s.fold()
- It raises an error
- Cannot do that - won't run
- It calls
Shirt
'sfold()
method - It does nothing
Using super()
There is some redundancy in this code:
class Shirt:
def __init__(self, size: int):
self.size = size
self.buttons: List[Button] = []
def add_button(self, button: Button) -> None:
self.buttons.append(button)
class FormalShirt(Shirt):
def add_button(self, button: Button) -> None:
if button.fancy:
self.buttons.append(button)
The line self.buttons.append(button)
appears twice. It's a very small amount of redundancy here, but if add_button()
was a complicated function in Shirt
, we would not want to rewrite it in FormalShirt
. We also wouldn't want the same code in multiple places because updating that method would require updating it in both places -- which is prone to typos and bugs.
Calling a superclass's method
We can instead directly call Shirt
's add_button()
from within FormalShirt
using super()
:
class Shirt:
def __init__(self, size: int):
self.size = size
self.buttons: List[Button] = []
def add_button(self, button: Button) -> None:
self.buttons.append(button)
class FormalShirt(Shirt):
def add_button(self, button: Button) -> None:
if button.fancy:
super().add_button(button)
This code does the same thing as before, but it has less redundancy. If we update the way that buttons are added, we only need to update it in the Shirt
class, and the changes will propogate down to FormalShirt
.
We could do the same thing for our Cat
and Lion
's eat()
methods:
class Cat:
def __init__(self, name: str):
self.name = name
self.food: List[str] = ['tuna', 'chicken']
def knead(self) -> None:
print('Kneading')
def eat(self, food: str) -> None:
if food in self.food:
print(f'Eating {food}')
class Lion(Cat):
def roar(self) -> None:
print('Roaring')
def eat(self, food: str) -> None:
if food in ['zebra']:
super().eat(food)
But there is an even more convenient way to handle this one...
Calling a superclass's constructor
We can modify the self.food
attribute so that the eat()
method inherited from Cat
works by default in Lion
.
The self.food
attribute is defined in Cat
's constructor, so we need to overwrite it with a new constructor in Lion
... one that executes Cat
's constructor first, and then adds 'zebra'
to self.food
:
class Cat:
def __init__(self, name: str):
self.name = name
self.food: List[str] = ['tuna', 'chicken']
def knead(self) -> None:
print('Kneading')
def eat(self, food: str) -> None:
if food in self.food:
print(f'Eating {food}')
class Lion(Cat):
def __init__(self, name: str):
super().__init__(name)
self.food += ['zebra']
def roar(self) -> None:
print('Roaring')
lion = Lion('Mini')
lion.eat('zebra') # Eating zebra
Poll: What does this output?
class Cat:
def __init__(self, name: str):
self.name = name
def knead(self) -> None:
print('Kneading')
class Lion(Cat):
def knead(self) -> None:
print('I am a lion')
super().knead()
lion: Cat = Lion('Mini')
lion.knead()
-
Kneading
I am a lion
Kneading
//I am a lion
I am a lion
//Kneading
Source: https://www.reddit.com/r/ProgrammerHumor/comments/60lm55/oop_what_actually_happens
Everything is a subclass of object
Every class that we write is by default a subclass of object
.
These two class definitions are equivalent:
class MyClass: pass
class MyClass(object): pass
This is why, as we saw in Lecture 4, every class has a built-in __str__()
method: they inherit it from object
.
There are many methods that each class inherits from object
. One is __eq__(self, other) -> bool
, which defines whether two objects are equal to each other.
With our current definition of Student
:
s1 = Student('s1', 'CS')
s2 = Student('s1', 'CS')
print(s1 == s2) # False
And after adding the __eq__()
method:
class Student():
def __init__(self, student_id: str, major: str):
self.id = student_id
self._major = major
def __eq__(self, other: object) -> bool:
if not isinstance(other, Student):
raise TypeError
return self.id == other.id
s1 = Student('s1', 'CS')
s2 = Student('s1', 'CS')
print(s1 == s2) # True
Poll: Why is this bad?
class Cat:
def __init__(self, name: str):
self.name = name
self.food: List[str] = ['tuna', 'chicken']
def __eq__(self, other: object) -> bool:
if not isinstance(other, Cat):
raise ValueError
return other.name in self.food
- It's possible for
cat_a
to equalcat_b
today, but forcat_a
to not be equal tocat_b
tomorrow (with no code changes) - It's possible for
cat_a
to not equal itself - It's possible for
cat_a
to equalcat_b
, andcat_b
to not equalcat_a
- All cats will be equal, making the
__eq__()
function useless
We will see more of these functions inherited from object
later on.
UML diagrams
A UML (Unified Modeling Language) diagram visually shows us the classes and their relationships in a program.
Here is a box in a UML diagram showing us the class Cat
. It says that the class has a str
attribute called name
and two methods called knead()
and eat(food: str)
.
Generally, the top part of the box contains the class's name, the middle part contains its attributes, and the lower part contains its methods. +
indicates that a method or attribute is publicly available, as opposed to -
, which indicates that it is private (two underscores __
).
To depict a subclass / superclass relationship between classes, we draw an arrow from the subclass to the superclass:
The Liskov Subsitution Principle
We've seen a few examples of design principles, such as the Single Responsibility Principle. Here's another one.
The Liskov Substitution Principle states that "If S is a subtype of T, then objects of T can be substituted with objects of S without altering any of expected functionality."
In other words, a member of the subclass can be used wherever a member of the superclass is required.
For example, if you wanted coffee, and you received an espresso, you would be satisfied because the coffee hierarchy follows the Liskov Substitution Principle. Espresso is an appropriate subclass of coffee.
Breaking the Liskov Substitution Principle using Python code could look like a subclass overriding a method in such a way that it doesn't accomplish the original goal anymore.
https://www.dmv.ca.gov/portal/driver-licenses-identification-cards/real-id/what-is-real-id/ You may have heard about the Real ID requirements that went into effect in the United States on May 7, 2025. In order to pass through TSA at the airport (even for travel entirely within the United States), people need to carry a Real ID. (This policy was planned far in advance, and most IDs in the United States fit this requirement by the time it came into effect.)
Poll: Which are true?
- The Real ID requirements follow the Single Responsibility Principle because one object covers multiple uses (TSA and driving)
- The Real ID requirements break the Single Responsibility Principle because the same object is used for multiple unrelated activities (TSA and driving)
- The Real ID requirements follow the Liskov Substitution Principle because anywhere that the old ID is used, the new (more specific one) can be used instead
- The Real ID requirements break the Liskov Substitution Principle because there are things that the Real ID can do that the old ID (less specific one) cannot