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.

—

How to use these exercises

  1. Read the statement and the expected output. Your program must give exactly that output with that data.
  2. Try it without looking at the hints. If you’re stuck for more than 15 minutes, open the hint.
  3. Before the solutions, do the “What’s wrong here?” section: three snippets with typical bugs to predict.
  4. Compare with the commented solution: what matters isn’t that yours looks the same, but understanding why it’s built that way.
  5. 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 name and age, with read-only properties.
  • Abstract methods sound() and move().
  • A normal method describe() that returns three lines: the introduction, the sound and the movement (it uses the abstract ones).
  • __str__ with the format Name (own detail, N years) (“1 year” in the singular) and __repr__ with the format Class('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() and can_withdraw(amount) (returns True or False).
  • Normal methods: deposit(amount), withdraw(amount), apply_interest() and statement().
  • withdraw checks the amount, asks can_withdraw and, if the answer is False, raises a custom AccountError exception. 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) and loan_days().
  • Hooks with a default value: fine_per_day() (0.20 € per day late) and can_take(item) (True).
  • fine(days_out): the days past the loan period times the daily fine (never negative).
  • __str__ with the format Laura (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 use sound(), move() and extra(). 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 __name or __age: if they tried with self.__name, Python would look for _Dog__name and raise AttributeError. That’s what the name and age properties are for.
  • repr with type(self).__name__: that’s why the list shows Dog('Rex', 3), Cat('Mia', 5)… even though __repr__ lives in Animal.
  • “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:

AccountOperationCheckBalance after
Currentdeposit 200—700.00
Currentwithdraw 900700 − 900 = −200 ≥ −300: yes−200.00
Currentfeebalance < 0−215.00
Currentwithdraw 200−215 − 200 = −415 ≥ −300: noAccountError
Savings3.5% interest2000 × 0.035 = 702070.00
Savingswithdraw 5001570 ≥ 0 and 3 operations: yes1570.00 (counter to 0)
Savingswithdraw 100counter 0 < 3: noAccountError
Investment12% interest5000 × 0.12 = 6005600.00
Investmentwithdraw 20005600 − 2000 × 1.02 = 3560 ≥ 1000: yes3600.00
Investmentpenalty2000 × 0.02 = 403560.00
Investmentwithdraw 30003560 − 3060 = 500 ≥ 1000: noAccountError

Why it’s built this way:

  • _move is the only one that touches the balance. The balance is private to Account (__balance), so children can’t write self.__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_withdraw returns 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 the raise.
  • SavingsAccount.deposit extends, it doesn’t replace. It calls super().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.
  • InvestmentAccount validates the profile before super().__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:

DayRequestCheck that failsResult
1Laura, “Calculus”noneloan
1Dr Ramos, “Python for Everybody”noneloan
2Pablo, the magazinecan_take → False (Visitor)rejected
2Pablo, “Computer Networks”none (0 of 1)loan
3Laura, “Python for Everybody”lent_to isn’t Nonerejected
4Pablo, “Data Structures in C”1 of 1: limitrejected

And the returns, where polymorphism does all the work:

Loans and fines of the three membersTimeline: Laura (Student, 15 days) returns on day 22, 6 days late, fined 1.20; Pablo (Visitor, 7 days) on day 13, fined 2.00; Dr Ramos (Lecturer, 60 days) on day 75 with no fine. Total 3.20.Loan days and delay (fine = days late × fine_per_day())day 0day 15day 30day 45day 60day 75LauraStudent15 days6 × 0.20 = 1.20 €returns on 22PabloVisitor7 days4 × 0.50 = 2.00 €returns on 13Dr RamosLecturer60 days14 × 0.00 = 0.00 €returns on 75Collected: 3.20 €loan_days()delay
The same return_item() gives three different fines because each member answers loan_days() and fine_per_day() in its own way.

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:

  • Library doesn’t know which kinds of member exist. If an AlumniMember arrives tomorrow, lend and return_item don’t change.
  • fine_per_day and can_take are 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.
  • Item isn’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, …) in fine avoids 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.

Question 1Single choice

How do you declare that Motorbike inherits from Vehicle?

  1. class Motorbike(Vehicle):
  2. class Motorbike extends Vehicle:
  3. class Motorbike: Vehicle
  4. class 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.

Question 2Short answer

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.

Question 3Single choice

ClassB inherits from ClassA, whose __init__(self, x) stores x. How does ClassB.__init__(self, x, colour) call the parent’s initialiser?

  1. super().__init__(x)
  2. super.__init__(x)
  3. ClassA.__init__(x)
  4. 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).

Question 4Short answer

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.

Question 5True or false

True or false?

  1. In Python 3 every class inherits, directly or indirectly, from object.
  2. If Dog inherits from Animal, isinstance(Dog("Toby"), Animal) is True.
  3. If Dog inherits from Animal, type(Dog("Toby")) == Animal is True.
  4. If a class defines two methods with the same name and different parameters, Python keeps both (overloading).
  5. A subclass can override __str__ to display itself differently.
Solution
  • T — In Python 3 every class inherits, directly or indirectly, from object.
  • T — If Dog inherits from Animal, isinstance(Dog("Toby"), Animal) is True.
  • F — If Dog inherits from Animal, type(Dog("Toby")) == Animal is True.
  • 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).

Question 6Match

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 object
  • obj.__class__.__name__ → The name of the object’s class as text
  • super() → 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).

Question 7Short answer

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".

Question 8Multiple · wrong ticks subtract

With this hierarchy, which except clauses catch raise OutOfStockError("sold out")?

class ShopError(Exception):
    pass

class OutOfStockError(ShopError):
    pass

class PaymentError(ShopError):
    pass
  1. except OutOfStockError:
  2. except ShopError:
  3. except Exception:
  4. except PaymentError:
  5. 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.

Question 9True or false

About custom exceptions, true or false?

  1. An exception class can have attributes and methods like any other class.
  2. It has to inherit from Exception (or a subclass of it) to be raised and caught like the others.
  3. Its name has to end in Error or Python won’t accept it.
  4. If its __init__ calls super().__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 Error or Python won’t accept it.
  • T — If its __init__ calls super().__init__(message), print(e) will show that message.

Ending in Error is a convention (a highly recommended one), not a language rule.

Question 10Short answer

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__.

Question 11Single choice

You want Shape to be impossible to instantiate and to force every subclass to write area(). What do you do?

  1. Shape inherits from ABC and area gets @abstractmethod
  2. You write area with pass
  3. You put a raise in __init__
  4. 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.

Question 12Short answer

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.

Question 13Short answer

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.

Question 14Single choice

What happens if, in the same class, you write def area(self): and, further down, def area(self, scale):?

  1. Only the second exists: obj.area() gives TypeError because scale is missing
  2. Python picks one or the other depending on the arguments
  3. Syntax error when the class loads
  4. 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:

  1. In the first call, it enters Lecturer.description and from there Member.description (two frames on the stack). The parent’s returns 'Member Ramos': you’ll see it as Return value.
  2. Back in the child, the variable text holds 'Member Ramos (staff)'… and the frame ends with Return value None. The text is lost with the frame.
  3. In the second call, the same path, but the frame of LecturerFixed returns the whole string.
  4. Compare the two print calls: the first receives None, 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…CodeResult
Inheritclass Magazine(Book):Magazine has everything Book has
Initialise the parent’s datasuper().__init__(title, author)Parent attributes in the object
Override a methoddef limit(self): return 10The child’s objects use their own
Extend a methodreturn super().description() + ' (staff)'Parent’s text + the new part
Check “is a”isinstance(e, Member)True for the children too
Exact classtype(e).__name__'Student'
Lookup orderFellow.__mro__(Fellow, Lecturer, Student, object)
Abstract classclass Item(ABC): + @abstractmethodTypeError when instantiated
Abstract property@property and below it @abstractmethodChildren write it with @property
Data for the childrenself._base_salary (one underscore)They read it without AttributeError

Exercise templates:

PatternSkeletonResult in the exercises
Template methoddef withdraw(self, c): … if not self.can_withdraw(c): raise …A different rule per account
Hook with a defaultdef fine_per_day(self): return 0.20The lecturer overrides it with 0.0
Real name in the parentf'{type(self).__name__}(…)'Dog('Rex', 3)
Extend with a countersuper().deposit(c) then self.operations += 1Savings counts deposits
SymptomLikely cause
AttributeError: '…' object has no attribute 'title'The child has __init__ and didn’t call super().__init__
AttributeError with _Child__dataThe 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 argumentThe override changed the signature
None appearsThe override extends without return
RecursionErrorself.method() inside its own override; it should be super().method()
A filter leaves objects outtype(x) == Parent instead of isinstance

Before you hand it in, ask yourself:

  1. Does every child with its own __init__ call super().__init__ with the parent’s parameters?
  2. Do the overrides have the same signature as the parent’s method, and do they return something?
  3. Is what’s shared written once in the parent, with the children answering only what changes?
  4. Do I use isinstance (and not type) 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.

Keep goingAll Python articles

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *