Python inheritance exercises — animals, bank accounts and library loans, with solutions, a quiz and a cheat sheet (and the extra s that sank a mock exam)
In an FP2 mock exam I spent ten minutes staring at a TypeError: Can't instantiate abstract class Student without understanding it: Student had its loan-days method, I could see it. It had it, yes… spelled loans_days, with an extra s. To Python it was a brand-new method and the abstract one was still unimplemented. That slip, the forgotten super().__init__ and the missing return when extending a method are the three most common stumbles with inheritance, and they’ll all show up in these exercises.
You’ve already seen the inheritance theory and the practice with three hierarchies. Now it’s your turn.
Table of Contents
—
How to use these exercises
- Read the statement and the expected output. Your program must give exactly that output with that data.
- Try it without looking at the hints. If you’re stuck for more than 15 minutes, open the hint.
- Before the solutions, do the “What’s wrong here?” section: three snippets with typical bugs to predict.
- Compare with the commented solution: what matters isn’t that yours looks the same, but understanding why it’s built that way.
- Finish with the exam-style quiz and keep the cheat sheet.
All three exercises use from abc import ABC, abstractmethod. Every output you’ll see is the real output of the solutions.
—
Basic level
Exercise 1: animal system with specific behaviour
Design a hierarchy for the animals of a vet clinic. The base class Animal is abstract:
- Private attributes
nameandage, with read-only properties. - Abstract methods
sound()andmove(). - A normal method
describe()that returns three lines: the introduction, the sound and the movement (it uses the abstract ones). __str__with the formatName (own detail, N years)(“1 year” in the singular) and__repr__with the formatClass('Name', age).
The subclasses add their own detail:
Dog(name, age, breed): “Woof woof!”, “runs wagging its tail”; its detail is the breed.Cat(name, age, indoor): “Meow!”, “walks stealthily”; its detail is “indoor” or “outdoor”.Bird(name, age, can_fly): “Tweet tweet!”; it moves “flies” or “hops along”, and its detail is “flies” or “flightless”.
With Dog('Rex', 3, 'Labrador'), Cat('Mia', 5, True), Bird('Kiwi', 1, False) and Bird('Piolín', 2, True), print describe() for each one, the whole list and, finally, try to create an Animal('Nadie', 0) catching the error. Expected output:
Rex (Labrador, 3 years)
Sound: Woof woof!
Movement: runs wagging its tail
Mia (indoor, 5 years)
Sound: Meow!
Movement: walks stealthily
Kiwi (flightless, 1 year)
Sound: Tweet tweet!
Movement: hops along
Piolín (flies, 2 years)
Sound: Tweet tweet!
Movement: flies
[Dog('Rex', 3), Cat('Mia', 5), Bird('Kiwi', 1), Bird('Piolín', 2)]
TypeError: Can't instantiate abstract class Animal without an implementation for abstract methods 'move', 'sound'
Each animal’s own detail appears in __str__, which lives in the parent. How does the parent know the dog’s breed? With an extra() method the parent calls and each child overrides (a hook). And since name and age are private to Animal, the children don’t touch them: the parent’s __init__ does it all, and they call it with super().__init__(name, age).
—
Intermediate level and final challenge
Exercise 2: bank account hierarchy
Design an account hierarchy. The base Account is abstract:
- Private attributes: holder, balance and a list of movements (strings). When opened it records “Opening: 500.00 €”.
- Abstract methods:
kind(),annual_interest()andcan_withdraw(amount)(returnsTrueorFalse). - Normal methods:
deposit(amount),withdraw(amount),apply_interest()andstatement(). withdrawchecks the amount, askscan_withdrawand, if the answer isFalse, raises a customAccountErrorexception. After withdrawing it calls a hook_after_withdrawal(amount)that does nothing in the parent.
Subclasses:
CurrentAccount(holder, balance, overdraft_max): no interest; it can go negative down to-overdraft_max; if the balance is negative after a withdrawal, it charges a 15 € fee.SavingsAccount(holder, balance, interest): it only withdraws if the balance doesn’t go negative and there have been at least 3 deposits since the last withdrawal (the first withdrawal is allowed).InvestmentAccount(holder, balance, profile): 4%, 7% or 12% interest depending on the profile (cautious,moderate,aggressive); it only withdraws if, counting a 2% penalty on the amount withdrawn, at least 1000 € remain; then it charges that penalty.
Data: a current account for Laura with 500 € and 300 of overdraft (deposits 200 and withdraws 900), a savings account for Pablo with 2000 € at 3.5% (applies interest and withdraws 500) and an investment account for Elena with 5000 € on the aggressive profile (applies interest and withdraws 2000). Then try to withdraw 200 from the current account, 100 from the savings account and 3000 from the investment account, catching the errors, and print the three statements. Expected output:
Error: Current account does not allow withdrawing 200.00 € Error: Savings account does not allow withdrawing 100.00 € Error: Investment account (aggressive) does not allow withdrawing 3000.00 € Current account for Laura Opening: 500.00 € Deposit: +200.00 € | balance 700.00 € Withdrawal: -900.00 € | balance -200.00 € Overdraft fee: -15.00 € | balance -215.00 € Savings account for Pablo Opening: 2000.00 € Interest 3.5%: +70.00 € | balance 2070.00 € Withdrawal: -500.00 € | balance 1570.00 € Investment account (aggressive) for Elena Opening: 5000.00 € Interest 12.0%: +600.00 € | balance 5600.00 € Withdrawal: -2000.00 € | balance 3600.00 € Penalty 2 %: -40.00 € | balance 3560.00 €
withdraw goes once in Account: it’s the template method. Each child decides with can_withdraw whether it’s allowed and does whatever comes next in _after_withdrawal (the fee, resetting the counter, the penalty). So children can move the balance without touching the parent’s private attribute, give Account a method _move(amount, concept) that changes the balance and records the movement. The savings account needs to count deposits: extend deposit with super().deposit(amount) and add one to the counter.
Exercise 3 (challenge): library loans and fines
The faculty library is back. Create an abstract class Member with a borrowed dictionary (title → day it was borrowed) and:
- Abstract:
limit()(how many items at once) andloan_days(). - Hooks with a default value:
fine_per_day()(0.20 € per day late) andcan_take(item)(True). fine(days_out): the days past the loan period times the daily fine (never negative).__str__with the formatLaura (Student, 0/3): name, class and current loans over the limit.
Three members: Student (3 items, 15 days), Lecturer (10 items, 60 days, no fines) and Visitor (1 item, 7 days, 0.50 €/day and can’t take magazines). A class Item(title, kind='book') knows who it’s lent to, and a Library has lend(member, item, day) (raises LoanError if the item is already lent, if the member is at their limit or if they can’t take it) and return_item(member, item, day), which returns a text with the days and the fine and adds the fine to the amount collected.
With the loans and returns of the statement (you’ll see them in the solution), the expected output is:
Day 1: loan of ‘Calculus’ to Laura Day 1: loan of ‘Python for Everybody’ to Dr Ramos Day 2: Rejected: Pablo cannot take a magazine Day 2: loan of ‘Computer Networks’ to Pablo Day 3: Rejected: ‘Python for Everybody’ is already with Dr Ramos Day 4: Rejected: Pablo already has 1 of 1 allowed loans Day 13: Pablo returns ‘Computer Networks’: 11 days, fine 2.00 € Day 22: Laura returns ‘Calculus’: 21 days, fine 1.20 € Day 75: Dr Ramos returns ‘Python for Everybody’: 74 days, fine 0.00 € Members: Laura (Student, 0/3), Dr Ramos (Lecturer, 0/10), Pablo (Visitor, 0/1) Collected in fines: 3.20 €
lend and return_item mustn’t have a single if on the kind of member: they ask member.limit(), member.can_take(…) and member.fine(…), and each class answers. Notice the order of the checks in lend: on day 4, Pablo asks for a free book but already has one, so the error is the limit one. For the class name in __str__, type(self).__name__.
—
What’s wrong here?
Three snippets with one bug each. Before reading the explanation, predict what they print or what error they raise.
Snippet 1: the summer loan period
class Member:
def loan_days(self):
return 15
def card(self):
return f'Loan period: {self.loan_days()} days'
class Lecturer(Member):
def loan_days(self, in_summer): # override with a DIFFERENT signature
return 90 if in_summer else 60
print(Lecturer().card())
What happens when you run it?
This:
Traceback (most recent call last):
File "signature.py", line 14, in <module>
print(Lecturer().card())
~~~~~~~~~~~~~~~^^
File "signature.py", line 6, in card
return f'Loan period: {self.loan_days()} days'
~~~~~~~~~~~~~~^^
TypeError: Lecturer.loan_days() missing 1 required positional argument: 'in_summer'
Lecturer overrode loan_days with a different signature (it asks for in_summer), but card(), which lives in the parent, still calls it with no arguments. Since self is a Lecturer, Python runs the child’s version and the argument is missing. Children must accept the same as the parent. The fix: a parameter with a default value in both classes.
class Member:
def loan_days(self, in_summer=False): # the parent fixes the signature
return 15
def card(self, in_summer=False):
return f'Loan period: {self.loan_days(in_summer)} days'
class Lecturer(Member):
def loan_days(self, in_summer=False): # same signature as the parent
return 90 if in_summer else 60
print(Lecturer().card())
print(Lecturer().card(in_summer=True))
Output:
Loan period: 60 days Loan period: 90 days
Snippet 2: the vanishing description
class Member:
def __init__(self, name):
self.name = name
def description(self):
return f'Member {self.name}'
class Lecturer(Member):
def description(self):
text = super().description() + ' (staff)' # builds the text…
# …and forgets the return
print(Lecturer('Ramos').description())
What does it print?
None
None. The override builds the text in the variable text and ends without return, so it returns None. No error at all: it’s the quietest bug of the topic. Fixed:
class Member:
def __init__(self, name):
self.name = name
def description(self):
return f'Member {self.name}'
class Lecturer(Member):
def description(self):
return super().description() + ' (staff)'
print(Lecturer('Ramos').description())
Output:
Member Ramos (staff)
Snippet 3: the extra s
from abc import ABC, abstractmethod
class Member(ABC):
@abstractmethod
def loan_days(self):
pass
class Student(Member):
def loans_days(self): # one s too many
return 15
laura = Student()
What happens?
Traceback (most recent call last):
File "typo.py", line 15, in <module>
laura = Student()
TypeError: Can't instantiate abstract class Student without an implementation for abstract method 'loan_days'
loans_days doesn’t override loan_days: it’s another method. The abstract one is still unimplemented and Student can’t be created. The good thing about the abstract class is that the error appears here, when creating the object; without ABC, laura.loan_days() would have returned the None from the parent’s pass and the bug would have shown up much later. With the name spelled right:
from abc import ABC, abstractmethod
class Member(ABC):
@abstractmethod
def loan_days(self):
pass
class Student(Member):
def loan_days(self):
return 15
laura = Student()
print(laura.loan_days())
Output:
15
—
Commented solutions
Solution to exercise 1
from abc import ABC, abstractmethod
class Animal(ABC):
def __init__(self, name, age):
self.__name = name # private: children use the properties
self.__age = age
@property
def name(self):
return self.__name
@property
def age(self):
return self.__age
@abstractmethod
def sound(self):
pass
@abstractmethod
def move(self):
pass
def extra(self): # hook: each child gives its own detail
return ''
def describe(self): # template: uses the abstract ones
return (f'{self}\n'
f' Sound: {self.sound()}\n'
f' Movement: {self.move()}')
def __str__(self):
unit = 'year' if self.__age == 1 else 'years'
return f'{self.__name} ({self.extra()}, {self.__age} {unit})'
def __repr__(self):
return f'{type(self).__name__}({self.__name!r}, {self.__age})'
class Dog(Animal):
def __init__(self, name, age, breed):
super().__init__(name, age)
self.breed = breed
def sound(self):
return 'Woof woof!'
def move(self):
return 'runs wagging its tail'
def extra(self):
return self.breed
class Cat(Animal):
def __init__(self, name, age, indoor):
super().__init__(name, age)
self.indoor = indoor
def sound(self):
return 'Meow!'
def move(self):
return 'walks stealthily'
def extra(self):
return 'indoor' if self.indoor else 'outdoor'
class Bird(Animal):
def __init__(self, name, age, can_fly):
super().__init__(name, age)
self.can_fly = can_fly
def sound(self):
return 'Tweet tweet!'
def move(self):
return 'flies' if self.can_fly else 'hops along'
def extra(self):
return 'flies' if self.can_fly else 'flightless'
animals = [Dog('Rex', 3, 'Labrador'), Cat('Mia', 5, True),
Bird('Kiwi', 1, False), Bird('Piolín', 2, True)]
for a in animals:
print(a.describe())
print(animals)
try:
Animal('Nadie', 0)
except TypeError as err:
print('TypeError:', err)
The decisions that matter:
describe()and__str__live in the parent and usesound(),move()andextra(). No child repeats the format; it just answers three questions. If the format changes tomorrow, you touch one place.extra()is a hook, not an abstract method. It returns''by default; all three children override it. It could be abstract (they all have it), and that would be equally valid: we chose a hook because an animal with no detail still makes sense.- Private attributes stay in the parent. Children don’t touch
__nameor__age: if they tried withself.__name, Python would look for_Dog__nameand raiseAttributeError. That’s what thenameandageproperties are for. reprwithtype(self).__name__: that’s why the list showsDog('Rex', 3),Cat('Mia', 5)… even though__repr__lives inAnimal.- “1 year”: the singular is decided in
__str__with a conditional expression, which is why Kiwi shows “1 year”.
The exercise hints asked for super().__init__(name, age) in each child. If you remove it from Dog, describe() calls __str__, which reads self.__name (that is, _Animal__name), and that doesn’t exist: AttributeError on the first line of the output.
Solution to exercise 2
from abc import ABC, abstractmethod
class AccountError(Exception):
pass
class Account(ABC):
def __init__(self, holder, initial_balance):
self.__holder = holder
self.__balance = initial_balance
self.__movements = [f'Opening: {initial_balance:.2f} €']
@property
def holder(self):
return self.__holder
@property
def balance(self):
return self.__balance
def _move(self, amount, concept): # the only place that touches the balance
self.__balance += amount
self.__movements.append(f'{concept}: {amount:+.2f} € | balance {self.__balance:.2f} €')
@abstractmethod
def kind(self):
pass
@abstractmethod
def annual_interest(self):
pass
@abstractmethod
def can_withdraw(self, amount):
pass
def deposit(self, amount):
if amount <= 0:
raise AccountError(f'invalid deposit: {amount}')
self._move(amount, 'Deposit')
def withdraw(self, amount): # template: each child sets the rule
if amount <= 0:
raise AccountError(f'invalid withdrawal: {amount}')
if not self.can_withdraw(amount):
raise AccountError(f'{self.kind()} does not allow withdrawing {amount:.2f} €')
self._move(-amount, 'Withdrawal')
self._after_withdrawal(amount)
def _after_withdrawal(self, amount): # empty hook
pass
def apply_interest(self):
interest = round(self.__balance * self.annual_interest(), 2)
if interest:
self._move(interest, f'Interest {self.annual_interest():.1%}')
def statement(self):
head = f'{self.kind()} for {self.__holder}'
return '\n'.join([head] + [' ' + m for m in self.__movements])
class CurrentAccount(Account):
FEE = 15
def __init__(self, holder, initial_balance, overdraft_max):
super().__init__(holder, initial_balance)
self.overdraft_max = overdraft_max
def kind(self):
return 'Current account'
def annual_interest(self):
return 0.0
def can_withdraw(self, amount):
return self.balance - amount >= -self.overdraft_max
def _after_withdrawal(self, amount):
if self.balance < 0:
self._move(-self.FEE, 'Overdraft fee')
class SavingsAccount(Account):
def __init__(self, holder, initial_balance, interest):
super().__init__(holder, initial_balance)
self.interest = interest
self.operations = 3 # the first withdrawal is allowed
def kind(self):
return 'Savings account'
def annual_interest(self):
return self.interest
def deposit(self, amount): # EXTENDS: deposits and counts the operation
super().deposit(amount)
self.operations += 1
def can_withdraw(self, amount):
return self.balance - amount >= 0 and self.operations >= 3
def _after_withdrawal(self, amount):
self.operations = 0
class InvestmentAccount(Account):
PROFILES = {'cautious': 0.04, 'moderate': 0.07, 'aggressive': 0.12}
MINIMUM = 1000
PENALTY = 0.02
def __init__(self, holder, initial_balance, profile):
if profile not in self.PROFILES:
raise ValueError(f'unknown profile: {profile!r}')
super().__init__(holder, initial_balance)
self.profile = profile
def kind(self):
return f'Investment account ({self.profile})'
def annual_interest(self):
return self.PROFILES[self.profile]
def can_withdraw(self, amount): # the remaining balance, penalty included
return self.balance - amount * (1 + self.PENALTY) >= self.MINIMUM
def _after_withdrawal(self, amount):
self._move(-round(amount * self.PENALTY, 2), 'Penalty 2 %')
cc = CurrentAccount('Laura', 500, overdraft_max=300)
cc.deposit(200)
cc.withdraw(900)
ca = SavingsAccount('Pablo', 2000, interest=0.035)
ca.apply_interest()
ca.withdraw(500)
ci = InvestmentAccount('Elena', 5000, 'aggressive')
ci.apply_interest()
ci.withdraw(2000)
attempts = [(cc, 200), (ca, 100), (ci, 3000)]
for account, amount in attempts:
try:
account.withdraw(amount)
except AccountError as err:
print('Error:', err)
for account in [cc, ca, ci]:
print(account.statement())
The template method is withdraw:
def withdraw(self, amount): # template: each child sets the rule
if amount <= 0:
raise AccountError(f'invalid withdrawal: {amount}')
if not self.can_withdraw(amount):
raise AccountError(f'{self.kind()} does not allow withdrawing {amount:.2f} €')
self._move(-amount, 'Withdrawal')
self._after_withdrawal(amount)
def _after_withdrawal(self, amount): # empty hook
pass
The parent always does the same thing (validate, ask, move, hook) and each child only changes two pieces. Let’s check the numbers with a trace per account:
| Account | Operation | Check | Balance after |
|---|---|---|---|
| Current | deposit 200 | — | 700.00 |
| Current | withdraw 900 | 700 − 900 = −200 ≥ −300: yes | −200.00 |
| Current | fee | balance < 0 | −215.00 |
| Current | withdraw 200 | −215 − 200 = −415 ≥ −300: no | AccountError |
| Savings | 3.5% interest | 2000 × 0.035 = 70 | 2070.00 |
| Savings | withdraw 500 | 1570 ≥ 0 and 3 operations: yes | 1570.00 (counter to 0) |
| Savings | withdraw 100 | counter 0 < 3: no | AccountError |
| Investment | 12% interest | 5000 × 0.12 = 600 | 5600.00 |
| Investment | withdraw 2000 | 5600 − 2000 × 1.02 = 3560 ≥ 1000: yes | 3600.00 |
| Investment | penalty | 2000 × 0.02 = 40 | 3560.00 |
| Investment | withdraw 3000 | 3560 − 3060 = 500 ≥ 1000: no | AccountError |
Why it’s built this way:
_moveis the only one that touches the balance. The balance is private toAccount(__balance), so children can’t writeself.__balance -= 15. With_move(one underscore: internal, but for the children) they change the balance and, on the way, the movement is always recorded the same way.can_withdrawreturns a boolean and the parent raises the exception. That way the error message has the same format in all three and children don’t repeat theraise.SavingsAccount.depositextends, it doesn’t replace. It callssuper().deposit(amount)(which validates and moves the balance) and only adds the counter. If it replaced it entirely, it would have to repeat the validation.InvestmentAccountvalidates the profile beforesuper().__init__: with a profile that doesn’t exist the account never gets opened.- Interest is rounded to cents before moving it, as a bank would.
AccountError(Exception) is inheritance too: a custom exception is a child class of Exception. You saw it in the Python exceptions topic, and now you know why except AccountError works: except uses isinstance.
Solution to exercise 3
from abc import ABC, abstractmethod
class LoanError(Exception):
pass
class Member(ABC):
def __init__(self, name):
self.name = name
self.borrowed = {} # title -> day it was borrowed
@abstractmethod
def limit(self):
pass
@abstractmethod
def loan_days(self):
pass
def fine_per_day(self): # hook: 0.20 €/day unless overridden
return 0.20
def can_take(self, item): # hook: everything by default
return True
def fine(self, days_out):
return max(0, days_out - self.loan_days()) * self.fine_per_day()
def __str__(self):
return f'{self.name} ({type(self).__name__}, {len(self.borrowed)}/{self.limit()})'
class Student(Member):
def limit(self):
return 3
def loan_days(self):
return 15
class Lecturer(Member):
def limit(self):
return 10
def loan_days(self):
return 60
def fine_per_day(self):
return 0.0
class Visitor(Member):
def limit(self):
return 1
def loan_days(self):
return 7
def fine_per_day(self):
return 0.50
def can_take(self, item): # magazines are reading-room only
return item.kind != 'magazine'
class Item:
def __init__(self, title, kind='book'):
self.title = title
self.kind = kind
self.lent_to = None
class Library:
def __init__(self):
self.collected = 0.0
def lend(self, member, item, day):
if item.lent_to is not None:
raise LoanError(f'‘{item.title}’ is already with {item.lent_to.name}')
if len(member.borrowed) >= member.limit():
raise LoanError(f'{member.name} already has {len(member.borrowed)} of {member.limit()} allowed loans')
if not member.can_take(item):
raise LoanError(f'{member.name} cannot take a {item.kind}')
item.lent_to = member
member.borrowed[item.title] = day
def return_item(self, member, item, day):
days_out = day - member.borrowed.pop(item.title)
item.lent_to = None
fine = member.fine(days_out) # polymorphism: each member its own
self.collected += fine
return f'{member.name} returns ‘{item.title}’: {days_out} days, fine {fine:.2f} €'
lib = Library()
calculus = Item('Calculus')
python = Item('Python for Everybody')
networks = Item('Computer Networks')
data = Item('Data Structures in C')
magazine = Item('Scientific American', 'magazine')
laura, ramos, pablo = Student('Laura'), Lecturer('Dr Ramos'), Visitor('Pablo')
operations = [
(lib.lend, laura, calculus, 1), (lib.lend, ramos, python, 1),
(lib.lend, pablo, magazine, 2), (lib.lend, pablo, networks, 2),
(lib.lend, laura, python, 3), (lib.lend, pablo, data, 4),
(lib.return_item, pablo, networks, 13), (lib.return_item, laura, calculus, 22),
(lib.return_item, ramos, python, 75),
]
for action, member, item, day in operations:
try:
result = action(member, item, day)
print(f'Day {day:>2}: {result or "loan of ‘" + item.title + "’ to " + member.name}')
except LoanError as err:
print(f'Day {day:>2}: Rejected: {err}')
print('Members:', ', '.join(str(s) for s in [laura, ramos, pablo]))
print(f'Collected in fines: {lib.collected:.2f} €')
The operations list is the script of the statement: tuples with the action (the lib.lend or lib.return_item method, without calling it), the member, the item and the day. The for runs them one by one and catches LoanError. Since lend returns None and return_item a text, the line result or "loan of…" picks what to print.
lend checks in this order:
def lend(self, member, item, day):
if item.lent_to is not None:
raise LoanError(f'‘{item.title}’ is already with {item.lent_to.name}')
if len(member.borrowed) >= member.limit():
raise LoanError(f'{member.name} already has {len(member.borrowed)} of {member.limit()} allowed loans')
if not member.can_take(item):
raise LoanError(f'{member.name} cannot take a {item.kind}')
item.lent_to = member
member.borrowed[item.title] = day
Trace of the loans:
| Day | Request | Check that fails | Result |
|---|---|---|---|
| 1 | Laura, “Calculus” | none | loan |
| 1 | Dr Ramos, “Python for Everybody” | none | loan |
| 2 | Pablo, the magazine | can_take → False (Visitor) | rejected |
| 2 | Pablo, “Computer Networks” | none (0 of 1) | loan |
| 3 | Laura, “Python for Everybody” | lent_to isn’t None | rejected |
| 4 | Pablo, “Data Structures in C” | 1 of 1: limit | rejected |
And the returns, where polymorphism does all the work:
Pablo had “Computer Networks” from day 2 to 13: 11 days, 4 over his 7-day period, at 0.50 €: 2.00 €. Laura, from 1 to 22: 21 days, 6 late at 0.20 €: 1.20 €. Dr Ramos, 74 days with a 60-day period: 14 late at 0 €. Collected: 3.20 €.
Why it’s built this way:
Librarydoesn’t know which kinds of member exist. If anAlumniMemberarrives tomorrow,lendandreturn_itemdon’t change.fine_per_dayandcan_takeare hooks: only those who are different override them (the lecturer pays no fines; the visitor can’t take magazines). The abstract methods are what everyone must decide: the limit and the loan period.Itemisn’t a hierarchy (book and magazine only differ in one value). Not everything has to inherit: if the kind only changes a value and not a behaviour, an attribute is enough.max(0, …)infineavoids negative fines for anyone who returns early.
—
Exam-style quiz
Now test yourself as in the lab. FP2 quizzes look like this: about 10 questions in 10-15 minutes, single choice, multiple choice (wrong answers subtract), true or false, fill in the blanks, matching and “what does this code print?”. Here are 14 original questions about inheritance. Answer without running anything, press Check and read the explanation of each one. These questions are also the question bank of the FP2 theory mock exam, which will mix them with those of the other topics.
How do you declare that Motorbike inherits from Vehicle?
class Motorbike(Vehicle):class Motorbike extends Vehicle:class Motorbike: Vehicleclass Motorbike(inherits=Vehicle):
Solution
Answer: class Motorbike(Vehicle):
The superclass (or superclasses) goes in parentheses after the name. Without parentheses, the class inherits directly from object.
What does this program print?
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
def introduce(self):
return f"{self.name}: {self.speak()}"
class Dog(Animal):
def speak(self):
return "woof"
print(Dog("Toby").introduce())Solution
Answer: Toby: woof
introduce lives in Animal, but self is a Dog, so self.speak() looks in Dog first and finds the overridden version. That’s polymorphism: the parent’s method uses the child’s without knowing it.
ClassB inherits from ClassA, whose __init__(self, x) stores x. How does ClassB.__init__(self, x, colour) call the parent’s initialiser?
super().__init__(x)super.__init__(x)ClassA.__init__(x)self.__init__(x)
Solution
Answer: super().__init__(x)
The usual thing is to start the child’s __init__ with super().__init__(...) so the parent sets up its attributes and then add the child’s own (self.colour = colour).
What does this program print?
class Person:
def __init__(self, name):
self.name = name
class Student(Person):
def __init__(self, name, mark):
self.mark = mark
s = Student("Eva", 8)
print(hasattr(s, "name"))Solution
Answer: False
Student overrides __init__ and doesn’t call super().__init__(name), so the code that creates self.name never runs. Inheriting the methods isn’t enough: attributes only exist if someone assigns them.
True or false?
- In Python 3 every class inherits, directly or indirectly, from
object. - If
Doginherits fromAnimal,isinstance(Dog("Toby"), Animal)isTrue. - If
Doginherits fromAnimal,type(Dog("Toby")) == AnimalisTrue. - If a class defines two methods with the same name and different parameters, Python keeps both (overloading).
- A subclass can override
__str__to display itself differently.
Solution
- T — In Python 3 every class inherits, directly or indirectly, from
object. - T — If
Doginherits fromAnimal,isinstance(Dog("Toby"), Animal)isTrue. - F — If
Doginherits fromAnimal,type(Dog("Toby")) == AnimalisTrue. - F — If a class defines two methods with the same name and different parameters, Python keeps both (overloading).
- T — A subclass can override
__str__to display itself differently.
type() returns the exact class (Dog); isinstance() also accepts superclasses. Python has no overloading by parameters: the second def with the same name replaces the first (you imitate it with default values or *args).
Match each tool with what it tells you or does:
isinstance(obj, C) | |
issubclass(B, A) | |
type(obj) | |
obj.__class__.__name__ | |
super() |
Definitions:
- The name of the object's class as text
- Makes a copy of the object
- Is obj a C or a subclass of C?
- Gives access to the parent class's methods
- The exact class of the object
- Does class B derive from class A?
Solution
isinstance(obj, C)→ Is obj a C or a subclass of C?issubclass(B, A)→ Does class B derive from class A?type(obj)→ The exact class of the objectobj.__class__.__name__→ The name of the object’s class as textsuper()→ Gives access to the parent class’s methods
obj.__class__ is the same as type(obj), and its __name__ is the name as a str (useful in __repr__, so that a subclass shows its own name).
What does this program print?
class A:
def who(self):
return "A"
class B(A):
def who(self):
return "B" + super().who()
class C(B):
pass
print(C().who())Solution
Answer: BA
C has no who, so Python looks in B. B‘s version returns "B" plus what its parent A returns: "BA".
With this hierarchy, which except clauses catch raise OutOfStockError("sold out")?
class ShopError(Exception):
pass
class OutOfStockError(ShopError):
pass
class PaymentError(ShopError):
passexcept OutOfStockError:except ShopError:except Exception:except PaymentError:except ValueError:
Solution
Answer: except OutOfStockError: · except ShopError: · except Exception:
An except catches its type and all its subclasses. That’s why, when a module raises several custom exceptions, a common base (ShopError) is a good idea: its users can catch them all at once or one by one.
About custom exceptions, true or false?
- An exception class can have attributes and methods like any other class.
- It has to inherit from
Exception(or a subclass of it) to be raised and caught like the others. - Its name has to end in
Erroror Python won’t accept it. - If its
__init__callssuper().__init__(message),print(e)will show that message.
Solution
- T — An exception class can have attributes and methods like any other class.
- T — It has to inherit from
Exception(or a subclass of it) to be raised and caught like the others. - F — Its name has to end in
Erroror Python won’t accept it. - T — If its
__init__callssuper().__init__(message),print(e)will show that message.
Ending in Error is a convention (a highly recommended one), not a language rule.
What does this program print?
class BalanceError(Exception):
def __init__(self, missing):
super().__init__(f"short by {missing} euros")
self.missing = missing
try:
raise BalanceError(15)
except BalanceError as e:
print(e.missing * 2, e)Solution
Answer: 30 short by 15 euros
e.missing is the custom attribute (15, times 2 = 30) and print(e) shows the message passed to super().__init__.
You want Shape to be impossible to instantiate and to force every subclass to write area(). What do you do?
Shapeinherits fromABCandareagets@abstractmethod- You write
areawithpass - You put a
raisein__init__ - You declare
abstract class Shape:
Solution
Answer: Shape inherits from ABC and area gets @abstractmethod
With from abc import ABC, abstractmethod, Shape() gives TypeError, and a subclass that doesn’t implement area can’t be instantiated either.
What number does this program print?
class Shape:
def area(self):
return 0
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.side
class Rect(Shape):
def __init__(self, b, h):
self.b, self.h = b, h
def area(self):
return self.b * self.h
shapes = [Square(3), Rect(2, 5), Shape()]
print(sum(s.area() for s in shapes))Solution
Answer: 19
The same call s.area() gives 9, 10 and 0 depending on each object’s class: 9 + 10 + 0 = 19.
What do you call being able to use the same call, like s.area(), and get a different behaviour depending on the object’s class?
Solution
Answer: Polymorphism
Polymorphism means «many forms». In Python the objects don’t even need to share a parent: it’s enough that they have the method (duck typing), although with inheritance the design is clearer.
What happens if, in the same class, you write def area(self): and, further down, def area(self, scale):?
- Only the second exists:
obj.area()givesTypeErrorbecausescaleis missing - Python picks one or the other depending on the arguments
- Syntax error when the class loads
- Only the first exists
Solution
Answer: Only the second exists: obj.area() gives TypeError because scale is missing
To accept calls with a different number of arguments in Python you use default values (scale=1) or *args.
—
Watch it step by step in Python Tutor
Let’s watch the quietest bug of the snippets live: the override without return. On pythontutor.com, choose Python 3 and paste:
class Member:
def __init__(self, name):
self.name = name
def description(self):
return 'Member ' + self.name
class Lecturer(Member):
def description(self):
text = super().description() + ' (staff)'
class LecturerFixed(Member):
def description(self):
return super().description() + ' (staff)'
print(Lecturer('Ramos').description())
print(LecturerFixed('Ramos').description())
Output:
None Member Ramos (staff)
What to watch step by step:
- In the first call, it enters
Lecturer.descriptionand from thereMember.description(two frames on the stack). The parent’s returns'Member Ramos': you’ll see it as Return value. - Back in the child, the variable
textholds'Member Ramos (staff)'… and the frame ends with Return valueNone. The text is lost with the frame. - In the second call, the same path, but the frame of
LecturerFixedreturns the whole string. - Compare the two
printcalls: the first receivesNone, the second the string. No error at any point.
—
Cheat sheet: Python inheritance
The essentials in 30 seconds: the child is written class Child(Parent): and inherits everything; if it has its own __init__, it calls super().__init__(…); overriding is writing a method with the same name and signature; extending is return super().method() + …; Python looks methods up from the object’s class upwards (MRO); isinstance respects inheritance and type doesn’t; an abstract class (ABC + @abstractmethod) can’t be instantiated and binds its children.
| I want to… | Code | Result |
|---|---|---|
| Inherit | class Magazine(Book): | Magazine has everything Book has |
| Initialise the parent’s data | super().__init__(title, author) | Parent attributes in the object |
| Override a method | def limit(self): return 10 | The child’s objects use their own |
| Extend a method | return super().description() + ' (staff)' | Parent’s text + the new part |
| Check “is a” | isinstance(e, Member) | True for the children too |
| Exact class | type(e).__name__ | 'Student' |
| Lookup order | Fellow.__mro__ | (Fellow, Lecturer, Student, object) |
| Abstract class | class Item(ABC): + @abstractmethod | TypeError when instantiated |
| Abstract property | @property and below it @abstractmethod | Children write it with @property |
| Data for the children | self._base_salary (one underscore) | They read it without AttributeError |
Exercise templates:
| Pattern | Skeleton | Result in the exercises |
|---|---|---|
| Template method | def withdraw(self, c): … if not self.can_withdraw(c): raise … | A different rule per account |
| Hook with a default | def fine_per_day(self): return 0.20 | The lecturer overrides it with 0.0 |
| Real name in the parent | f'{type(self).__name__}(…)' | Dog('Rex', 3) |
| Extend with a counter | super().deposit(c) then self.operations += 1 | Savings counts deposits |
| Symptom | Likely cause |
|---|---|
AttributeError: '…' object has no attribute 'title' | The child has __init__ and didn’t call super().__init__ |
AttributeError with _Child__data | The child uses the parent’s self.__data |
TypeError: Can't instantiate abstract class … | An abstract method is missing or misspelled |
TypeError: … missing 1 required positional argument | The override changed the signature |
None appears | The override extends without return |
RecursionError | self.method() inside its own override; it should be super().method() |
| A filter leaves objects out | type(x) == Parent instead of isinstance |
Before you hand it in, ask yourself:
- Does every child with its own
__init__callsuper().__init__with the parent’s parameters? - Do the overrides have the same signature as the parent’s method, and do they return something?
- Is what’s shared written once in the parent, with the children answering only what changes?
- Do I use
isinstance(and nottype) when asking “is it a…?”?
And that wraps up inheritance. The next topic, Python operator overloading, uses exactly the last thing you saw: overriding the magic methods every class inherits from object.
Did this article help you? Here is how you can support SergioLearns →




What is an operating system — theory with your laptop on a study afternoon, a write() call and a CPU that never rests (and the three programs that weren’t running at the same time)