Encapsulation and Inheritance

advanced40 min

Learning objectives

  • Explain encapsulation and inheritance
  • Apply them appropriately within Python programs

Learn

AQA 4.1.2.3 — Encapsulation and inheritance

Retrieval: the previous lesson built Book out fully and proved each object protects its own state independently. This lesson covers two further OOP tools working with that same Book class: encapsulation (protecting a class's internal state from misuse, not just keeping it separate) and inheritance (building a new, related class without duplicating Book's code).

Key vocabulary

  • Encapsulation — restricting direct access to an object's internal state, exposing only deliberate, controlled ways to change it.
  • Underscore convention — Python's way of marking an attribute as "internal, don't access directly" (a single leading underscore); a convention, not an enforced restriction.
  • Inheritance — a class (the subclass) building on an existing class (the superclass), automatically gaining its attributes and methods.
  • Method overriding — a subclass providing its own version of a method the superclass already defines.
  • super() — lets a subclass call its superclass's own version of a method, typically to extend rather than completely replace it.

Understand — encapsulation

Book's is_available attribute is currently open to anyone: nothing stops hobbit.is_available = "yes" (a string, not the boolean the rest of the class expects) or hobbit.is_available = True while the book is genuinely still out on loan. Encapsulation means giving external code only the specific, controlled ways to change state that the class itself has decided are valid (like borrow() and return_book()), rather than allowing direct, unrestricted attribute assignment from anywhere.

See it — encapsulating a borrow counter

class Book:
    def __init__(self, title, author, isbn):
        self.title = title
        self.author = author
        self.isbn = isbn
        self.is_available = True
        self._times_borrowed = 0    # leading underscore: internal, don't set directly

    def borrow(self):
        if not self.is_available:
            return f"{self.title} is already on loan."
        self.is_available = False
        self._times_borrowed += 1   # only this method is allowed to change it
        return f"You have borrowed {self.title}."

    def return_book(self):
        self.is_available = True
        return f"Thank you for returning {self.title}."

    def times_borrowed(self):
        return self._times_borrowed

Common mistake — encapsulation

Assuming the leading underscore genuinely prevents access — it doesn't. hobbit._times_borrowed = 1000 is still syntactically legal Python and will run without error. The underscore is a convention telling other programmers "this is internal, please don't touch it directly" — real protection comes from disciplined use, going through borrow() rather than the attribute itself, not from the language enforcing it the way some other languages do.

Understand — inheritance

An EBook shares almost everything with Book — a title, an author, an ISBN — but differs in a genuine, specific way: an ebook can be "borrowed" by multiple readers at once, so it has no real concept of is_available blocking anyone. Inheritance lets EBook reuse Book's existing code entirely, and override only the one method that genuinely needs to behave differently, instead of copying and pasting the whole class and risking the two definitions drifting apart over time.

See it — EBook, inheriting and overriding

class EBook(Book):
    def __init__(self, title, author, isbn, file_size_mb):
        super().__init__(title, author, isbn)   # reuse Book's own constructor
        self.file_size_mb = file_size_mb

    def borrow(self):                            # override - completely different behaviour
        self._times_borrowed += 1
        return f"{self.title} (ebook) is now available on your device."

quiet_life = EBook("Quiet Life", "A. Author", "978-0000000001", 2.4)
print(quiet_life.borrow())          # uses EBook's own overridden version
print(quiet_life.author)            # "A. Author" - inherited from Book, never redefined in EBook

super().__init__(...) calls Book's own constructor to set title, author, isbn and is_available exactly as before, so EBook doesn't need to repeat that logic — it only adds what's genuinely new (file_size_mb), and only overrides borrow(), the one method that genuinely needs different behaviour.

Trace it — which method actually runs?

library_items = [Book("The Hobbit", "Tolkien", "1"), EBook("Quiet Life", "Author", "2", 2.4)]
for item in library_items:
    print(item.borrow())

For the Book object, item.borrow() runs Book's own borrow() (no override exists on Book itself). For the EBook object, Python looks for borrow() on EBook first — finds the overridden version there — and runs that one instead, never even checking Book's version. Each object runs whichever version of the method its own class (or the nearest one up the inheritance chain) actually defines.

Debug it — diagnose, explain, fix, test, justify (inheritance error)

class EBook(Book):
    def __init__(self, title, file_size_mb):
        self.file_size_mb = file_size_mb

quiet_life = EBook("Quiet Life", 2.4)
print(quiet_life.title)

This crashes with AttributeError: 'EBook' object has no attribute 'title'.

  1. Diagnose: does this EBook.__init__ ever actually call Book's own constructor?
  2. Explain: why does simply inheriting from Book (via class EBook(Book):) not automatically run Book's __init__ too?
  3. Fix: correct EBook's constructor so title, author, isbn and is_available are all genuinely set.
  4. Test: confirm quiet_life.title now works, alongside quiet_life.file_size_mb.
  5. Justify: explain why inheritance gives EBook access to Book's methods automatically, but overriding __init__ specifically means Book's own __init__ needs to be called deliberately.

(EBook's own init completely replaces Book's - defining init in a subclass overrides the superclass's version exactly like any other method, so Book's constructor logic never runs unless explicitly called. The fix is to call super().init(title, author, isbn) inside EBook's init before or alongside setting file_size_mb.)

Debug it — diagnose, explain, fix, test, justify (incorrect super() use)

A different attempt at EBook, this time trying to extend borrow() rather than fully replace it — logging every ebook loan while still using Book's own logic:

class EBook(Book):
    def __init__(self, title, author, isbn, file_size_mb):
        super().__init__(title, author, isbn)
        self.file_size_mb = file_size_mb

    def borrow(self):
        print(f"Logging ebook loan: {self.title}")
        return super().borrow(self)

Calling .borrow() on an EBook crashes with TypeError: borrow() takes 1 positional argument but 2 were given.

  1. Diagnose: how many arguments is super().borrow(self) actually passing to Book's borrow() method?
  2. Explain: super() already knows which object it's operating on — so why does adding self explicitly cause one argument too many?
  3. Fix: correct the super().borrow() call.
  4. Test: confirm borrowing an EBook now prints the log line and correctly runs Book's own availability-checking logic.
  5. Justify: explain the genuine difference between calling super().borrow(self) and calling super().borrow().

(super().borrow(self) passes self as an EXTRA argument on top of the one super() already supplies automatically - super() has already bound itself to the current object, exactly like an ordinary method call does, so writing self again duplicates it. The fix is super().borrow(), with no explicit self. super() automatically supplies the current object as the implicit first argument, precisely mirroring how self is supplied automatically for an ordinary method call.)

Common mistake — overriding

Overriding a method and accidentally losing behaviour the class still needed, by replacing it completely (as the first debug task's broken constructor did) rather than genuinely deciding whether to extend the superclass's version (via super()) or fully replace it (a deliberate, valid choice too — EBook's overridden borrow() genuinely doesn't need Book's original logic at all, since ebooks aren't limited to one borrower). Both are legitimate; the mistake is doing one when you meant the other.

Why this matters for the NEA

Reducing duplication between closely related classes (via inheritance) and protecting a class's own internal rules from being bypassed elsewhere in a large program (via encapsulation) are both exactly the kind of design maturity that matters as an NEA project's own codebase grows past a handful of files — not requirements to satisfy for their own sake, but genuine responses to problems a growing codebase actually has.

Apply it

Design a third class, AudioBook(Book), with its own additional attribute narrator, and its own overridden describe() method that includes the narrator's name — using super().__init__() correctly in its constructor.

Challenge

Give AudioBook an overridden borrow() that extends (not replaces) Book's own borrow() using super().borrow() — after the normal borrowing logic succeeds, it should also print "Estimated listening time: ..." using a duration_minutes attribute you add.

Looking ahead: the next lesson uses Book, EBook and AudioBook together — the same method name, describe() or borrow(), behaving differently depending on which object it's called on, which is exactly what polymorphism means.

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