Inheritance

📖
What is 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)
💡
Terminology

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
What Just Happened?

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!
⚠️
Problem

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)
💡
Golden Rule

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
ℹ️
When to Use super() in Methods

Use super().method_name() when you want to extend the parent's behavior, not completely replace it.


Level 5: Inheritance vs Composition

⚠️
Important Decision

Not everything should be a subclass! Choose wisely.

The "is-a" Test

Ask yourself: "Is X a Y?"

RelationshipIs-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
Simple Rule

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

ℹ️
A Note on Exams

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:

  • name and price
  • __str__ that returns something like "Espresso: €2.50"

Part B:

Create a CustomBeverage subclass that:

  • Has an extras list (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 name instance variable
  • A method area() that returns 0 (base case)
  • __str__ that returns "Shape: {name}, area: {area}"

Part B:

Create two subclasses:

Rectangle(Shape):

  • Has width and height
  • Override area() to return width * 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
💡
Why This Works

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:

  • name and health (default 100)
  • take_damage(amount) that reduces health
  • is_alive() that returns True if health > 0
  • __str__ showing name and health

Part B:

Create a Warrior subclass:

  • Has armor (default 10)
  • Override take_damage so 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?
⚠️
Hint

What's missing in ElectricCar.__init__?


You're Ready

If you can do these, you understand inheritance. Now go touch grass or something. 🌱

---

Quick Reference

📝
Inheritance Cheat Sheet

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


💡
Final Note

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. 😏