Lesson 16: Object-Oriented Programming II — Inheritance, Polymorphism, and Dunder Methods
🎧 Listen to this lesson
Lesson 10's classes were islands. Real systems organise classes into families: a savings account *is an* account with extras, a boss *is an* enemy with a nastier attack. Inheritance lets a subclass reuse and extend a parent class; polymorphism lets code treat family members uniformly while each behaves its own way; and dunder methods (the double-underscore hooks like __len__ and __str__) let your objects plug into Python's own syntax — len(), print(), in, and more.
Inheritance: reuse with a family tree
A subclass written as class Dog(Pet) inherits every attribute and method of Pet. It can add new behaviour and override inherited methods. super().__init__(name) calls the parent's constructor so shared setup is written once:
class Pet:
"""A pet with a name and a sound it makes."""
def __init__(self, name):
self.name = name
self.sound = "..."
def speak(self):
return f"{self.name} says {self.sound}!"
class Dog(Pet):
def __init__(self, name):
super().__init__(name)
self.sound = "woof"
class Cat(Pet):
def __init__(self, name):
super().__init__(name)
self.sound = "meow"
pets = [Dog("Rex"), Cat("Mimi"), Pet("Blob")]
for p in pets:
print(p.speak())
Output:
Rex says woof!
Mimi says meow!
Blob says ...!
The loop is the polymorphism payoff: one call, p.speak(), three behaviours — Python looks up the method on each object's *actual* class at run time. The generic Pet falls back to its default sound because neither subclass overrode speak itself; they customised the *data* (sound) that the inherited method uses. Overriding comes in two flavours — change the data, or replace the method — and the second appears shortly. Design rule of thumb: inherit when the relationship is genuinely "is a" (a savings account is an account); if it is merely "has a" (a library has books), use composition — objects holding objects, as in Lesson 10's library.
Worked example 1 — extending through super()
Children frequently *extend* a parent method rather than replace it: do the parent's work, then more. The cleanest small case is a savings account that reuses the parent's deposit machinery to add interest:
class Account:
"""A simple account."""
def __init__(self, owner):
self.owner = owner
self.balance = 0
def deposit(self, amount):
self.balance += amount
return self.balance
class SavingsAccount(Account):
"""An account that can earn interest."""
def add_interest(self, rate):
interest = self.balance * rate
return self.deposit(interest)
acct = SavingsAccount("Ada")
print(acct.deposit(200))
print(acct.add_interest(0.05))
Output:
200
210.0
add_interest computes 5% of 200 (10.0) and routes it through the inherited deposit — so the balance-updating logic exists in exactly one place. If deposit later gains validation or logging, interest inherits the improvement free. SavingsAccount defines no __init__ at all and still works, because the parent's is inherited as-is: inheritance is opt-out, not opt-in. A subclass that defines nothing is still a distinct type — useful for the isinstance checks below — though a class that adds *nothing* is usually a sign the hierarchy is speculative.
Worked example 2 — a game hierarchy with a real override
Games are the classic hierarchy: everything is an Entity, enemies specialise it, and a boss overrides the attack to strike twice by literally calling the parent's version two times:
class Entity:
"""Base class for game characters."""
def __init__(self, name, hp):
self.name = name
self.hp = hp
def take_damage(self, amount):
self.hp -= amount
print(f"{self.name} takes {amount} damage. HP: {self.hp}")
def attack(self, target, damage):
print(f"{self.name} attacks {target.name}!")
target.take_damage(damage)
class Enemy(Entity):
pass
class Boss(Enemy):
"""A boss hits twice per attack."""
def attack(self, target, damage):
super().attack(target, damage)
super().attack(target, damage)
hero = Entity("Hero", 100)
goblin = Enemy("Goblin", 30)
dragon = Boss("Dragon", 200)
goblin.attack(hero, 10)
dragon.attack(hero, 25)
print(isinstance(dragon, Enemy), isinstance(dragon, Entity))
Output:
Goblin attacks Hero!
Hero takes 10 damage. HP: 90
Dragon attacks Hero!
Hero takes 25 damage. HP: 65
Dragon attacks Hero!
Hero takes 25 damage. HP: 40
True True
Follow the hero's HP: 100 → 90 (goblin) → 65 → 40 (the boss's doubled hits). Boss.attack *replaces* the inherited method yet reuses it via super() — the override-with-extension pattern, the most common override shape in real code. And isinstance(dragon, Enemy) is True because a Boss *is a* Enemy *is a* Entity: type checks respect the whole ancestry. That is exactly why game code can hold every character in one list typed as entities and call attack and take_damage uniformly — the family contract holds all the way down.
Dunder methods: plugging into Python's syntax
Names with double underscores are hooks Python itself calls. Implement them and your objects stop feeling like foreign objects and start behaving like built-ins:
class Playlist:
"""A playlist that behaves a little like a built-in list."""
def __init__(self):
self.songs = []
def add(self, song):
self.songs.append(song)
def __len__(self):
return len(self.songs)
def __str__(self):
return f"Playlist({len(self)} songs): " + ", ".join(self.songs)
def __contains__(self, song):
return song in self.songs
pl = Playlist()
pl.add("Imagine")
pl.add("Heroes")
print(len(pl))
print(pl)
print("Imagine" in pl)
print("Yesterday" in pl)
Output:
2
Playlist(2 songs): Imagine, Heroes
True
False
len(pl), print(pl) and "Imagine" in pl worked *because* the class defined __len__, __str__ and __contains__ — Python routes those operations to the dunders. Two siblings deserve mention: __repr__, the developer-facing representation (aim for something that could recreate the object), used when __str__ is absent; and __eq__, which defines what == means for your objects — without it, two objects with identical data compare unequal, because default equality is identity ("same object?"), not content ("same values?"). The rule: define dunders because your object's semantics call for them, never to be clever.
Common mistakes
- Skipping
super().__init__. A subclass constructor that sets its own fields but never calls the parent's leaves inherited attributes undefined — the failure surfaces later as an AttributeError in a method you did not write. Call super first, then add your extras. - Hierarchies that are too deep or speculative. Five levels of inheritance means behaviour hides in five files and changing a grandparent breaks grandchildren mysteriously. Two or three levels with clear jobs is a healthy shape; prefer composition (Lesson 10's Library *holding* Books) when in doubt.
- Checking types instead of using polymorphism. A chain of
if isinstance(x, Dog): ... elif isinstance(x, Cat): ...re-implements what method dispatch does for free — every new subclass forces edits to the chain. Onespeak()call handles subclasses that do not exist yet. - Magic dunder expectations.
__len__must return a non-negative int;__str__must return a string. Returning anything else raises confusing TypeErrors at the call site, far from your code.
Real-World Challenge
The task. A lending library stocks different media types. Model a MediaItem base class (title, creator, a generic describe), specialise it into Book (adds pages) and Movie (adds minutes, and "creator" means the director), each overriding describe with its own format. A catalogue routine must print every item through the same call — polymorphism doing the work — including a plain base-class item that uses the generic format.
Worked solution.
class MediaItem:
"""Base class for anything the library lends."""
def __init__(self, title, creator):
self.title = title
self.creator = creator
def describe(self):
return f"{self.title} by {self.creator}"
class Book(MediaItem):
def __init__(self, title, author, pages):
super().__init__(title, author)
self.pages = pages
def describe(self):
return f"Book: {self.title} by {self.creator}, {self.pages} pages"
class Movie(MediaItem):
def __init__(self, title, director, minutes):
super().__init__(title, director)
self.minutes = minutes
def describe(self):
return f"Movie: {self.title} directed by {self.creator}, {self.minutes} min"
items = [
Book("Dune", "Frank Herbert", 412),
Movie("Spirited Away", "Hayao Miyazaki", 125),
MediaItem("Unknown Artefact", "Anonymous"),
]
for item in items:
print(item.describe())
Output:
Book: Dune by Frank Herbert, 412 pages
Movie: Spirited Away directed by Hayao Miyazaki, 125 min
Unknown Artefact by Anonymous
How it works. Each subclass constructor takes its specialised facts (author+pages, director+minutes), hands the shared facts up through super().__init__, and stores only its own extra — no title/creator code is duplicated anywhere. describe is overridden per class because the formats genuinely differ, while the base version remains as the sensible default that the plain artefact uses: three items, one loop, zero type checks, and each line in the right format. The creator field is the quiet design decision — naming the shared slot by its *role* lets Book call it an author and Movie a director in their formats while the base class stays neutral, a small example of how naming is half of design.
Extension. Add a Podcast subclass (creator = host, plus episode count) and a polymorphic loan_days() method — books 21 days, movies 3, podcasts 7, base item 14 — then print each catalogue line with its loan period. Finally implement __eq__ on MediaItem comparing title and creator, and show that two separately-constructed Books for "Dune" now compare equal while different titles do not.
Going further — the two representations, and equality by value
Two dunders decide how your objects present themselves, and confusing them is a rite of passage. __repr__ is the developer's view: unambiguous, ideally showing the values you would need to recreate the object. __str__ is the user's view: friendly and brief. When only __repr__ exists, print falls back to it — one more reason to write it well:
class Point:
"""A 2D point with a proper representation and value equality."""
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x}, {self.y})"
def __eq__(self, other):
return self.x == other.x and self.y == other.y
p = Point(2, 3)
print(p)
print(Point(2, 3) == Point(2, 3))
Output:
Point(2, 3)
True
Without __eq__, that comparison is False: default equality is identity, and two separately built points are different objects even with identical coordinates. Defining __eq__ declares that a Point *means* its coordinates — a semantic decision only the class author can make (should two bank accounts with equal balances be "equal"? Almost certainly not; their identity is the account, not the number). Libraries feel the difference everywhere: lists compare element-wise with ==, dictionaries use it for lookups of value-like keys, and test assertions (next lesson) lean on it constantly. Choose per class, deliberately: value objects (points, dates, money amounts) get __eq__; entities with identity (accounts, students, orders) usually keep the default.
🎬 Watch & Learn
Prefer video? These searches surface top tutorials on this lesson's topic:
- Python inheritance & polymorphism — Corey Schafer OOP series — pick a recent, well-reviewed video
- Python dunder methods — make your classes Pythonic — pick a recent, well-reviewed video
- Tech With Tim: OOP inheritance explained with examples — pick a recent, well-reviewed video
💪 Practice Zone
Try these yourself — hints are included, answers are not. Run your code in any Python environment.
- Shape hierarchy — Create a
Shapebase class andCircle/Rectanglesubclasses, each with anarea()method.Hint
Circle needsmath.pi * r ** 2; Rectangle needsw * h. Callsuper().__init__()for shared setup. - Override __str__ — Give each shape a
__str__so printing shows its type and area, e.g.Circle(area=78.5).Hint
Define__str__in each subclass — or once in the base usingself.area()andtype(self).__name__. - Polymorphic gallery — Put mixed shapes in one list and loop through calling
.area()— noifchecking types.Hint
That's the whole point of polymorphism:for s in shapes: print(s.area())just works.
🌍 Real World
Game engines organize thousands of entities — players, enemies, projectiles — in inheritance hierarchies exactly like your game example. UI frameworks do the same: every button and text box inherits from a base Widget class. Inheritance keeps million-line codebases manageable.
Unit 5: Professional Python
🎧 Listen to this unit