Learning Goals
3 minBy the end of this lesson you can:
- Write a child class with
class Child(Parent):and use what it inherits. - Override a method so the child behaves differently.
- Call
super()to reuse the parent's__init__or method instead of copying it. - Decide whether two classes really are parent and child, or just similar.
Warm-Up · The Rule That Was Only Fixed Once
5 minTwo classes, written a week apart. Predict the last line.
class Hero: def __init__(self, name, hp=100): self.name = name self.hp = hp def take_damage(self, amount): self.hp = max(0, self.hp - amount) class Monster: def __init__(self, name, hp=40): self.name = name self.hp = hp def take_damage(self, amount): self.hp = self.hp - amount felix = Hero("Felix") slime = Monster("Slime") felix.take_damage(150) slime.take_damage(150) print(felix.hp, slime.hp)
Answer
Output
0 -110
Somebody fixed "HP must never go below zero" in Hero and never got round to Monster. The slime now has minus one hundred and ten hit points.
The two classes started identical. One improvement later, they disagree — and nothing announced it. Every rule that lives in two places is a rule that will eventually be true in one of them.
New Concept · One Class Built on Another
12 minWrite the shared part once, in a parent class:
class Character: """Anything in the game with a name that can be hurt.""" def __init__(self, name, hp): self.name = name self.max_hp = hp self.hp = hp def take_damage(self, amount): """Lower hp, never below zero.""" self.hp = max(0, self.hp - amount) def is_alive(self): return self.hp > 0 def __str__(self): return f"{self.name}: {self.hp}/{self.max_hp} HP"
Then say that a hero is a character. The brackets after the class name are the whole syntax:
class Hero(Character): """A character the player controls."""
That child already works, and it has everything:
felix = Hero("Felix", 100) felix.take_damage(30) print(felix) print(felix.is_alive())
Output
Felix: 70/100 HP True
Nothing was copied. Hero borrows the parent's __init__, its methods and its rules — including the "never below zero" fix, which now exists in exactly one place.
Character is the parent (or base class). Hero is the child (or subclass). The child inherits everything the parent has, and may add or change whatever it likes.
Adding things the parent has not got means writing the child's own __init__ — and calling the parent's from inside it, so you do not retype the three lines it already does:
class Hero(Character): """A character the player controls. Carries potions.""" def __init__(self, name, hp=100, potions=2): super().__init__(name, hp) self.potions = potions
super() means "the parent of this class". So super().__init__(name, hp) runs Character.__init__ on this same object, and then the child adds one more attribute.
super()It always has its own brackets: super(), not super. And you never pass self into it — it already knows which object it is working on, exactly like any other method call through a dot.
Overriding is writing a method the parent already has. The child's version wins:
class Monster(Character): """A character the game controls. Tough hide.""" def take_damage(self, amount): """Monsters ignore the first 2 points of every hit.""" super().take_damage(max(0, amount - 2))
Notice what the override did not do: it did not rewrite the "never below zero" rule. It adjusts the number and hands the work to the parent. That is the difference between replacing a method and extending it, and extending is nearly always the one you want.
The same trick works on __str__:
def __str__(self): return super().__str__() + f" - potions: {self.potions}"
A hero is a character. ✅ A monster is a character. ✅ A backpack is a character? ❌ — a hero has a backpack, which is an attribute, not a parent. If the sentence sounds wrong, inheritance is the wrong tool, and you want one object holding another instead.
Worked Example · One Family, Two Children
12 minType this out and run it. Look at how little is in the two child classes.
class Character: """Anything in the game with a name that can be hurt.""" def __init__(self, name, hp): self.name = name self.max_hp = hp self.hp = hp def take_damage(self, amount): """Lower hp, never below zero.""" self.hp = max(0, self.hp - amount) def is_alive(self): """Return True while there is any hp left.""" return self.hp > 0 def __str__(self): return f"{self.name}: {self.hp}/{self.max_hp} HP" class Hero(Character): """A character the player controls. Carries potions.""" def __init__(self, name, hp=100, potions=2): super().__init__(name, hp) self.potions = potions def drink_potion(self): """Heal 30 hp, never above max. Return False if none are left.""" if self.potions == 0: return False self.potions = self.potions - 1 self.hp = min(self.max_hp, self.hp + 30) return True def __str__(self): return super().__str__() + f" - potions: {self.potions}" class Monster(Character): """A character the game controls. Tough hide, drops loot.""" def __init__(self, name, hp, loot): super().__init__(name, hp) self.loot = loot def take_damage(self, amount): """Monsters ignore the first 2 points of every hit.""" super().take_damage(max(0, amount - 2)) felix = Hero("Felix") slime = Monster("Slime", 40, "5 gold") print(felix) print(slime) slime.take_damage(12) print("Felix attacks!", slime) felix.take_damage(25) print("The slime hits back.", felix) felix.drink_potion() print("Felix drinks a potion.", felix) slime.take_damage(32) print("Felix attacks again.", slime) if not slime.is_alive(): print("The slime is defeated. You find", slime.loot)
Output
Felix: 100/100 HP - potions: 2 Slime: 40/40 HP Felix attacks! Slime: 30/40 HP The slime hits back. Felix: 75/100 HP - potions: 2 Felix drinks a potion. Felix: 100/100 HP - potions: 1 Felix attacks again. Slime: 0/40 HP The slime is defeated. You find 5 gold
slime.take_damage(12) finds Monster.take_damage first, because the child is asked before the parent. That subtracts 2 and calls super().take_damage(10), which is Character.take_damage — the one place the "never below zero" rule lives. Two classes, one rule.
Two experiments worth doing now:
- Add
self.gold = 0toCharacter.__init__. Both children get it, immediately, with no other edit. - Ask Python about the family:
print(isinstance(slime, Character))printsTrue, because a monster really is a character.
Try It Yourself
13 minAdd class Boss(Monster) — a child of a child, which is allowed. Give it a rage attribute set to False, and a method roar() that sets it to True. Create one, damage it, print it, and check that everything it inherited still works.
Hint
class Boss(Monster): """A monster with a temper.""" def __init__(self, name, hp, loot): super().__init__(name, hp, loot) self.rage = False
Give Boss two overrides that both call super():
take_damage— a boss ignores the first 5 points, and roars the first time its hp drops under half.__str__— the parent's line plus" [RAGING]"whenrageisTrue.
Then put a hero, a monster and a boss in one list and loop over it, printing each and calling take_damage(10) on all three. One loop, three different behaviours — and you did not write a single if to choose between them.
Mini-Challenge 🔥 · Debug: The Child That Forgot Its Parent
8 minAna's game will not start. Both child classes are broken, in three different ways.
class Character:
"""Anything with a name that can be hurt."""
def __init__(self, name, hp):
self.name = name
self.hp = hp
def __str__(self):
return f"{self.name} ({self.hp} HP)"
class Monster(Character):
def __init__(self, name, hp, loot):
self.loot = loot
def describe(self):
return super.__str__() + " drops " + self.loot
class Hero(Character):
def __init__(self, name, hp, potions=2):
super().__init__(self, name, hp)
self.potions = potions
slime = Monster("Slime", 40, "5 gold")
print(slime.describe())Answer
Monster.__init__never calls the parent. Writing a child__init__replaces the parent's, sonameandhpare accepted and thrown away. The object has onlyloot, and the first mention ofself.nameraisesAttributeError. Addsuper().__init__(name, hp)as the first line.super.__str__()is missing its brackets.superon its own is a built-in, not the parent. It must besuper().__str__().Heropassesselftosuper().super().__init__(self, name, hp)sends three values into a method that wants two:TypeError: Character.__init__() takes 3 positional arguments but 4 were given. Drop theself.
The first is the one to remember. If a child has its own __init__, calling super().__init__(...) is almost always its first line — and if it does not need one at all, leave __init__ out entirely and let the parent's run.
Recap
3 minclass Child(Parent): gives the child every attribute and method the parent has. Write a method the parent already has and the child's version wins — that is overriding. super() reaches the parent's version, so super().__init__(name, hp) reuses its setup and super().take_damage(...) extends its behaviour instead of replacing it. Never pass self to super(), and never write it without brackets. Use inheritance only when "a child is a parent" is a true sentence.
Vocabulary Card
- inheritance
- A class receiving all the attributes and methods of another class.
- parent / child
- Also base class and subclass.
class Hero(Character):—Characteris the parent. - override
- Writing a method in the child that the parent already has. The child's wins.
super()- The parent's version of a method, called from inside the child.
Homework
4 minGive your own class from OOP-03 a child class.
- Say the "is-a" sentence in a comment at the top, so you know it is the right tool.
- The child adds at least one attribute, set through its own
__init__withsuper(). - The child overrides exactly one method, and that override calls
super()rather than copying the parent. - Make one parent object and one child object, put both in a list, and print the list in one loop.
Hint: a book and a textbook, a bus and an express bus, a stall and a halal-certified stall, a drink and a hot drink.
Sample · rak_buku.py
# rak_buku.py - a textbook IS A book, so it inherits class Book: """One book on my shelf.""" def __init__(self, title, author, price): self.title = title self.author = author self.price = price def value_after_years(self, years): """Return what it is worth after losing 10% of its price a year.""" worth = self.price for year in range(years): worth = worth * 0.9 return worth def __str__(self): return f"{self.title} by {self.author} - $ {self.price:.2f}" class Textbook(Book): """A textbook IS A book, with a subject and a form.""" def __init__(self, title, author, price, subject, form): super().__init__(title, author, price) self.subject = subject self.form = form def value_after_years(self, years): """Textbooks lose value faster - the syllabus changes.""" return super().value_after_years(years) * 0.5 def __str__(self): return super().__str__() + f" [{self.subject}, Form {self.form}]" shelf = [ Book("Lat: Village Boy", "Lat", 24.00), Textbook("Sejarah Tingkatan 2", "KPM", 12.90, "Sejarah", 2), ] for book in shelf: print(book) print(f" after 3 years: $ {book.value_after_years(3):.2f}")
Output
Lat: Village Boy by Lat - $24.00 after 3 years: $17.50 Sejarah Tingkatan 2 by KPM - $12.90 [Sejarah, Year 8] after 3 years: $4.70
One loop, two classes, and the right method runs for each without a single if. Both overrides extend the parent instead of copying it — the depreciation sum exists in exactly one place.