Python Programming — Alpha School

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.

Key idea. Key idea: Inherit for genuine "is a" relationships; override to replace or extend via super(); write calling code against the family, not the specific class. Dunders wire your objects into len(), print() and in — Python's own grammar.

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. One speak() 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.
Try it. Try it: Give Playlist a __getitem__(self, index) that returns self.songs[index] — then playlist[0] and even for song in playlist start working (iteration uses __getitem__ when __iter__ is absent). Test both.

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:

💪 Practice Zone

Try these yourself — hints are included, answers are not. Run your code in any Python environment.

  1. Shape hierarchy — Create a Shape base class and Circle/Rectangle subclasses, each with an area() method.
    HintCircle needs math.pi * r ** 2; Rectangle needs w * h. Call super().__init__() for shared setup.
  2. Override __str__ — Give each shape a __str__ so printing shows its type and area, e.g. Circle(area=78.5).
    HintDefine __str__ in each subclass — or once in the base using self.area() and type(self).__name__.
  3. Polymorphic gallery — Put mixed shapes in one list and loop through calling .area() — no if checking types.
    HintThat'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

Keep reading to complete this lesson