Inheritance Project · Live Demo

🦁 Zoo Manager — Inheritance in Action

A live, interactive companion to the Inheritance guide. One Animal base class defines the shared interface — feed() and makeSound() — and three subclasses, Lion, Elephant, and Parrot, each override that interface with their own behavior. Spawn animals below and watch the exact same method calls produce completely different results, depending on the object's actual subclass.

Try the Simulator View Python Source
Advertisement

Introduction to the Zoo Manager Project

📘 What Is the Zoo Manager, Exactly?

Zoo Manager is a small, fully working application built to turn one idea from the Inheritance guide into something you can click, break, and rebuild: a single Animal base class defines what every animal in the zoo must be able to do — feed() and makeSound() — and three subclasses, Lion, Elephant, and Parrot, each supply their own version of those two behaviors.

The project exists in two parallel forms that mirror each other line for line: a JavaScript simulator you can click through right now in the Live Simulator section, and the actual runnable Python source underneath it, which you can copy, run in-browser via Pyodide, or drop straight into your own project. Seeing the identical class hierarchy expressed in two different languages is itself part of the lesson — inheritance is a concept, not a Python-only trick.

Nothing here is decorative. Every button you press calls a real method, on a real object, resolved through Python's (and JavaScript's) actual method-lookup rules. When the Lion card roars instead of trumpeting, that isn't a canned animation — it's Lion.makeSound() genuinely overriding Animal.makeSound() at runtime.

1 base class, 3 subclasses 2 overridden methods per subclass Live, clickable simulator Runnable Pyodide-powered Python source Line-by-line code walkthrough

The Zookeeper's Dilemma

Imagine you actually managed a real zoo with three hundred animals across forty species. Every morning, a zookeeper walks the grounds with one clipboard that says exactly two instructions: "feed it" and "listen to it." The zookeeper doesn't carry three hundred different instruction manuals — they don't need to. Each animal already knows how to interpret "feed it" in its own way: the lion wants meat, the elephant wants hay, the parrot wants seeds.

That is precisely what this program models. The zookeeper in our code is the simulator's UI and the final for loop in the Python script — neither of them has any idea what species it's dealing with. They just call feed() and makeSound() and trust that whichever subclass is actually running will do the right, species-specific thing. That trust is what inheritance and method overriding make possible.

Why this matters beyond animals: Replace "zookeeper" with "payment processor," "notification dispatcher," or "AI agent orchestrator," and the exact same shape reappears — one caller, many interchangeable implementations, zero special-casing.

Why a Programmer Should Learn This

Four Reasons This Small Project Pays Off

A zoo simulator sounds like a toy exercise, but the pattern it teaches shows up in almost every serious codebase you will ever touch. Here's why it's worth the twenty minutes.

1. It turns theory into muscle memory. Reading that "super().feed() extends rather than replaces the parent method" is one kind of understanding. Clicking "Feed" on a Lion card and watching the hunger bar drop while a lion-specific sentence appears in the log is a different, stickier kind of understanding — you're no longer trusting the explanation, you're watching the mechanism.

2. It rehearses the "is-a" test in a domain everyone already understands. Before you apply inheritance to payment processors, UI components, or AI agents, it helps to practice the judgment call — "is a Lion genuinely a kind of Animal?" — somewhere the answer is obvious, so the instinct is already built when the domain gets more abstract.

3. It bridges two languages with one mental model. The JavaScript class Lion extends Animal in the simulator and the Python class Lion(Animal): in the source panel are doing the same conceptual work with different syntax. Recognizing that overlap is exactly what lets a developer move between a Python backend and a JavaScript frontend without relearning object-oriented design from scratch.

4. It's a realistic onramp to much larger systems. The "one shared interface, many overriding subclasses, one caller that never checks which subclass it got" shape is the exact skeleton behind game engines, plugin architectures, ORM model classes, and — as the AI-era section below explains — modern AI agent frameworks. Master it here, at three animals, and it scales to three hundred.

Significance in the Programming World

The "animal hierarchy" example is not unique to this site — it's one of the oldest and most widely used teaching devices in computer science, appearing in nearly every introductory OOP course for a reason: animals have obvious, intuitive "is-a" relationships that don't require domain expertise to evaluate. But the significance runs deeper than pedagogy.

Under the hood, what looks like a simple if-free, type-check-free dispatch (calling animal.makeSound() without asking "which animal is this?") is implemented by real compilers and interpreters using structures like virtual method tables in C++/Java or method resolution order (MRO) lookups in Python. Every time you click "Speak" in this simulator, you're triggering — at a much smaller scale — the exact same dispatch mechanism that lets massive production systems route millions of calls to the correct implementation without a single hardcoded type check.

Takeaway: Small, toy-sized examples like this one aren't a "beginner phase" you graduate out of — they're a compressed, inspectable version of a mechanism that scales, unmodified, all the way up to distributed, production-grade software.

How This App Demonstrates Inheritance

One Parent, Three Specializations

Every animal in this zoo — regardless of species — shares the same two-method interface: feed() and makeSound(). That interface lives exactly once, on the Animal base class. Each subclass overrides both methods to express what makes it unique, while still fitting the same mold the zookeeper (this app) expects.

Animal
name, hunger, feed(), makeSound()
🦁 Lion
roars, eats meat
🐘 Elephant
trumpets, eats plants
🦜 Parrot
talks, eats seeds & flies

Polymorphism in action: The "Feed" and "Speak" buttons below call the exact same two methods no matter which animal card they belong to. The output differs only because each subclass overrides those methods differently — this is inheritance setting up polymorphism, exactly as covered in the guide's AI-era section.

The Zoo Simulator

Add animals to your zoo, then feed them or make them speak. Every action is logged below so you can see exactly which subclass's overridden method actually ran.

Your zoo is empty. Add a Lion, Elephant, or Parrot to get started. 🌳
📋 Zoo Log — method calls in real time
Waiting for your first animal…

The Python Behind This App

This simulator's JavaScript logic is a direct mirror of the Python class hierarchy below. Copy it, run it right here in the browser via Pyodide, or drop it straight into your own project.

class Animal:
    def __init__(self, name: str):
        self.name = name
        self.hunger = 50

    def feed(self) -> str:
        self.hunger = max(0, self.hunger - 25)
        return f"{self.name} has been fed."

    def makeSound(self) -> str:
        raise NotImplementedError("Subclasses must implement makeSound()")

class Lion(Animal):
    def feed(self) -> str:
        base = super().feed()
        return f"{base} It devours a slab of meat."

    def makeSound(self) -> str:
        return f"{self.name} lets out a mighty ROAR!"

class Elephant(Animal):
    def feed(self) -> str:
        base = super().feed()
        return f"{base} It munches on leaves and hay."

    def makeSound(self) -> str:
        return f"{self.name} trumpets loudly!"

class Parrot(Animal):
    def feed(self) -> str:
        base = super().feed()
        return f"{base} It cracks open a sunflower seed."

    def makeSound(self) -> str:
        return f"{self.name} squawks: 'Polly wants a cracker!'"

zoo = [Lion("Leo"), Elephant("Ella"), Parrot("Polly")]
for animal in zoo:
    print(animal.makeSound())
    print(animal.feed())

Comprehensive Line-by-Line Walkthrough

Below is every meaningful line in the script, explained in the order Python actually executes it — from class definition to the final printed line.

  1. class Animal: Declares the base class with no parent of its own (it implicitly inherits from Python's built-in object). Nothing runs yet — Python is just registering the blueprint.
  2. def __init__(self, name: str): The constructor every animal will run first. self.name = name stores the given name; self.hunger = 50 gives every new animal an identical, deliberately mid-range starting hunger — hungry enough that feeding visibly matters, not so hungry it looks broken on arrival.
  3. def feed(self) -> str: The base implementation of feeding. max(0, self.hunger - 25) subtracts 25 points but clamps the floor at zero using max(), so hunger can never go negative no matter how many times an already-full animal is fed. It then returns a plain, species-neutral confirmation string.
  4. def makeSound(self) -> str: raise NotImplementedError(...) Deliberately provides no real sound. If any future subclass forgets to override this, calling it fails loudly and immediately, rather than silently returning a wrong or empty sound — the same contract-enforcement pattern used by Shape.area() in the main Inheritance guide.
  5. class Lion(Animal): The parentheses declare Animal as the parent. Lion now has __init__, feed(), and makeSound() automatically available, and will override the last two.
  6. Lion's feed(): base = super().feed() calls Animal.feed() first, running the exact same hunger-clamping math and capturing its returned sentence in a local variable named base. The next line returns f"{base} It devours a slab of meat." — the parent's sentence with a lion-specific sentence appended. Nothing about the hunger math was duplicated.
  7. Lion's makeSound(): Fully replaces the parent's version (no super() call here, since there's no shared sound logic worth reusing) and returns f"{self.name} lets out a mighty ROAR!".
  8. class Elephant(Animal): and class Parrot(Animal): Structurally identical to Lion — each calls super().feed() and appends its own diet-specific sentence, then fully overrides makeSound() with its own vocalization. The repetition across all three subclasses is intentional: it's what makes the "same shape, different content" nature of overriding so visible.
  9. zoo = [Lion("Leo"), Elephant("Ella"), Parrot("Polly")] Builds one Python list holding three objects of three different classes. Python lists don't enforce a single element type, so this is perfectly valid — the only thing all three objects share is that they're each some kind of Animal.
  10. for animal in zoo: On each pass, animal is bound to the next object in the list — first the Lion, then the Elephant, then the Parrot. The loop body never asks type(animal) or branches on species; it treats every iteration identically.
  11. print(animal.makeSound()) then print(animal.feed()): For each animal, Python looks up makeSound and feed starting from that specific object's actual class — not from whatever type the variable "looks like" in the code. This is why the identical two lines produce a roar-then-meat-sentence for Leo, a trumpet-then-hay-sentence for Ella, and a squawk-then-seed-sentence for Polly.
  12. Final output: Six printed lines total — two per animal — with zero if statements checking species anywhere in the loop. Every difference in the output comes entirely from which subclass's overridden method actually executed.

Same shape in JavaScript: The Live Simulator above runs a JavaScript class hierarchy — class Lion extends Animal, super.feed(), and so on — that mirrors this Python source line for line. Clicking "Feed" on the Lion card triggers the exact same override chain you just read, just in a different syntax.

Why This Is Essential in the Age of AI

The Animal → Lion/Elephant/Parrot shape isn't just a teaching toy — it is, almost unmodified, the skeleton behind how modern AI systems are actually structured in production. Here's the direct mapping.

🤖

Agent Base Classes

Swap Animal for BaseAgent, and Lion/Elephant/Parrot for ChatAgent/RetrieverAgent/CodeAgent. Each overrides a shared act()-style method exactly the way our animals override makeSound().

🔌

Plugin & Tool Interfaces

Extensible AI toolkits define a base Tool class that raises NotImplementedError on run() — identical to Animal.makeSound() — forcing every new integration to supply a real implementation before it can be registered.

🧠

Model Family Trees

Just as every zoo animal must implement feed() and makeSound(), every Hugging Face Transformers model subclass must implement its own forward pass while inheriting shared tokenization and checkpoint-loading logic from a common base.

📊

Dataset Abstractions

torch.utils.data.Dataset is a base class every custom dataset inherits, overriding just __len__ and __getitem__ — two required methods, the same way our animals override exactly two.

🛡️

Safety Guardrails via Overriding

Production AI systems implement content filters and policy checks as subclasses of a shared Guardrail base, each overriding check() differently per policy domain — mirroring how Lion, Elephant, and Parrot each customize feed() differently for their own diet.

🎮

Simulation & RL Environments

Reinforcement-learning libraries like Gymnasium define a base Env class; every new environment overrides step() and reset() the same way our animals override feed() and makeSound() — one interface, endless environments.

🧩

Multi-Agent Orchestration

Frameworks coordinating several AI agents at once (a planner, a researcher, a coder) route work through a shared BaseAgent.act() call — the orchestrator never needs a special case per agent type, exactly like AlertDispatcher.broadcast() in the Case Study section.

🔍

Evaluation Harnesses

LLM evaluation frameworks define a base Metric class with a required score() method; every specific metric (accuracy, BLEU, a custom rubric) overrides it the same way Lion and Elephant override feed() — letting one evaluation loop run any metric interchangeably.

The honest version of this claim: The zoo you just built is a toy-sized version of exactly this pattern. If you understand why Leo roars instead of trumpeting, you already understand the core mechanism that lets a production AI framework route a request to the correct agent, model, or tool without a single hardcoded type check.

The Design Patterns Hiding Inside This Project

You didn't just write "some inheritance" — you actually implemented two named, well-studied design patterns without necessarily calling them that. Recognizing them by name makes it much easier to talk about your code with other engineers, and to recognize the same shape the next time it shows up in a library you're reading.

Pattern 1: Template Method

The Template Method pattern defines the skeleton of an operation in a base class, deferring some steps to subclasses. That's exactly what Animal.feed() does: the "reduce hunger, don't go below zero" skeleton lives in the parent, and each subclass supplies its own closing detail by calling super().feed() and appending to it.

class Animal:
    def feed(self) -> str:
        self.hunger = max(0, self.hunger - 25)
        return f"{self.name} has been fed."

class Lion(Animal):
    def feed(self) -> str:
        base = super().feed()
        return f"{base} It devours a slab of meat."

Where you'll see this again: Django's class-based views, pytest's fixture teardown chains, and PyTorch's nn.Module.__call__ (which runs bookkeeping, then calls your overridden forward()) all follow this exact "fixed skeleton, overridable steps" shape.

Pattern 2 (the road not taken): Strategy

It's worth seeing the alternative you didn't choose, because the comparison sharpens your judgment about when inheritance is the right tool. The Strategy pattern achieves similar flexibility through composition instead of inheritance — you'd store a "feeding behavior" object as an attribute rather than overriding a method.

class FeedingStrategy:
    def describe(self) -> str:
        raise NotImplementedError

class CarnivoreDiet(FeedingStrategy):
    def describe(self) -> str:
        return "devours a slab of meat"

class Animal:
    def __init__(self, name: str, diet: FeedingStrategy):
        self.name = name
        self.diet = diet
        self.hunger = 50

    def feed(self) -> str:
        self.hunger = max(0, self.hunger - 25)
        return f"{self.name} has been fed. It {self.diet.describe()}."

leo = Animal("Leo", CarnivoreDiet())

Why we still chose inheritance for this project: Strategy shines when the behavior needs to change at runtime (an animal switching diets, a payment processor switching providers). Inheritance shines when the behavior is fixed for the object's entire lifetime — a Lion is always a Lion. Since our animals never change species mid-simulation, inheritance is the simpler, more honest fit. Knowing both patterns is what lets you make that call deliberately instead of by habit.

Pattern 3: Factory Method

Notice that spawnAnimal('lion') in the JavaScript simulator takes a plain string and decides which class to instantiate — the calling code (the spawn buttons) never writes new Lion(...) directly. That indirection is the Factory Method pattern: centralizing "which concrete subclass do I create?" in one place instead of scattering constructor calls everywhere.

def make_animal(species: str, name: str) -> Animal:
    factories = {
        "lion": Lion,
        "elephant": Elephant,
        "parrot": Parrot,
    }
    if species not in factories:
        raise ValueError(f"Unknown species: {species}")
    return factories[species](name)

zoo = [make_animal("lion", "Leo"), make_animal("parrot", "Polly")]

Why this matters: if a new species is added later, only make_animal's dictionary needs a new entry — every call site that already uses make_animal("...", "...") keeps working unchanged. This is precisely what the JavaScript simulator's makeAnimal(kind) helper function does, just with an if/else chain instead of a dictionary lookup.

Testing Your Inheritance Hierarchy

A class hierarchy you can't verify with tests is a class hierarchy you're afraid to refactor. Here's a real, runnable pytest suite for the exact Animal/Lion/Elephant/Parrot hierarchy from the Python Source section above — copy it into a file named test_zoo.py alongside your code and run pytest.

import pytest
from zoo import Animal, Lion, Elephant, Parrot

def test_base_animal_cannot_make_sound():
    generic = Animal("Generic")
    with pytest.raises(NotImplementedError):
        generic.makeSound()

def test_feed_reduces_hunger_by_25():
    leo = Lion("Leo")
    assert leo.hunger == 50
    leo.feed()
    assert leo.hunger == 25

def test_hunger_never_goes_negative():
    leo = Lion("Leo")
    for _ in range(10):
        leo.feed()
    assert leo.hunger == 0

def test_each_subclass_returns_a_unique_sound():
    leo, ella, polly = Lion("Leo"), Elephant("Ella"), Parrot("Polly")
    sounds = {leo.makeSound(), ella.makeSound(), polly.makeSound()}
    assert len(sounds) == 3

def test_feed_message_mentions_the_animals_name():
    ella = Elephant("Ella")
    assert "Ella" in ella.feed()

@pytest.mark.parametrize("cls,expected_fragment", [
    (Lion, "meat"),
    (Elephant, "hay"),
    (Parrot, "seed"),
])
def test_each_subclass_mentions_its_own_diet(cls, expected_fragment):
    animal = cls("Test Animal")
    assert expected_fragment in animal.feed()

def test_all_animals_are_instances_of_animal():
    zoo = [Lion("Leo"), Elephant("Ella"), Parrot("Polly")]
    assert all(isinstance(a, Animal) for a in zoo)

@pytest.fixture
def sample_zoo():
    return [Lion("Leo"), Elephant("Ella"), Parrot("Polly")]

def test_feeding_one_animal_does_not_affect_others(sample_zoo):
    sample_zoo[0].feed()
    assert sample_zoo[0].hunger == 25
    assert sample_zoo[1].hunger == 50
    assert sample_zoo[2].hunger == 50

def test_zoo_size_matches_number_of_animals_created(sample_zoo):
    assert len(sample_zoo) == 3
📝 Why These Specific Tests
  1. test_base_animal_cannot_make_sound: Verifies the contract-enforcement itself — that instantiating the base class directly and calling the unimplemented method actually raises, rather than silently doing nothing.
  2. test_hunger_never_goes_negative: Directly tests the max(0, ...) clamp by feeding an animal far more times than its hunger can absorb — exactly the kind of edge case a code review comment would flag, now automated.
  3. test_each_subclass_returns_a_unique_sound: Uses a Python set to assert all three sounds are distinct — a copy-paste bug where two subclasses accidentally return the same string would fail this test immediately.
  4. The @pytest.mark.parametrize test: Runs the same assertion logic against all three subclasses without three copy-pasted test functions — the test suite itself follows the "one interface, many implementations" idea it's testing.
  5. The sample_zoo fixture: Centralizes zoo construction so every test starts from an identical, known state — the same reason the JavaScript simulator resets hunger to 50 for every new animal.
  6. test_feeding_one_animal_does_not_affect_others: Directly guards against Mistake 4 from the Debugging Guide — if hunger were ever accidentally declared as a shared class attribute instead of an instance attribute, this test would immediately fail.

Common Mistakes When Writing This Kind of Hierarchy

Every mistake below is one a real beginner makes on their first attempt at this exact pattern. Recognizing the symptom is often faster than re-reading the theory.

Mistake 1: Forgetting super().__init__()

class Lion(Animal):
    def __init__(self, name: str):
        self.roar_volume = "loud"

Symptom: AttributeError: 'Lion' object has no attribute 'name' the first time you call .feed() or .makeSound(). Fix: call super().__init__(name) as the first line of any overridden __init__.

Mistake 2: Misspelling the Method Name in the Override

class Lion(Animal):
    def make_sound(self) -> str:
        return "ROAR"

Symptom: No error at all — Python happily lets you define a brand-new method. Calling lion.makeSound() still raises the parent's NotImplementedError. Fix: the override's method name must match the parent's exactly — copy-paste the def line from the parent class instead of retyping it.

Mistake 3: Calling super() with the Wrong Method Name

class Lion(Animal):
    def makeSound(self) -> str:
        base = super().feed()
        return f"{self.name} roars."

Symptom: No crash, but hunger mysteriously decreases every time an animal makes a sound. Fix: super().<method>() should almost always call the same method name you're currently overriding.

Mistake 4: Mutating a Class Attribute Instead of an Instance Attribute

class Animal:
    hunger = 50

    def feed(self) -> str:
        Animal.hunger -= 25
        return "fed"

Symptom: Feeding any animal reduces every animal's hunger. Fix: initialize per-instance state inside __init__ as self.hunger = 50, never as a bare class-body assignment.

Mistake 5: Testing the Wrong Thing with type() Instead of isinstance()

if type(animal) == Lion:
    print("It's a lion!")

if isinstance(animal, Lion):
    print("It's a lion (or some kind of lion)!")

Symptom: Code that works today silently stops recognizing an animal once you introduce a deeper hierarchy. Fix: prefer isinstance() over type() == whenever you're checking "is this object part of this family."

Mistake 6: Overriding __init__ Signature Incompatibly

class Parrot(Animal):
    def __init__(self, name: str, vocabulary_size: int):
        super().__init__(name)
        self.vocabulary_size = vocabulary_size

for cls in [Lion, Elephant, Parrot]:
    cls("Test")

Symptom: Generic code that constructs any subclass the same way breaks specifically on the one subclass with a different constructor signature (violating Liskov Substitution). Fix: give new required parameters a sensible default, or keep the constructor signature consistent across the whole family.

Mistake 7: Forgetting That print() Isn't the Same as return

class Lion(Animal):
    def makeSound(self) -> str:
        print(f"{self.name} roars")

message = leo.makeSound()
print(f"Logged: {message}")

Symptom: The sound appears to work fine when called directly, but any code that tries to use the returned value gets None. Fix: decide once whether a method's job is to print or to return, and stay consistent with what the base class's signature promises.

Mistake 8: Catching Exceptions Too Broadly Around Polymorphic Calls

for animal in zoo:
    try:
        print(animal.makeSound())
    except Exception:
        pass

Symptom: An animal that was never given a proper makeSound() override just doesn't print anything, with no error anywhere. Fix: catch specific, expected exceptions only; never blanket-catch Exception around code whose safety net depends on a specific error actually surfacing.

Mistake 9: Not Checking the MRO Before Adding Multiple Inheritance

class LandAnimal:
    def feed(self) -> str:
        return "Grazes on land."

class WaterAnimal:
    def feed(self) -> str:
        return "Filters food from water."

class Hippo(LandAnimal, WaterAnimal):
    pass

print(Hippo().feed())
print(Hippo.__mro__)

Symptom: Multiple inheritance silently picks one parent's method over the other based on the order they were listed — class Hippo(LandAnimal, WaterAnimal) behaves differently from class Hippo(WaterAnimal, LandAnimal). Fix: whenever two parent classes define the same method name, print Cls.__mro__ and confirm the order matches your intent.

A Full Debugging Session, Narrated

Here's what actually diagnosing one of these bugs looks like in practice, start to finish — useful to read once so the process feels familiar the first time it happens to you for real.

The report: "I added a Giraffe class and now feeding any animal in my zoo raises AttributeError: 'Giraffe' object has no attribute 'hunger' — but only for Giraffe, everything else still works."

Step 1 — Reproduce narrowly. Confirm it's specifically Giraffe("Gerald").feed() that fails, not the whole zoo.

Step 2 — Read the traceback bottom to top. The error points at self.hunger = max(0, self.hunger - 25) inside Animal.feed(). That's shared code every subclass uses — so the bug isn't in that line, it's in why Giraffe never got a hunger attribute.

Step 3 — Check the constructor. Sure enough:

class Giraffe(Animal):
    def __init__(self, name: str, neck_length_m: float):
        self.neck_length_m = neck_length_m

Step 4 — Connect it to a known pattern. This is Mistake 1 from earlier on this exact page: a forgotten super().__init__() call.

Step 5 — Fix and verify with a test. Add super().__init__(name) as the constructor's first line, then run a test like test_hunger_never_goes_negative against Giraffe specifically.

The pattern behind the pattern: almost every bug in an inheritance hierarchy traces back to one of the numbered mistakes on this page. Building a mental index of "symptom → likely mistake number" is, in practice, most of what makes debugging these hierarchies fast.

Real-World Case Study: A Notification System

Same Shape, Different Domain

To prove this isn't just an animal-specific trick, here's the identical structural pattern applied to a problem you're far more likely to actually build: a notification system that can send messages by email, SMS, or push notification. Read it side by side with the Zoo hierarchy — the shapes are line-for-line identical.

class Notifier:
    def __init__(self, recipient: str):
        self.recipient = recipient
        self.sent_count = 0

    def send(self, message: str) -> str:
        self.sent_count += 1
        return f"[{self.sent_count}] Queued for {self.recipient}: {message}"

    def channel_name(self) -> str:
        raise NotImplementedError("Subclasses must implement channel_name()")

class EmailNotifier(Notifier):
    def send(self, message: str) -> str:
        base = super().send(message)
        return f"{base} (via SMTP, HTML formatted)"

    def channel_name(self) -> str:
        return "Email"

class SMSNotifier(Notifier):
    def send(self, message: str) -> str:
        base = super().send(message[:160])
        return f"{base} (via carrier gateway, 160-char limit)"

    def channel_name(self) -> str:
        return "SMS"

class PushNotifier(Notifier):
    def send(self, message: str) -> str:
        base = super().send(message)
        return f"{base} (via FCM/APNs, silent if app is foregrounded)"

    def channel_name(self) -> str:
        return "Push"

class AlertDispatcher:
    def __init__(self, channels: list):
        self.channels = channels

    def broadcast(self, message: str) -> None:
        for channel in self.channels:
            print(f"[{channel.channel_name()}] {channel.send(message)}")

dispatcher = AlertDispatcher([
    EmailNotifier("glenn@example.com"),
    SMSNotifier("+63-900-000-0000"),
    PushNotifier("device-token-abc123"),
])
dispatcher.broadcast("Server deploy completed successfully.")

Map it back to the zoo: Notifier ↔ Animal, channel_name() ↔ makeSound(), send() ↔ feed(), and AlertDispatcher.broadcast() ↔ the zookeeper's for animal in zoo loop. A dispatcher was written once and will keep working correctly the day someone adds a SlackNotifier or a WebhookNotifier.

Second Example: A UI Widget Hierarchy

One more domain, because two examples make the generalization much harder to doubt. This time: rendering a form made of different input widgets, each of which knows how to render itself as HTML.

class FormField:
    def __init__(self, label: str, name: str):
        self.label = label
        self.name = name

    def render(self) -> str:
        raise NotImplementedError("Subclasses must implement render()")

class TextField(FormField):
    def render(self) -> str:
        return f'<label>{self.label}</label><input type="text" name="{self.name}">'

class CheckboxField(FormField):
    def render(self) -> str:
        return f'<label><input type="checkbox" name="{self.name}"> {self.label}</label>'

class DropdownField(FormField):
    def __init__(self, label: str, name: str, options: list):
        super().__init__(label, name)
        self.options = options

    def render(self) -> str:
        opts = "".join(f'<option>{o}</option>' for o in self.options)
        return f'<label>{self.label}</label><select name="{self.name}">{opts}</select>'

class Form:
    def __init__(self, fields: list):
        self.fields = fields

    def render(self) -> str:
        return "".join(field.render() for field in self.fields)

signup_form = Form([
    TextField("Full Name", "name"),
    CheckboxField("Subscribe to newsletter", "newsletter"),
    DropdownField("Country", "country", ["PH", "US", "UK"]),
])
print(signup_form.render())

The same shape, a third time: FormField ↔ Animal, render() ↔ makeSound(), and Form.render() ↔ the zookeeper's loop. This is, structurally, how real UI frameworks like React's class components, Django's form widgets, and native mobile UI toolkits render heterogeneous collections of components without a giant if isinstance(field, TextField): ... elif ... chain.

How Real Frameworks Use This Exact Pattern

A quick tour of the pattern in the wild, so the next time you read one of these frameworks' source code, the shape jumps out immediately instead of looking like unfamiliar magic.

FrameworkBase ClassWhat Gets Overridden
Djangomodels.ModelField declarations and Meta options — every model "is a" database table row.
Django REST FrameworkAPIViewget(), post(), put() — the HTTP dispatcher never checks which view it's calling.
PyTorchnn.Moduleforward() — every custom layer or model overrides exactly this one method.
pytestunittest.TestCasesetUp(), tearDown() — a Template Method pair subclasses hook into.
Flask-RESTfulResourceget(), post(), delete() — structurally identical to Django REST Framework's approach.
Gymnasium (RL)Envstep(), reset() — every custom environment overrides exactly these two.

Glossary of Terms Used on This Page

A quick-reference table for every term this page assumes you already know, or half-explains in passing.

TermMeaning
Base class / Parent classThe class being inherited from — Animal in this project.
Subclass / Child classA class that inherits from another — Lion, Elephant, and Parrot here.
Method overridingA subclass redefining a method that already exists on its parent, with the same name and signature.
super()A built-in that gives you access to the parent class's version of a method from inside an override.
PolymorphismCalling the same method name on different objects and getting behavior appropriate to each object's actual class.
Dynamic dispatchThe runtime mechanism that decides, at the moment a method is called, which actual class's implementation to run.
MRO (Method Resolution Order)The exact order Python searches through a class hierarchy when looking up a method or attribute.
isinstance(obj, Cls)Checks whether an object is an instance of a class or any of its subclasses.
NotImplementedErrorAn exception deliberately raised in a base class method to force subclasses to override it before use.
"is-a" relationshipThe test for whether inheritance is appropriate — "a Lion is a(n) Animal" holds true.
CompositionBuilding behavior by holding a reference to another object as an attribute, instead of inheriting from a class.
Multilevel inheritanceA chain of three or more classes — Tiger → BigCat → Animal, as explored in Exercise 05.
Multiple inheritanceA class inheriting from two or more parents at once — class FlyingSquirrel(Mammal, Glider):.
Liskov Substitution PrincipleAny subclass should be usable anywhere its parent class is expected, without breaking the caller.
Template Method patternA design pattern where a base class defines a fixed algorithm skeleton and defers specific steps to subclasses.
Instance attribute vs. class attributeAn instance attribute (self.hunger) belongs to one object; a class attribute is shared by every instance.
Duck typingPython's tendency to care about what an object can do rather than its declared type.
Contract (informal)The implicit agreement that every subclass's override behaves consistently with what callers expect.
MixinA class meant to be combined with others via multiple inheritance to add one specific capability.
Factory Method (pattern)Centralizing which concrete subclass to instantiate behind one function.
Strategy (pattern)Achieving flexible, swappable behavior through composition instead of inheritance.
Open/Closed PrincipleClasses should be open for extension but closed for modification.
Type checker (static)A tool like mypy that analyzes type hints without running the code.
Class attribute counterA shared, class-level value that every instance can read and increment.
Parametrized testA single test function run multiple times with different inputs, via @pytest.mark.parametrize.
Runtime classThe actual, concrete class an object was constructed as — what Python's dynamic dispatch looks up.

Exercises: Extend the Zoo Yourself

Reading a working hierarchy is one thing — extending it without breaking it is where the concept actually sinks in. Copy the Python source above into your own editor (or the Run panel) and work through these, in order. Each one builds on the last.

Beginner01

Add a Giraffe

Create a fourth subclass, Giraffe(Animal), that overrides makeSound() to return a humming sound and overrides feed() to append a sentence about eating acacia leaves via super().feed(). Add it to the zoo list and confirm the loop picks it up automatically.

Beginner02

Refuse to Overfeed

Modify Animal.feed() so that if self.hunger is already 0, it returns f"{self.name} is already full." instead of the usual message. Then verify every subclass still works without touching Lion, Elephant, or Parrot at all.

Intermediate03

Add a Diet Attribute

Give Animal.__init__ a new parameter, diet: str, and store it as self.diet. Update each subclass's super().__init__() call to pass the right value. Add a new method describe_diet() on Animal that every subclass inherits unchanged.

Intermediate04

Build a Zookeeper Class

Write a Zookeeper class with a rounds(zoo: list) method that loops through every animal and calls feed() and makeSound(). Call Zookeeper().rounds(zoo) instead of writing the loop inline.

Intermediate05

Multilevel: Add a BigCat Layer

Insert a new class BigCat(Animal) between Animal and Lion, so the chain becomes Lion(BigCat) and BigCat(Animal). Give BigCat a shared pounce() method. Add a Tiger(BigCat) subclass that also gets pounce() for free.

Challenge06

Wire It Into the JS Simulator

Open this page's source and find the JavaScript Lion/Elephant/Parrot classes near the bottom. Add a matching Giraffe extends Animal class, then wire a fourth "Add Giraffe" button into the spawn bar.

Beginner07

Add a __repr__ Method

Give Animal a __repr__(self) that returns something like f"<{type(self).__name__} name={self.name!r} hunger={self.hunger}>". Confirm that printing each animal shows the correct class name automatically.

Beginner08

Count Animals by Species

Write a standalone function count_by_species(zoo: list) -> dict that returns a dictionary like {"Lion": 1, "Elephant": 2}. Use type(animal).__name__ to get each animal's class name as a string.

Intermediate09

Add a Class-Level Population Counter

Add a class attribute population = 0 directly on Animal, and increment Animal.population += 1 inside __init__. Confirm all three species increment the same shared counter.

Intermediate10

Write the pytest Suite Yourself

Before looking at the Testing section above, write your own test_hunger_never_goes_negative and test_each_subclass_returns_a_unique_sound functions from scratch. Then compare your version against the one provided.

Challenge11

Refactor to the Strategy Pattern

Using the Strategy pattern example from the Design Patterns section as a starting point, fully rewrite the hierarchy as a single Animal class that takes a sound_strategy and diet_strategy object. Then argue which version — inheritance or composition — you'd actually choose, and why.

Challenge12

Build the Notification System

Implement the full Notifier/EmailNotifier/SMSNotifier/PushNotifier/AlertDispatcher hierarchy from the Case Study section yourself, from memory. Then add a fourth channel, SlackNotifier, and confirm the dispatcher picks it up with zero changes.

Stuck? Hints (Not Solutions)

Each hint nudges you toward the right mechanism without writing the code for you — try for at least ten minutes before reading the matching hint.

ExerciseHint
01 — GiraffeCopy Lion's structure exactly and just change the two strings inside feed() and makeSound().
02 — Refuse to OverfeedCheck self.hunger == 0 as the very first line inside feed() and return early with a different message.
03 — Diet AttributeAdd diet as a new parameter to Animal.__init__, then update every super().__init__(...) call.
04 — Zookeeper ClassThe method body is almost a direct copy of the existing bottom-of-file for loop — just wrap it.
05 — BigCat LayerChange class Lion(Animal): to class Lion(BigCat):, then define class BigCat(Animal): above it.
06 — Wire Into JSFind the NAME_POOLS object and the if (kind === 'lion') ... chain inside makeAnimal() — both need a new branch.
07 — __repr__type(self).__name__ gives you the actual runtime class name as a string.
08 — Count by Speciescollections.Counter(type(a).__name__ for a in zoo) does most of the work.
09 — Population CounterDeclare population = 0 directly inside the class body of Animal, then reference it as Animal.population.
10 — Write Your Own TestsStart from "what should never happen" (hunger below zero) and "what must always differ" (each subclass's sound).
11 — Strategy RefactorGive Animal a constructor parameter that accepts a small object with a describe() method.
12 — Notification SystemBuild Notifier first with just send() and channel_name(), get one subclass working end to end, then copy the shape.

No solutions posted on purpose: Every exercise above is checkable by running your code and reading the output — that feedback loop is more valuable than a provided answer key.

Interview Prep: Questions You Can Now Answer

Technical interviews love inheritance questions because they're easy to ask and hard to answer well without hands-on experience. Having built this project, you can now answer every one of these with a concrete example instead of a memorized definition.

Q1. What's the difference between inheritance and composition, and when would you choose one over the other?
Inheritance models an "is-a" relationship and is fixed for an object's lifetime — a Lion is always a Lion. Composition models a "has-a" relationship and can change at runtime. Choose inheritance when the specialization is permanent and the hierarchy is shallow; choose composition when the behavior needs to be swappable.
Q2. Walk me through what happens when you call an overridden method on a subclass instance.
Python looks up the method starting from the object's actual runtime class — not the variable's declared type — following the Method Resolution Order. It finds the closest override in that chain and executes it. If that override calls super().method(), the lookup continues up the chain.
Q3. Why would you deliberately make a base class method raise NotImplementedError?
To enforce a contract: every subclass must supply its own implementation before the class can be meaningfully used. It converts a silent, hard-to-trace bug into an immediate, loud failure at the exact call site where the missing piece matters.
Q4. What's the Liskov Substitution Principle, and can you give an example of violating it?
It states that a subclass should be usable anywhere its parent is expected without breaking the caller's assumptions. Mistake 6 in this project's Debugging Guide is a direct violation: giving Parrot a required constructor argument the other subclasses don't have.
Q5. How does Python resolve method lookups when a class inherits from multiple parents?
Via the C3 linearization algorithm, producing a single, consistent Method Resolution Order stored on Cls.__mro__. Python searches that order left to right and uses the first matching implementation it finds.
Q6. What's a practical difference between an instance attribute and a class attribute, and how have you seen that go wrong?
An instance attribute belongs to one object; a class attribute is shared across every instance. Mistake 4 in this project shows the failure mode: defining hunger as a class attribute means feeding any one animal silently changes every animal's hunger.
Q7. How would you unit test that a base class's contract is actually being enforced by its subclasses?
Write a parametrized test that instantiates each subclass and asserts the overridden method returns something meaningful, plus a separate test confirming the base class itself still raises when called directly.
Q8. What's the Factory Method pattern, and why not just call constructors directly everywhere?
Factory Method centralizes the decision of which concrete subclass to instantiate in one place — like this project's make_animal(species, name) function. When a new subclass is added, only the factory needs a new entry.
Q9. If you had a hierarchy five levels deep, how would you refactor it?
Start by identifying which parts of the chain are genuine "is-a" relationships versus behaviors that got bolted on as convenient shortcuts. Behaviors used by only some branches are strong candidates for extraction into mixins or composed strategy objects.
Q10. How do type hints interact with inheritance and static type checkers?
A subclass's overridden method should return a type compatible with what the base class promises. Tools like mypy check this automatically: if Animal.feed() -> str and a subclass override implicitly returns None, mypy flags it before the code ever runs.
Q11. What's a mixin, and how is it different from a normal parent class?
A mixin is a class designed to be combined with others via multiple inheritance to add one specific, self-contained capability — it typically has no __init__ of its own and isn't meant to be instantiated alone.
Q12. Describe a bug you'd expect from forgetting super().__init__(), and how you'd diagnose it.
The subclass instance would be missing every attribute the parent constructor was responsible for setting — calling any method that reads those attributes raises AttributeError. Read the traceback for which attribute is missing, then check the subclass's __init__.

Frequently Asked Questions

Why does Animal.makeSound() raise an error instead of just returning a generic sound?

+

Returning a generic sound like "..." would let a broken subclass silently pass — you'd only notice something was wrong by reading the output and thinking "that doesn't sound right." Raising NotImplementedError instead fails immediately and loudly, at the exact moment the missing override matters, which is far easier to debug.

What happens if a subclass forgets to call super().feed()?

+

Nothing crashes — Python doesn't require it. But the subclass's feed() would then be responsible for updating self.hunger itself, or hunger would silently stop decreasing whenever that animal is fed.

Isn't this the same as just writing three separate, unrelated feed_lion(), feed_elephant(), and feed_parrot() functions?

+

No — and that difference is the whole point. Three separate functions would each need their own hunger-clamping logic duplicated, and the calling code would need an if/elif chain to pick the right one. With inheritance, the hunger math exists exactly once, and the calling loop never checks species at all.

Does the JavaScript simulator work identically to the Python source?

+

Structurally, yes — class Lion extends Animal with super.feed() follows the same override-and-extend pattern as Python's class Lion(Animal) with super().feed(). The syntax differs but the dispatch mechanism is conceptually identical in both languages.

Is my zoo roster saved anywhere if I refresh the page?

+

No. The simulator keeps your roster in an in-memory JavaScript array for the current page session only, so refreshing or navigating away resets it.

Can I add a species that behaves like two different animals at once?

+

Yes, using multiple inheritance — class FlyingSquirrel(Mammal, Glider):, for example. Python resolves conflicts between the parents using the Method Resolution Order (MRO).

Why start hunger at 50 instead of 0 or 100?

+

A deliberate design choice, not a technical requirement — starting at the midpoint means the hunger bar visibly moves the first time you click "Feed" without immediately hitting the floor.

Why does the Case Study use a totally different example (notifications) instead of more animals?

+

To prove the pattern generalizes. If the only example you'd ever seen was animals, it would be easy to mistake "inheritance" for "a trick that works for zoo simulators." Seeing the identical shape reappear in a notification system is what proves you learned a transferable pattern.

Do I need to know the Template Method and Strategy pattern names to use inheritance well?

+

No — you can write correct code without ever naming the pattern. But knowing the names lets you communicate design decisions precisely with other engineers instead of re-explaining the whole mechanism every time.

What's the actual difference between type(x) == Lion and isinstance(x, Lion)?

+

type(x) == Lion only matches if x is exactly a Lion — a hypothetical WhiteLion(Lion) subclass would fail the check. isinstance(x, Lion) matches Lion and any of its subclasses.

Can two different subclasses accidentally have identical makeSound() output, and would that be a bug?

+

Nothing stops it syntactically — Python doesn't enforce distinct return values. Whether it's a bug depends entirely on your domain; for a zoo simulator it would almost certainly be an accidental copy-paste.

Why does the pytest suite use @pytest.mark.parametrize instead of three separate test functions?

+

Because writing test_lion_diet, test_elephant_diet, and test_parrot_diet as three near-identical functions would duplicate the same assertion logic three times. Parametrizing keeps the test's own logic written once, run three times.

Is it bad practice to have every subclass call super() in every overridden method?

+

Not inherently — it depends on whether the parent's logic is actually worth reusing. feed() calls super() in every subclass because the hunger-clamping math is genuinely shared. makeSound() deliberately does not call super(), because the base version only raises an error.

How would this project change if two animals needed to interact with each other, like a predator and prey?

+

You'd likely introduce a separate method or a small coordinating class rather than cramming interaction logic into Animal itself — for example, a hunt(predator, prey) function or a PredatorMixin class that only certain subclasses inherit.

Does adding more subclasses ever make the base class itself need to change?

+

Ideally, no — that's the whole promise of the Open/Closed Principle: classes should be open for extension but closed for modification. If adding a new animal type keeps forcing you back into Animal's code, that's a signal the base class's interface is missing something.

Where should I go next after finishing this project?

+

Back to the Inheritance guide's Projects section for the other nine planned builds, or forward to the Polymorphism guide — since everything this project demonstrated is, formally speaking, polymorphism in action.

Step-by-Step Execution Trace

The Line-by-Line Walkthrough in the Python Source section explains what each line does. This table traces exactly what happens, statement by statement, when the bottom of that script actually runs.

StepStatementWhat Actually Happens
1Lion("Leo")Lion.__init__ doesn't exist, so Python uses Animal.__init__ directly. self.name = "Leo", self.hunger = 50.
2Elephant("Ella")Same lookup: no Elephant.__init__ override exists, so Animal.__init__ runs again for a completely separate object.
3Parrot("Polly")Same again — three independent objects, three independent self.hunger = 50 values.
4zoo = [leo, ella, polly]One list object is created holding three references. The list itself has no idea these are different classes.
5for animal in zoo: (iteration 1)animal is bound to the Lion object. Python does not evaluate anything about its type yet.
6animal.makeSound()Python looks up makeSound starting at type(animal), which is Lion. Finds Lion.makeSound immediately.
7return f"{self.name} lets out a mighty ROAR!"self.name resolves to the instance attribute set in step 1: "Leo".
8print(...)The string "Leo lets out a mighty ROAR!" is written to stdout.
9animal.feed()Lookup starts at Lion again, finds Lion.feed. Inside it, super().feed() explicitly continues the lookup from Animal.
10self.hunger = max(0, 50 - 25)Executes inside Animal.feed, using self — which is still the same Lion object. self.hunger becomes 25.
11return f"{self.name} has been fed."Animal.feed returns "Leo has been fed." back up to Lion.feed, where it's stored in the local variable base.
12return f"{base} It devours a slab of meat."Lion.feed appends its own sentence and returns the combined string, which print() then writes out.
13Loop iteration 2animal is rebound to the Elephant object. Steps 6–12 repeat identically in structure, but every lookup now resolves to Elephant's overrides.
14Loop iteration 3animal is rebound to the Parrot object; the same structural steps run a third time.
15Loop endszoo is exhausted. Six total lines have been printed — two per animal — and at no point did any line of code branch on species.

The one idea worth internalizing from this table: steps 6 and 9 are the only places where "which subclass is this?" gets decided, and neither of them involves an if statement — it's a lookup, not a decision your code wrote out by hand.

Performance & Memory Notes

Is Method Lookup Through Inheritance Slow?

In CPython, resolving animal.makeSound() walks the object's Method Resolution Order until it finds a match, then caches that result. For a hierarchy this shallow — one level deep — the lookup cost is negligible, effectively a single dictionary access per call.

Rule of thumb: Don't avoid inheritance for performance reasons at this scale — reach for flatter hierarchies when the design calls for it, not because you're worried about lookup speed.

Memory: Every Instance Carries Its Own __dict__

By default, each Lion, Elephant, and Parrot instance stores its attributes in a per-instance dictionary. For three animals in a browser demo, this is irrelevant. For a real system tracking hundreds of thousands of objects, that per-instance dictionary overhead adds up — which is exactly the problem __slots__ solves.

class Animal:
    __slots__ = ('name', 'hunger')

    def __init__(self, name: str):
        self.name = name
        self.hunger = 50

class Parrot(Animal):
    __slots__ = ('vocabulary_size',)

    def __init__(self, name: str, vocabulary_size: int = 0):
        super().__init__(name)
        self.vocabulary_size = vocabulary_size

Every subclass in a __slots__-based hierarchy must declare its own __slots__ tuple — even an empty one, __slots__ = () — or Python silently gives that subclass a __dict__ anyway.

The Real Cost Is Almost Always Human, Not Computational

In practice, the performance question that matters far more than CPU cycles is: how long does it take a new engineer to understand this hierarchy? A shallow, well-named hierarchy costs almost nothing to read. Deep, tangled hierarchies cost real engineering time regardless of how fast the CPU resolves them.

A Closer Look at the Type Hints

Every method in the Python source uses type hints — name: str, -> str — that are easy to skim past. They're not decoration; they're a second, machine-checkable description of the contract every subclass has to honor.

def feed(self) -> str:
    ...

def makeSound(self) -> str:
    raise NotImplementedError(...)

Because Animal.feed() is annotated -> str, a static type checker like mypy can flag any subclass override that returns something else — say, None because a developer forgot a return statement — before the code ever runs.

class BrokenLion(Animal):
    def makeSound(self) -> str:
        print(f"{self.name} roars")

Why this matters for inheritance specifically: type hints on a base class method are effectively a formal, checkable version of the "contract" language used throughout this page. mypy --strict run over this hierarchy would catch a mismatched override before you ever clicked "Run".

Code Review Checklist for Inheritance Hierarchies

A condensed checklist distilled from every mistake, pattern, and test covered on this page. Run any new subclass you write against this list before considering it done.

CheckWhy It Matters
Does the "is-a" test genuinely hold?Prevents forcing an unnatural relationship into inheritance.
Does __init__ call super().__init__()?Guarantees inherited attributes are actually initialized — Mistake 1.
Does the override's method name match exactly?A typo silently creates a new method instead of overriding — Mistake 2.
Does super().method() reference the correct method name?Calling the wrong parent method causes silent side effects — Mistake 3.
Is shared state stored as an instance attribute, not a class attribute?Class attributes are shared across every instance — Mistake 4.
Does type-checking code use isinstance(), not type() ==?Respects future subclasses instead of breaking on them — Mistake 5.
Is the constructor signature compatible across all subclasses?Violating this breaks the Liskov Substitution Principle — Mistake 6.
Does at least one test confirm the base class enforces its contract?Catches a forgotten override immediately.
Does at least one test confirm subclasses produce genuinely distinct output?Catches accidental copy-paste duplication between subclasses.
Are type hints present and accurate on every overridden method?Lets a static type checker catch contract violations before runtime.
Is the hierarchy no deeper than 2–3 levels without a specific reason?Deep hierarchies cost real engineering time to read and reason about.
Would composition (a Strategy object) be a better fit than a new subclass?Worth asking explicitly whenever the "difference" between subclasses is really just one swappable behavior.

Further Reading & Related Material

On This Site

  • The full Inheritance guide — every concept this project demonstrates, explained from first principles.
  • Encapsulation — covers @property, access modifiers, and __slots__ in far more depth.
  • Polymorphism — the formal name for "one interface, many overriding subclasses."
  • Abstraction — a closer look at why Animal.makeSound() exposes only an interface.

Standard Library & Language Reference

  • The abc module — for formally enforcing abstract methods with @abstractmethod.
  • pytest.mark.parametrize — the decorator used in the Testing section.
  • typing module and mypy — for statically checking the contracts described in the Type Hints section.
  • Python's data model documentation on __mro__ and super() — the authoritative reference for exactly how method resolution order is computed.

A Note on This Page's Own Accessibility

Practicing What the Guide Preaches

It would be a little ironic to teach clean interface design while shipping an inaccessible page, so a few concrete choices are worth naming explicitly: every section carries an aria-labelledby pointing at its heading, interactive elements like the FAQ accordion and animal action buttons are real <button> elements (not clickable <div>s, which keyboard and screen-reader users can't reliably activate), and color is never the only signal — hunger bars pair color with a numeric label, and FAQ items pair a rotating "+" icon with an actual open/closed state change.

Why this belongs on a technical page at all: the same discipline that makes a class hierarchy maintainable — clear contracts, no hidden coupling, one responsibility per component — is the same discipline that makes a web page usable by everyone. Both are "interface design," just at different layers.

The JavaScript Behind the Live Simulator

The Python Source section above is the "official" teaching example, but the Live Simulator you clicked through earlier is powered by real JavaScript — not a scripted animation. Here it is in full, so you can compare it line-for-line against the Python version and see exactly where the two languages agree and where their syntax diverges.

class Animal {
  constructor(name) {
    this.name = name;
    this.hunger = 50;
  }
  feed() {
    this.hunger = Math.max(0, this.hunger - 25);
    return this.name + ' has been fed.';
  }
  makeSound() {
    throw new Error('Subclasses must implement makeSound()');
  }
}

class Lion extends Animal {
  feed() { return super.feed() + ' It devours a slab of meat.'; }
  makeSound() { return this.name + ' lets out a mighty ROAR!'; }
}

class Elephant extends Animal {
  feed() { return super.feed() + ' It munches on leaves and hay.'; }
  makeSound() { return this.name + ' trumpets loudly!'; }
}

class Parrot extends Animal {
  feed() { return super.feed() + ' It cracks open a sunflower seed.'; }
  makeSound() { return this.name + " squawks: 'Polly wants a cracker!'"; }
}
📝 Same Idea, Different Syntax — A Direct Comparison
  1. class Lion(Animal): vs. class Lion extends Animal {'{'}: Python uses parentheses after the class name; JavaScript uses the explicit keyword extends. Both declare the exact same relationship.
  2. super().feed() vs. super.feed(): Python calls super() as a function first, then accesses the method; JavaScript treats super as a direct reference to the parent's prototype methods. Same purpose, different grammar.
  3. self vs. this: Python requires self as an explicit first parameter on every method; JavaScript's this is implicit and bound automatically for class methods. Both refer to "the specific object this method was called on."
  4. raise NotImplementedError(...) vs. throw new Error(...): Python's exception hierarchy has a dedicated, semantically precise exception for this exact situation; JavaScript's generic Error class is used the same way, since JavaScript has no equivalent dedicated subclass built in by default.
  5. What's identical: the dispatch mechanism. In both languages, calling .makeSound() on an object resolves to that object's actual runtime class first — never the declared type, never a hardcoded check. That single fact is the entire lesson, expressed twice.

A Brief History of the Animal-Hierarchy Example

Why Every OOP Course Reaches for Animals

Object-oriented programming as a distinct paradigm traces back to Simula in the 1960s and was popularized by Smalltalk in the 1970s, but the specific "Animal → Dog/Cat" teaching example became a near-universal convention once Java's rise in the late 1990s put beginner OOP education in front of millions of new programmers. Early, widely used textbooks needed a domain that required zero specialized knowledge to evaluate — nobody needs a computer science background to agree that a dog barking and a cat meowing are both "an animal making a sound, differently."

That choice stuck for a practical reason: it isolates the OOP concept being taught from the domain knowledge needed to understand the example. A payroll example forces you to half-understand payroll rules before you can even see the inheritance; an animal example asks nothing of you except things you already knew in kindergarten.

Why this project follows the convention deliberately, not lazily: using a well-worn example on purpose means every ounce of unfamiliarity in this page comes from the OOP mechanism itself, not from the domain — and the Case Study and UI Widget sections above exist specifically to prove the lesson doesn't stay trapped inside the familiar example.

Naming Conventions for Hierarchies Like This One

Every name in this project's code was chosen deliberately. A few conventions worth adopting in your own hierarchies:

ConventionExample From This ProjectWhy
Base class names describe the category, not a specific memberAnimal, not Lion or GenericPetSignals immediately that this class is meant to be extended, not instantiated directly in normal use.
Subclass names are concrete nounsLion, Elephant, ParrotReads naturally in the "is-a" test: "a Lion is an Animal."
Overridden methods keep the parent's exact verbfeed(), makeSound() — never renamed per subclassA renamed method (lionRoar() instead of makeSound()) silently stops being an override — see Mistake 2 in the Debugging Guide.
Boolean-returning methods read as yes/no questions(Not used in this project, but the convention: is_hungry(), not hunger_check())Makes call sites read like plain English: if animal.is_hungry():.
Private helper methods that support the contract get a leading underscore(See the Encapsulation guide's _hash() example)Signals "internal use only" without Python enforcing it at the language level.
Type hints match the base class's promise exactlyEvery feed() and makeSound() override returns -> strLets static type checkers catch a broken contract before runtime — see the Type Hints section.

A Simple Decision Tree: Should This Be a Subclass?

Work Through These Questions, in Order

Every time you're tempted to write class NewThing(Animal): — or any new subclass of any hierarchy — walk through this sequence before committing to it.

QuestionIf YesIf No
1. Does the "is-a" test genuinely hold?Continue to question 2.Stop — use composition (a "has-a" attribute) instead.
2. Does the new type need to override at least one existing method?Continue to question 3.Consider whether you need a new class at all, or just a new instance of an existing class with different data.
3. Can the new type's behavior be fully expressed by overriding Animal's existing two methods?Write a normal subclass, exactly like Lion, Elephant, or Parrot.Consider whether Animal's interface itself needs a new method — added carefully, since every existing subclass will need to handle it too.
4. Does the behavior need to change at runtime, not just per-type?Reconsider Strategy (composition) instead of inheritance, even if question 1 passed.Inheritance remains the right, simpler choice.
5. Will this push the hierarchy past 2–3 levels deep?Pause and reconsider whether a mixin or composition would flatten the design.Proceed — the hierarchy is still shallow enough to reason about easily.

The point of this tree isn't to be followed mechanically every time — it's to make the judgment calls this page has been building toward (the "is-a" test, Strategy vs. inheritance, hierarchy depth) into a repeatable habit instead of a one-off insight you read once and forgot.

A Mock Live-Coding Prompt, Solved Step by Step

The Prompt

"Design a small class hierarchy for a zoo's feeding system. There should be a way to feed any animal in a collection without the calling code knowing which specific animal it's feeding. Each animal type should reduce hunger the same way, but describe the feeding differently. Add a test proving your solution actually differentiates between types."

Step 1: Restate the Requirements as a Checklist

  • Need a shared interface for feeding across all animal types.
  • The calling code must not branch on type — this is explicitly asking for polymorphism.
  • Hunger reduction logic should be shared, not duplicated per type.
  • Each type needs its own description text.
  • Needs at least one test proving the differentiation actually works.

Step 2: Recognize the Shape You Already Know

This is, almost word for word, the exact Animal/Lion/Elephant/Parrot hierarchy from this project. Naming that recognition out loud in a real interview is itself a good signal — it shows you can map an unfamiliar-sounding prompt back onto a pattern you've actually built before, rather than starting from a blank page.

Step 3: Write the Base Class First, Interface Only

class Animal:
    def __init__(self, name: str):
        self.name = name
        self.hunger = 50

    def feed(self) -> str:
        self.hunger = max(0, self.hunger - 25)
        return self._feed_message()

    def _feed_message(self) -> str:
        raise NotImplementedError

Notice the small deliberate change from the main project: splitting out a private _feed_message() hook instead of having subclasses override feed() directly. This is the Template Method pattern applied slightly more strictly — the hunger math in feed() is now completely un-overridable, which is worth mentioning out loud as a design tradeoff in a live interview.

Step 4: Fill In the Subclasses

class Lion(Animal):
    def _feed_message(self) -> str:
        return f"{self.name} devours a slab of meat."

class Elephant(Animal):
    def _feed_message(self) -> str:
        return f"{self.name} munches on leaves and hay."

Step 5: Write the Caller That Never Branches on Type

def feed_all(animals: list) -> list:
    return [a.feed() for a in animals]

Step 6: Write the Required Test

def test_feed_differentiates_between_types():
    lion, elephant = Lion("Leo"), Elephant("Ella")
    assert lion.feed() != elephant.feed()

Why walking through this matters: the technical content here is nothing new — it's the same hierarchy this whole page has been teaching. What's new is the process: restate requirements, recognize a known shape, build the interface before the implementation, and finish with a test that directly verifies the requirement that was asked for. That process transfers to prompts this page never covered.

Wrapping Up: What This Project Taught You

🏁 What You've Built and Understood

You've just walked through a complete, working demonstration of Inheritance — from the abstract "is-a" relationship, through a fully interactive simulator, to a line-by-line reading of the real Python source that powers it. This wasn't a passive read; every button you clicked ran real overridden code.

You now have a concrete, inspectable answer to what could otherwise stay abstract: what does it actually mean for a subclass to override a method? It means Python looks up the method starting from the object's real class, finds the subclass's version first, and runs that — optionally after that version calls super() to reuse the parent's logic. You watched that exact mechanism fire every time Leo roared instead of trumpeting.

You also saw why this "toy" pattern is not something you outgrow — it's a compressed, fully-inspectable version of the same dispatch mechanism that lets production AI agent frameworks, ORM libraries, and plugin systems route calls correctly without a single hardcoded type check.

1 full class hierarchy read line by line 2 parallel implementations compared (JS & Python) 6 AI-era parallels connected 1 live, runnable Pyodide demo

Key Takeaways

  • One shared interface, many overriding implementations — Animal defines the contract; Lion, Elephant, and Parrot each fulfill it differently.
  • super() lets you extend instead of duplicate — every subclass's feed() reused the parent's hunger math instead of rewriting it.
  • Dispatch happens on the real class, not the declared type — the loop never checked species, yet always ran the correct override.
  • The same shape works in more than one language — the JavaScript simulator and the Python source are the same idea in two syntaxes.
  • This pattern scales unmodified into AI systems — agent base classes, tool interfaces, and dataset abstractions all use this exact structure.

Your Next Steps

This is one of ten planned inheritance projects on Poetic Bytes. Head back to the guide to see the rest of the roadmap, revisit the core lesson, or explore the other pillars of OOP.

✨ "The best way to understand a class hierarchy is to poke it until it talks back. Every roar, trumpet, and squawk you triggered above was the same mechanism that will one day route a request to the correct AI agent or the correct payment handler. Small examples, run for real, teach faster than big examples read passively."

— Glenn Junsay Pansensoy, Poetic Bytes

Back to Inheritance Projects Main Hub Back to Simulator

Safely Refactoring This Hierarchy with an AI Assistant

Prompting for Structural Changes Without Breaking the Contract

Given how naturally this kind of hierarchy comes up in AI-assisted coding sessions, it's worth naming a few habits that keep an AI assistant's refactor honest rather than accidentally destructive.

Do: paste the whole hierarchy, not one class. An assistant asked to "add a Giraffe" without seeing Animal, Lion, Elephant, and Parrot together has no way to match the existing pattern — it may invent a different shape entirely (a different constructor style, different method names) rather than extending the one you already have.

Do: ask it to run (or describe) the existing tests after any change. The pytest suite from the Testing section exists precisely so that "does this refactor still work?" has an objective, checkable answer — for a human or an AI assistant equally.

Don't: accept a rewrite that silently drops the NotImplementedError contract enforcement. It's a common, well-intentioned "simplification" an assistant might suggest — replacing it with a default sound "to make the base class more usable directly." That change removes the exact safety net Mistake 7's debugging story depended on; review structural suggestions like this with the same scrutiny you'd give a human teammate's pull request.

Don't: let a suggested refactor quietly break the Liskov Substitution Principle. If an AI-suggested change to one subclass's constructor adds a new required parameter the others don't have, that's Mistake 6 reappearing — worth catching in review, not after deployment.

Structuring This as a Real Multi-File Project

From One Script to a Small Package

Everything on this page lives in a single Python source block for teaching clarity, but a real codebase would split this hierarchy across files the moment more than one person needs to work on it. Here's a reasonable structure for exactly that:

zoo_project/
├── zoo/
│   ├── __init__.py
│   ├── animals.py
│   ├── factory.py
│   └── zookeeper.py
├── tests/
│   └── test_zoo.py
└── README.md

Splitting animals.py from zookeeper.py matters for a concrete reason: the zookeeper-facing code (the "caller" in every example on this page) should only ever need to import the Animal base class's public interface and the factory — never the individual subclasses directly. That's the multi-file version of the same discipline the loop demonstrates in a single file: depend on the shared interface, not on any specific concrete type.

from .animals import Animal, Lion, Elephant, Parrot
from .animals import Animal

class Zookeeper:
    def rounds(self, zoo: list[Animal]) -> None:
        for animal in zoo:
            print(animal.makeSound())
            print(animal.feed())

The tell that your structure is right: if zookeeper.py never has to change or add an import when a new animal subclass is added to animals.py, your file boundaries are correctly following the same "depend on the interface, not the implementation" idea this entire page has been building toward.

Common Code Review Comments You Might Receive

If you submit a hierarchy like this one for review, here's what an experienced reviewer is likely to flag — and what a good response looks like.

Reviewer CommentGood Response
"Should this really be a subclass, or just a different config value?"Revisit the Decision Tree section's question 1 — if the "difference" is just data, not behavior, a plain Animal("Leo", diet="carnivore") instance may be simpler than a whole new class.
"Can you add a test for the empty-list case?"Add test_feed_all_handles_empty_zoo asserting feed_all([]) == [] — a reviewer flagging this is checking that your polymorphic loop doesn't assume at least one element exists.
"Why does this subclass need its own __init__ at all?"A fair challenge — if a subclass's constructor does nothing but call super().__init__() with no new parameters, it can usually be deleted entirely and inherit the parent's constructor unchanged.
"This method name doesn't match the parent's — is this actually an override?"Exactly Mistake 2 from the Debugging Guide. Double-check the exact spelling and signature against the base class.
"What happens if two subclasses need the same new behavior — do we duplicate it?"A strong signal to introduce a mixin or push the shared logic up into the base class, rather than copy-pasting it into both subclasses.
"Have you run mypy on this?"Run it, and fix anything it flags — see the Type Hints section for exactly the kind of bug this catches before runtime.

Common Variations of This Exercise Seen in the Wild

This exact "one base class, several overriding subclasses, one type-blind caller" shape gets taught and re-implemented constantly, under different names. Recognizing the family resemblance is what turns a new codebase from intimidating into familiar within minutes.

VariationBase ClassOverridden Method(s)Where You'll See It
Shape hierarchyShapearea(), perimeter()The Inheritance guide's real-world example; nearly every geometry-based CS assignment.
Employee hierarchyEmployeepay(), summary()Payroll systems, HR software, the super() section of the Inheritance guide.
Notification hierarchyNotifiersend(), channel_name()The Case Study section above; nearly every real alerting or messaging system.
Payment hierarchyPaymentMethodprocess()E-commerce checkout flows; one of the ten planned Inheritance projects (Payment Methods).
UI widget hierarchyFormField / Componentrender()The UI Widget Hierarchy example above; virtually every UI framework.
Vehicle hierarchyVehiclehonk(), type-specific behaviorThe Inheritance guide's Types of Inheritance section; the Vehicle Garage planned project.
Game character hierarchyCharacterattack(), defend()Game engines; the RPG Character Builder planned project.

The skill this table is really teaching: once you've internalized one of these rows deeply — as this page has done with the zoo — reading any of the others becomes pattern-matching, not learning from scratch. That transfer is the actual payoff of building a project like this one all the way through.

Final Checklist: Have You Actually Got This?

Self-Assessment

Work through this list honestly. Every "no" points you back to a specific section above worth revisiting before you consider this project done.

  • I can explain, without looking, why Animal.makeSound() raises NotImplementedError instead of returning a default value.
  • I can predict what super().feed() does inside any of the three subclasses without running the code.
  • I completed at least three of the twelve exercises myself, not just read them.
  • I could explain the difference between the Template Method and Strategy patterns to another person in under a minute.
  • I understand why the for animal in zoo loop never needs an if statement checking species.
  • I could write a basic pytest test for a new subclass I add, without copying the Testing section verbatim.
  • I can name at least two real frameworks (PyTorch, Django, Gymnasium, etc.) that use this exact shape.
  • I know what to check first — using the Debugging Guide's numbered mistakes — if a new subclass I write breaks unexpectedly.

If most of these are true: you're genuinely ready for the next project in the Inheritance series, or to move on to Polymorphism, where this project's entire "one interface, many forms" theme gets its formal name.

How the Roster Cards Actually Get Drawn

One piece of this project hasn't been explained yet: how an Animal object turns into the visual card you clicked in the Live Simulator. It's worth walking through, because it's a second, smaller demonstration of polymorphism doing real work — this time in the rendering layer instead of the data layer.

function buildCard(animal) {
  const card = document.createElement('div');
  card.className = 'animal-card ' + animal.typeName.toLowerCase();

  const head = document.createElement('div');
  head.innerHTML = '<span class="ac-emoji">' + animal.emoji + '</span>' +
    '<div><div class="ac-name">' + animal.name + '</div>' +
    '<div class="ac-type">' + animal.typeName + ' extends Animal</div></div>';
  card.appendChild(head);

  const bar = document.createElement('div');
  const fill = document.createElement('div');
  fill.style.width = animal.hunger + '%';
  fill.style.background = animal.hunger > 70 ? 'var(--red)' : animal.barColor;
  bar.appendChild(fill);
  card.appendChild(bar);

  return card;
}
📝 Why This Function Is Polymorphic Too
  1. animal.typeName, animal.emoji, animal.barColor: Every one of these is a JavaScript getter — a property that looks like a plain attribute from the outside but actually runs a small overridden method under the hood. Lion, Elephant, and Parrot each define their own version, exactly like feed() and makeSound().
  2. buildCard() never checks the animal's type: Notice there is no if (animal instanceof Lion) anywhere in this function, even though the card visually looks different for every species — amber for Lion, blue for Elephant, teal for Parrot. The visual difference comes entirely from reading overridden getters, the same "one caller, many overriding implementations" shape as everything else on this page.
  3. The hunger bar's color logic: animal.hunger > 70 ? 'var(--red)' : animal.barColor layers one small conditional (an urgent-hunger warning) on top of the per-species color, which itself came from an overridden getter — a small example of combining a shared rule with subclass-specific data.
  4. Why this matters beyond this project: real UI frameworks constantly do exactly this — render a heterogeneous list of objects by calling the same handful of methods/getters on each one, never branching on concrete type. This function is a miniature, inspectable version of how a React or Vue component list renders a mix of different item types.
Advertisement

Subscribe to Poetic Bytes

Get the next nine inheritance projects first.

In-depth Python tutorials, live project drops like this one, and AI insights — delivered straight to your inbox. No spam, ever.

New project builds Weekly Python tutorials AI & ML deep-dives Free, cancel anytime

🔒 Join 8,400+ developers. Your email is never shared or sold.