Inheritance
Inheritance allows you to define new classes based on existing ones. The new class "inherits" attributes and methods from the parent, so you don't rewrite the same code twice. Because we're lazy. Efficiently lazy.
Why bother?
- Reuse existing code
- Build specialized versions of general classes
- Organize related classes in hierarchies
The Basic Idea
Think about it:
- A Student is a Person
- A Student has everything a Person has (name, age, etc.)
- But a Student also has extra stuff (exams, courses, stress)
Instead of copy-pasting all the Person code into Student, we just say "Student inherits from Person" and add the extra bits.
Person (superclass / parent)
↓
Student (subclass / child)
Superclass = Parent class = Base class (the original)
Subclass = Child class = Derived class (the new one)
Level 1: Creating a Subclass
The Syntax
Put the parent class name in parentheses:
class Student(Person):
pass
That's it. Student now has everything Person has.
Let's Build It Step by Step
Step 1: The superclass
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
Step 2: Add a method
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def birthday(self):
self.age += 1
Step 3: Add __str__
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def birthday(self):
self.age += 1
def __str__(self):
return f"{self.name}, age {self.age}"
Step 4: Create the subclass
class Student(Person):
pass
Step 5: Test it
s = Student("Alice", 20)
print(s) # Alice, age 20
s.birthday()
print(s) # Alice, age 21
Student inherited __init__, birthday, and __str__ from Person. We wrote zero code in Student but it works!
Level 2: Adding New Stuff to Subclasses
A subclass can have:
- Additional instance variables
- Additional methods
- Its own constructor
Adding a Method
class Student(Person):
def study(self):
print(f"{self.name} is studying...")
s = Student("Bob", 19)
s.study() # Bob is studying...
s.birthday() # Still works from Person!
Adding Instance Variables
Students have exams. Persons don't. Let's add that.
class Student(Person):
def __init__(self, name, age):
self.name = name
self.age = age
self.exams = [] # New!
We just copy-pasted the parent's __init__ code. That's bad. What if Person changes? We'd have to update Student too.
Level 3: The super() Function
super() lets you call methods from the parent class. Use it to avoid code duplication.
Better Constructor
class Student(Person):
def __init__(self, name, age):
super().__init__(name, age) # Call parent's __init__
self.exams = [] # Add our own stuff
Breaking it down:
super().__init__(name, age)
This says: "Hey parent class, run YOUR __init__ with these values."
Then we add the Student-specific stuff after.
Test It
s = Student("Charlie", 21)
print(s.name) # Charlie (from Person)
print(s.exams) # [] (from Student)
When overriding __init__, usually call super().__init__(...) first, then add your stuff.
Level 4: Overriding Methods
If a subclass defines a method with the same name as the parent, it replaces (overrides) the parent's version.
Example: Override __str__
Parent version:
class Person:
def __str__(self):
return f"{self.name}, age {self.age}"
Child version (override):
class Student(Person):
def __init__(self, name, age):
super().__init__(name, age)
self.exams = []
def __str__(self):
return f"Student: {self.name}, age {self.age}"
p = Person("Dan", 30)
s = Student("Eve", 20)
print(p) # Dan, age 30
print(s) # Student: Eve, age 20
Using super() in Overridden Methods
You can extend the parent's method instead of replacing it entirely:
class Student(Person):
def __str__(self):
base = super().__str__() # Get parent's version
return base + f", exams: {len(self.exams)}"
s = Student("Frank", 22)
print(s) # Frank, age 22, exams: 0
Use super().method_name() when you want to extend the parent's behavior, not completely replace it.
Level 5: Inheritance vs Composition
Not everything should be a subclass! Choose wisely.
The "is-a" Test
Ask yourself: "Is X a Y?"
| Relationship | Is-a? | Use |
|---|---|---|
| Student → Person | "A student IS a person" ✅ | Inheritance |
| Exam → Student | "An exam IS a student" ❌ | Nope |
| Car → Vehicle | "A car IS a vehicle" ✅ | Inheritance |
| Engine → Car | "An engine IS a car" ❌ | Nope |
When to Use Objects as Instance Variables
If X is NOT a Y, but X HAS a Y, use composition:
# A student HAS exams (not IS an exam)
class Student(Person):
def __init__(self, name, age):
super().__init__(name, age)
self.exams = [] # List of Exam objects
# Exam is its own class, not a subclass
class Exam:
def __init__(self, name, score, cfu):
self.name = name
self.score = score
self.cfu = cfu
IS-A → Use inheritance
HAS-A → Use instance variables (composition)
Level 6: Class Hierarchies
Subclasses can have their own subclasses. It's subclasses all the way down.
Person
↓
Student
↓
ThesisStudent
class Person:
pass
class Student(Person):
pass
class ThesisStudent(Student):
pass
A ThesisStudent inherits from Student, which inherits from Person.
The Secret: Everything Inherits from object
In Python, every class secretly inherits from object:
class Person: # Actually: class Person(object)
pass
That's why every class has methods like __str__ and __eq__ even if you don't define them (they're just not very useful by default).
Putting It Together: Complete Example
The Exam Class
class Exam:
def __init__(self, name, score, cfu):
self.name = name
self.score = score
self.cfu = cfu
def __str__(self):
return f"{self.name}: {self.score}/30 ({self.cfu} CFU)"
The Person Class
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def birthday(self):
self.age += 1
def __str__(self):
return f"{self.name}, age {self.age}"
The Student Class
class Student(Person):
def __init__(self, name, age):
super().__init__(name, age)
self.exams = []
def pass_exam(self, exam):
self.exams.append(exam)
def __str__(self):
base = super().__str__()
if self.exams:
exam_info = ", ".join(str(e) for e in self.exams)
return f"{base}, exams: [{exam_info}]"
return f"{base}, no exams yet"
Using It
# Create a student
s = Student("Grace", 20)
print(s)
# Grace, age 20, no exams yet
# Pass an exam
s.pass_exam(Exam("Python", 28, 6))
print(s)
# Grace, age 20, exams: [Python: 28/30 (6 CFU)]
# Pass another exam
s.pass_exam(Exam("Databases", 30, 9))
print(s)
# Grace, age 20, exams: [Python: 28/30 (6 CFU), Databases: 30/30 (9 CFU)]
# Have a birthday
s.birthday()
print(s)
# Grace, age 21, exams: [Python: 28/30 (6 CFU), Databases: 30/30 (9 CFU)]
Inheritance: Extra Practice
The exam is mostly problem-solving. Writing OOP code is really just organizing your logic nicely. Don't over-stress these — if you understand the concept, you can write the code. These exercises are just for practice, not memorization.
Exercise 1: Coffee Shop
You're building a coffee ordering system. ☕
Part A:
Create a Beverage class with:
nameandprice__str__that returns something like"Espresso: €2.50"
Part B:
Create a CustomBeverage subclass that:
- Has an
extraslist (e.g.,["oat milk", "extra shot"]) - Has an
add_extra(extra_name, extra_price)method - Each extra increases the total price
- Override
__str__to show the extras too
Test it:
drink = CustomBeverage("Latte", 3.00)
drink.add_extra("oat milk", 0.50)
drink.add_extra("vanilla syrup", 0.30)
print(drink)
# Latte: €3.80 (extras: oat milk, vanilla syrup)
Exercise 2: Shapes (Classic but Useful)
Part A:
Create a Shape class with:
- A
nameinstance variable - A method
area()that returns0(base case) __str__that returns"Shape: {name}, area: {area}"
Part B:
Create two subclasses:
Rectangle(Shape):
- Has
widthandheight - Override
area()to returnwidth * height
Circle(Shape):
- Has
radius - Override
area()to returnπ * radius²
Part C:
Create a function (not a method!) that takes a list of shapes and returns the total area:
shapes = [Rectangle(4, 5), Circle(3), Rectangle(2, 2)]
print(total_area(shapes)) # Should work for any mix of shapes
This is polymorphism — you call .area() on each shape and the correct version runs automatically. The function doesn't care if it's a Rectangle or Circle.
Exercise 3: Game Characters
You're making an RPG. Because why not.
Part A:
Create a Character class with:
nameandhealth(default 100)take_damage(amount)that reduces healthis_alive()that returnsTrueif health > 0__str__showing name and health
Part B:
Create a Warrior subclass:
- Has
armor(default 10) - Override
take_damageso damage is reduced by armor first
Create a Mage subclass:
- Has
mana(default 50) - Has
cast_spell(damage)that costs 10 mana and returns the damage (or 0 if no mana)
Test scenario:
w = Warrior("Ragnar")
m = Mage("Merlin")
w.take_damage(25) # Should only take 15 damage (25 - 10 armor)
print(w) # Ragnar: 85 HP
spell_damage = m.cast_spell(30)
print(m.mana) # 40
Exercise 4: Quick Thinking
No code needed — just answer:
4.1: You have Animal and want to create Dog. Inheritance or instance variable?
4.2: You have Car and want to give it an Engine. Inheritance or instance variable?
4.3: What does super().__init__() do and when would you skip it?
4.4: If both Parent and Child have a method called greet(), which one runs when you call child_obj.greet()?
Exercise 5: Fix The Bug
This code has issues. Find and fix them:
class Vehicle:
def __init__(self, brand):
self.brand = brand
self.fuel = 100
def drive(self):
self.fuel -= 10
class ElectricCar(Vehicle):
def __init__(self, brand, battery):
self.battery = battery
def drive(self):
self.battery -= 20
tesla = ElectricCar("Tesla", 100)
print(tesla.brand) # 💥 Crashes! Why?
What's missing in ElectricCar.__init__?
If you can do these, you understand inheritance. Now go touch grass or something. 🌱
Quick Reference
class Child(Parent): → Create subclass
super().__init__(...) → Call parent's constructor
super().method() → Call parent's method
Same method name → Overrides parent
New method name → Adds to child
IS-A → Use inheritance
HAS-A → Use instance variables
This is just the basics. There's more to discover (multiple inheritance, abstract classes, etc.), but now you have some bases to build on. And yes, these notes are correct. You're welcome. 😏