Polymorphism and Complete OOP Application

advanced40 min

Learning objectives

  • Explain polymorphism conceptually
  • Develop a complete object-oriented application using multiple classes

Learn

AQA 4.1.2.3 — Polymorphism, composition, and a complete application

Retrieval: the previous lesson gave Book, EBook and AudioBook each their own version of borrow(). This lesson names what you already built — polymorphism — and adds the final piece needed for a complete system: a Library class that contains books, rather than being one.

Key vocabulary

  • Polymorphism — the same method call behaving differently depending on which object's class actually defines it.
  • Composition — building a class out of other objects it contains and manages, rather than by inheriting from them.
  • "is-a" vs "has-a" — inheritance models an is-a relationship (an EBook is a Book); composition models a has-a relationship (a Library has a collection of Books — a Library is not itself a kind of book at all).

Understand — polymorphism is what you already built

items = [
    Book("The Hobbit", "Tolkien", "1"),
    EBook("Quiet Life", "Author", "2", 2.4),
    AudioBook("Circe", "Miller", "3", "Perdita Weeks"),
]

for item in items:
    print(item.describe())

The loop calls .describe() identically on every item, never checking what type each one actually is — yet each one runs its own version, because Python looks up describe starting from the object's own class first, exactly as traced in the previous lesson. This is polymorphism: one method call, several genuinely different behaviours, selected automatically by which object it's called on.

Understand — composition, a genuinely different relationship

Inheritance answers "is this new class a specific kind of an existing one?" (EBook is a Book, just one that borrows differently). A Library is not a kind of book at all — it has books. Modelling that with inheritance (class Library(Book):) would be a genuine design mistake: a library doesn't have a title or an author of its own, and "borrowing a library" makes no sense. Composition — the Library simply containing a list of Book objects as one of its own attributes — is the correct relationship here.

See it — the Library class

class Library:
    def __init__(self, name):
        self.name = name
        self.books = []          # composition: Library HAS a list of Book objects

    def add_book(self, book):
        self.books.append(book)

    def find_available(self):
        return [book for book in self.books if getattr(book, "is_available", True)]

    def describe_all(self):
        for book in self.books:
            print(book.describe())

describe_all doesn't care whether each item in self.books is a Book, an EBook or an AudioBook — it just calls .describe() on each, and polymorphism handles the rest.

Debug it — diagnose, explain, fix, test, justify (object-state bug)

class Library:
    def __init__(self, name, books=[]):
        self.name = name
        self.books = books

    def add_book(self, book):
        self.books.append(book)

central = Library("Central")
central.add_book(Book("The Hobbit", "Tolkien", "1"))

branch = Library("Branch")
print(len(branch.books))

This prints 1, not 0 — the brand-new branch library somehow already contains central's book, despite never having add_book called on it at all.

  1. Diagnose: does every call to Library(...) genuinely create its own separate, empty list, or could they be sharing one?
  2. Explain: a default parameter value like books=[] is created once, when the function is defined — not fresh, every single time the function is called. What does that mean for every Library object that doesn't explicitly supply its own books argument?
  3. Fix: change the constructor so every Library genuinely gets its own independent, empty list.
  4. Test: confirm a fresh Library now starts with 0 books, regardless of how many other Library objects already exist.
  5. Justify: explain why this bug is specifically about mutable default arguments (a list), and wouldn't happen with an immutable default like books=None used correctly.

(Every Library object that doesn't pass its own books argument is silently sharing the exact same list object, created once when init was defined - a well-known Python trap. The fix is to use a sentinel: def init(self, name, books=None): self.books = books if books is not None else []; this creates a genuinely new empty list on every call that needs one. Immutable defaults (like None) don't cause this because they can never be appended-to or mutated in place - a new value has to be explicitly reassigned, which never leaks between objects.)

Debug it — diagnose, explain, fix, test, justify (composition/interaction error)

A colleague later adds a Magazine class to the same system — a genuinely different kind of item, built independently rather than inheriting from Book at all, with its own describe() method but no is_available attribute:

class Magazine:
    def __init__(self, title, issue_number):
        self.title = title
        self.issue_number = issue_number

    def describe(self):
        return f"{self.title}, issue {self.issue_number}"

class Library:
    def __init__(self, name):
        self.name = name
        self.books = []

    def add_book(self, book):
        self.books.append(book)

    def find_available(self):
        return [book for book in self.books if book.is_available]

central = Library("Central")
central.add_book(Book("The Hobbit", "Tolkien", "1"))
central.add_book(Magazine("Wired", 412))
print(central.find_available())

This crashes with AttributeError: 'Magazine' object has no attribute 'is_available'.

  1. Diagnose: find_available assumes every item in self.books has an is_available attribute it can safely read directly — is that assumption actually true for every possible item the Library might contain?
  2. Explain: why does a method written correctly while only Book-family objects existed break the moment the collection also contains something built completely independently?
  3. Fix: rewrite find_available so it works correctly regardless of whether an item tracks is_available at all (getattr(book, "is_available", True), treating anything without the attribute as always available, is one valid approach).
  4. Test: confirm find_available now runs without crashing on a mixed collection, and returns sensible results for every kind of item.
  5. Justify: explain why this is a genuine composition/interaction bug, not an inheritance bug like the ones in the previous lesson.

(find_available assumes a uniform interface across every object Library holds, but composition specifically allows genuinely unrelated classes to be mixed together in the same collection - Magazine was never designed to satisfy Book's assumptions, and nothing about composition requires that it should be. The fix is to handle the missing attribute gracefully, e.g. via getattr(book, "is_available", True). This is a composition/interaction bug, not an inheritance bug, because Magazine isn't a broken subclass of Book at all - it's an entirely separate class that Library's own method wrongly assumed every contained item would resemble.)

Common mistake

Reaching for inheritance when composition is the correct relationship (or vice versa) — asking "is a Library a kind of Book?" (no — composition) versus "is an EBook a kind of Book?" (yes — inheritance) is exactly the right test. Using inheritance where a class merely contains or uses another, unrelated object leads to exactly the nonsensical "borrowing a library" problem described above.

Evaluate

For the library system built across this sequence, evaluate whether an object-oriented approach was genuinely justified compared with the procedural version from earlier in this sequence — specifically: which of OOP's benefits (bundled data+behaviour, encapsulation, inheritance avoiding duplication, polymorphism handling mixed types uniformly) were actually used, and would the procedural version have needed to solve the same problems some other way?

Justify

A colleague suggests Library should inherit from Book instead of containing a list of them, arguing "it would save writing a separate class." Justify why this is a poor design decision, referring to the is-a/has-a distinction explicitly.

Apply it — develop the complete application

Bring everything from this sequence together: create a Library containing a mix of Book, EBook and AudioBook objects, plus at least one Member (from the previous lesson's Design-it task) who borrows from it. Your program should demonstrate: object creation, at least one encapsulated attribute, inheritance with a genuine override, and a polymorphic loop that treats every kind of book uniformly.

Challenge

Extend the complete application so Library has a method most_borrowed() that returns whichever Book (or subclass) object has the highest times_borrowed() count across the whole collection.

Why this matters for the NEA

A working system combining several interacting classes — some related by inheritance, some by composition, with encapsulated internal state and methods that behave correctly across a mix of object types — is precisely the shape of a genuine NEA project's own architecture, not a simplified classroom version of it. The specific library system here is illustrative; the design reasoning (is-a vs has-a, when to override, when to encapsulate) is the transferable skill.

Looking ahead: Sequence 12 (Advanced Data Structures) returns to graphs, trees and hash tables — data structures you'll now be able to recognise are themselves often naturally modelled as classes, exactly like the ones built across this sequence.

Practise

Apply what you've just learned in the Coding Lab.

Open Coding Lab

Test yourself

Check your understanding with exam-style questions.

Go to Exam Practice
Log in to track this lesson on your progress dashboard.
Log in