Encapsulation and Inheritance
advanced40 minLearning 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'.
- Diagnose: does this
EBook.__init__ever actually callBook's own constructor? - Explain: why does simply inheriting from
Book(viaclass EBook(Book):) not automatically runBook's__init__too? - Fix: correct
EBook's constructor sotitle,author,isbnandis_availableare all genuinely set. - Test: confirm
quiet_life.titlenow works, alongsidequiet_life.file_size_mb. - Justify: explain why inheritance gives
EBookaccess toBook's methods automatically, but overriding__init__specifically meansBook'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.
- Diagnose: how many arguments is
super().borrow(self)actually passing toBook'sborrow()method? - Explain:
super()already knows which object it's operating on — so why does addingselfexplicitly cause one argument too many? - Fix: correct the
super().borrow()call. - Test: confirm borrowing an
EBooknow prints the log line and correctly runsBook's own availability-checking logic. - Justify: explain the genuine difference between calling
super().borrow(self)and callingsuper().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.