Python inheritance practice — a payroll, a fleet with a hybrid and a shop with IGIC (and the formula the hybrid copied instead of inheriting)
In an FP2 lab, a classmate had a Hybrid that inherited from Petrol… and that, instead of calling the parent’s formula, had copied and pasted it with the electric saving at the end. It worked. Until the assignment changed how fuel consumption was computed for petrol cars: he fixed it in Petrol and the hybrid kept computing with the old formula. Two classes, two versions of the same calculation, and only one up to date. The lesson: if the child needs what the parent does, it asks for it with super(); it doesn’t copy it.
In the Python inheritance theory you saw super(), method overriding, polymorphism and abstract classes with the faculty library. Here we use them in three intermediate-level programs, with data that looks like the real thing (and some of it wrong on purpose):
- A payroll with programmers, designers and a manager whose bonus depends on their team.
- A car fleet of petrol, electric and hybrid cars, with two levels of inheritance.
- A shop with physical products, books (which have 0% IGIC in the Canary Islands), digital licences and services.
All three share a pattern you’ll see three times: the parent writes the recipe once (the template method) and each child provides its data.
Table of Contents
—
Before you start
Make sure you’re clear on:
- How to inherit (
class Child(Parent):) and why a child with its own__init__callssuper().__init__(…). - The difference between replacing a method and extending it with
super().method(). - What an abstract class is (
ABC+@abstractmethod) and why instantiating it raisesTypeError. - The difference between
isinstanceandtype.
If any of them sounds shaky, review the theory before going on.
Copy each program into its own .py file and run it in full before reading the explanation. All three use only the standard library (from abc import ABC, abstractmethod), so they work in any Python 3.
—
Program 1: a payroll with different bonuses
A small company has programmers (they get a bonus by level: junior, mid or senior), designers (120 € per delivered project) and a manager (5% of their team’s base salary). We need to print everyone’s payslip, find who earns most and how much the staff costs per month. What makes it tricky: each job computes the bonus its own way, the manager needs the others to compute theirs, and there’s hiring data that must be rejected (a level that doesn’t exist, a negative salary).
The idea
What’s shared (name, base salary, how the total is computed, how the payslip is printed) goes once in Employee. What changes (the bonus and the category) are abstract methods each job writes. That way total_salary() and payslip() work for everyone without knowing the job: they call self.bonus() and each object answers its own.
The manager has one more detail: their payslip is the normal one plus the list of their team. That’s extending, not replacing: super().payslip() and add lines.
Before coding, think about which parts of the payslip are the same for everyone (the format, the sum) and which change (the bonus, the category). The same goes in the parent; what changes, in methods the children override.
The code
from abc import ABC, abstractmethod
class Employee(ABC):
"""What all staff share. Not instantiated: it is the contract."""
def __init__(self, name, base_salary):
if base_salary <= 0:
raise ValueError(f'invalid salary for {name}: {base_salary}')
self.name = name
self._base_salary = base_salary # _ : children can read it
@property
def base_salary(self):
return self._base_salary
@abstractmethod
def bonus(self): # each job computes it its own way
pass
@abstractmethod
def category(self):
pass
def total_salary(self): # template method: uses the abstract ones
return self._base_salary + self.bonus()
def payslip(self):
return (f'--- Payslip for {self.name} ---\n'
f'Category: {self.category()}\n'
f'Base salary: {self._base_salary:8.2f} €\n'
f'Bonus: {self.bonus():8.2f} €\n'
f'Total: {self.total_salary():8.2f} €')
def __repr__(self): # the name of the object's REAL class
return f'{type(self).__name__}({self.name!r}, {self._base_salary})'
class Programmer(Employee):
LEVELS = {'junior': 0.05, 'mid': 0.10, 'senior': 0.15}
def __init__(self, name, base_salary, language, level):
if level not in self.LEVELS: # we validate BEFORE creating anything
raise ValueError(f'unknown level: {level!r}')
super().__init__(name, base_salary)
self.language = language
self.level = level
def bonus(self):
return self._base_salary * self.LEVELS[self.level]
def category(self):
return f'Programmer {self.level} ({self.language})'
class Designer(Employee):
def __init__(self, name, base_salary, projects):
super().__init__(name, base_salary)
self.projects = projects
def bonus(self):
return 120 * self.projects # 120 € per delivered project
def category(self):
return f'Designer ({self.projects} projects)'
class Manager(Employee):
def __init__(self, name, base_salary, team):
super().__init__(name, base_salary)
self.team = list(team) # copy: doesn't share the outside list
def bonus(self): # 5% of the team's base salaries
return 0.05 * sum(e.base_salary for e in self.team)
def category(self):
return f'Manager (team of {len(self.team)})'
def payslip(self): # EXTENDS the parent's payslip
lines = [super().payslip(), 'Team:']
lines += [f' - {e.name} ({type(e).__name__})' for e in self.team]
return '\n'.join(lines)
laura = Programmer('Laura', 2100, 'Python', 'mid')
pablo = Programmer('Pablo', 1800, 'Java', 'junior')
ana = Designer('Ana', 1900, 4)
elena = Manager('Elena', 2600, [laura, pablo, ana])
staff = [laura, pablo, ana, elena]
for e in staff:
print(e.payslip())
print()
print('Staff:', staff)
print('Highest pay:', max(staff, key=lambda e: e.total_salary()).name)
print(f'Monthly cost: {sum(e.total_salary() for e in staff):.2f} €')
for data in [('Iván', 1700, 'C', 'expert'), ('Noa', -50, 'Python', 'mid')]:
try:
Programmer(*data)
except ValueError as err:
print('Not hired:', err)
try:
Employee('Nadie', 1000)
except TypeError as err:
print('TypeError:', err)
The lines doing most of the work are these two in Employee:
def total_salary(self): # template method: uses the abstract ones
return self._base_salary + self.bonus()
def payslip(self):
return (f'--- Payslip for {self.name} ---\n'
f'Category: {self.category()}\n'
f'Base salary: {self._base_salary:8.2f} €\n'
f'Bonus: {self.bonus():8.2f} €\n'
f'Total: {self.total_salary():8.2f} €')
total_salary and payslip don’t know what a programmer or a manager is. They call self.bonus() and self.category(), which have no code at all in Employee (they’re abstract). When the object is a Programmer, Python looks up bonus from Programmer and finds its own. That’s how the parent can use methods that only exist in the children.
The programmer’s __init__ validates before calling the parent:
def __init__(self, name, base_salary, language, level):
if level not in self.LEVELS: # we validate BEFORE creating anything
raise ValueError(f'unknown level: {level!r}')
super().__init__(name, base_salary)
self.language = language
self.level = level
Why this order, if the theory said “super().__init__ first”? Because here validating the level needs nothing from the parent, and this way, if the level is wrong, not even half the object gets created. The “first” rule is for when the child uses something the parent sets.
Output
--- Payslip for Laura ---
Category: Programmer mid (Python)
Base salary: 2100.00 €
Bonus: 210.00 €
Total: 2310.00 €
--- Payslip for Pablo ---
Category: Programmer junior (Java)
Base salary: 1800.00 €
Bonus: 90.00 €
Total: 1890.00 €
--- Payslip for Ana ---
Category: Designer (4 projects)
Base salary: 1900.00 €
Bonus: 480.00 €
Total: 2380.00 €
--- Payslip for Elena ---
Category: Manager (team of 3)
Base salary: 2600.00 €
Bonus: 290.00 €
Total: 2890.00 €
Team:
- Laura (Programmer)
- Pablo (Programmer)
- Ana (Designer)
Staff: [Programmer('Laura', 2100), Programmer('Pablo', 1800), Designer('Ana', 1900), Manager('Elena', 2600)]
Highest pay: Elena
Monthly cost: 9470.00 €
Not hired: unknown level: 'expert'
Not hired: invalid salary for Noa: -50
TypeError: Can't instantiate abstract class Employee without an implementation for abstract methods 'bonus', 'category'
Let’s check the numbers. Laura is mid: 2100 × 0.10 = 210, total 2310. Pablo, junior: 1800 × 0.05 = 90, total 1890. Ana delivered 4 projects: 4 × 120 = 480, total 2380. And Elena’s bonus is 5% of her team’s base salaries, not the totals:
| Step | Team member | e.base_salary | Running sum |
|---|---|---|---|
| 1 | Laura | 2100 | 2100 |
| 2 | Pablo | 1800 | 3900 |
| 3 | Ana | 1900 | 5800 |
| — | 5800 × 0.05 | 290 |
Elena earns 2600 + 290 = 2890 and is the top earner. The monthly cost is 2310 + 1890 + 2380 + 2890 = 9470 €, exactly what it prints.
The Staff: list shows Programmer('Laura', 2100)… even though __repr__ is written in Employee. That’s because it uses type(self).__name__: the name of the object’s real class, not the class where the code lives. Had you written it by hand ('Employee(…)'), they’d all show as Employee.
The last three lines are the bad cases: the level 'expert' doesn’t exist, Noa’s salary is negative (rejected by the parent, which validates the salary for everyone) and Employee can’t be instantiated because it’s abstract.
Questions you might ask
Why _base_salary with one underscore and not two? Because the Manager reads its colleagues’ base salary and the Programmer uses it in its bonus. With two underscores, inside Programmer the name would become _Programmer__base_salary and raise AttributeError. One underscore is the “internal, but children may use it” convention, and we also offer it through a read-only property for outsiders.
Why does Manager store list(team) and not team? So it doesn’t share the list with whoever created it. If someone later did team.append(…) outside, the manager’s team would change without them knowing (we saw this with references in the classes topic).
Why does Manager.payslip call super().payslip() instead of writing the whole payslip? Because the top part is the same as everyone’s. If tomorrow a “Tax withholding” line is added to Employee.payslip, the manager gets it without touching their class. Copying it would repeat the mistake of the hybrid at the start.
What happens if I add a Fellow and forget to write category? Fellow('Iván', 900) raises TypeError when created, naming the missing method. That’s what abstract methods are for.
Does max(staff, key=…) know they’re different employees? It doesn’t need to: the lambda calls e.total_salary() and each object answers. It’s polymorphism inside a standard library function.
Computing the manager’s bonus with e.total_salary() instead of e.base_salary. It looks the same and it isn’t: the total already includes each person’s bonus (you’d get 329 € instead of 290). Read the statement carefully: “base salary”.
Try it yourself
- Add
Intern(name, base_salary, hours)with a bonus of 8 € per overtime hour and put them in Elena’s team. How much does Elena’s bonus go up? (Hint: only their base salary counts.) - Make
Manageralso extendcategory()so it says “Manager (team of 3: 2 programmers)”, counting withisinstance.
—
Program 2: a car fleet with a hybrid
A car rental company wants to compare its fleet: cost per kilometre, cost of a 500 km trip, range and current value of each car. There are petrol, electric and hybrid cars. What makes it tricky: the hybrid is a petrol car that drives part of the trip on electric, so its cost and range come from the petrol ones with an adjustment; and we need to pick out the cars that use petrol without losing the hybrid. Prices per litre (1.65 €) and per kWh (0.15 €) are made up and round.
The idea
Three levels: Vehicle (abstract) → Petrol and Electric → Hybrid, which inherits from Petrol. Vehicle holds what’s shared (plate, kilometres, current value, trip cost) and two abstract members: the cost_per_km() method and the range_km property. The hybrid doesn’t repeat a single formula: it asks for Petrol‘s with super() and applies its saving.
Before coding, think about what each thing “is”. A hybrid is a petrol car with something extra; an electric car isn’t a petrol car. That sentence decides who inherits from whom.
The code
from abc import ABC, abstractmethod
CURRENT_YEAR = 2026
LITRE_PRICE = 1.65 # €/L (made up, round)
KWH_PRICE = 0.15 # €/kWh charging at home (made up)
class Vehicle(ABC):
def __init__(self, plate, model, year, price):
self.plate = plate
self.model = model
self.year = year
self.price = price
self._km = 0
def drive(self, km):
if km <= 0:
raise ValueError(f'{self.plate}: km must be positive ({km})')
self._km += km
@abstractmethod
def cost_per_km(self):
pass
@property
@abstractmethod
def range_km(self): # km with a full tank or battery
pass
def trip_cost(self, km): # common to all: uses the abstract one
return km * self.cost_per_km()
def current_value(self): # loses 8% a year, never below 10%
factor = max(0.10, 1 - 0.08 * (CURRENT_YEAR - self.year))
return self.price * factor
def __str__(self):
return f'{type(self).__name__:<9} {self.plate} {self.model} ({self.year}), {self._km} km'
class Petrol(Vehicle):
def __init__(self, plate, model, year, price, litres_100km, tank=50):
super().__init__(plate, model, year, price)
self.litres_100km = litres_100km
self.tank = tank
def cost_per_km(self):
return self.litres_100km / 100 * LITRE_PRICE
@property
def range_km(self):
return self.tank / self.litres_100km * 100
class Electric(Vehicle):
def __init__(self, plate, model, year, price, kwh_100km, battery):
super().__init__(plate, model, year, price)
self.kwh_100km = kwh_100km
self.battery = battery
def cost_per_km(self):
return self.kwh_100km / 100 * KWH_PRICE
@property
def range_km(self):
return self.battery / self.kwh_100km * 100
class Hybrid(Petrol): # grandchild of Vehicle: two levels
def __init__(self, plate, model, year, price, litres_100km, saving):
super().__init__(plate, model, year, price, litres_100km, tank=43)
self.saving = saving # share of the trip driven on electric
def cost_per_km(self): # EXTENDS Petrol's, doesn't repeat it
return super().cost_per_km() * (1 - self.saving)
@property
def range_km(self): # the property is extended too
return super().range_km / (1 - self.saving)
fleet = [
Petrol('4821KLM', 'Seat Ibiza', 2019, 16500, 5.8),
Electric('7310MNB', 'Renault Zoe', 2023, 29900, 17.2, 52),
Hybrid('1975LPR', 'Toyota Yaris', 2022, 23400, 4.0, 0.35),
]
print(f'{"Vehicle":<35}{"€/km":>7}{"500 km":>10}{"Range":>11}{"Value now":>11}')
for v in fleet:
print(f'{v.plate} {v.model:<14}{type(v).__name__:<13}{v.cost_per_km():7.4f}'
f'{v.trip_cost(500):8.2f} €{v.range_km:8.0f} km{v.current_value():9.0f} €')
print('Hybrid looks up cost_per_km in:', [c.__name__ for c in Hybrid.__mro__])
petrol = [v.plate for v in fleet if isinstance(v, Petrol)]
print('isinstance(v, Petrol):', petrol)
print('type(v) == Petrol: ', [v.plate for v in fleet if type(v) == Petrol])
fleet[2].drive(320)
print(fleet[2])
try:
fleet[0].drive(-40)
except ValueError as err:
print('Error:', err)
The heart of the program is the hybrid:
class Hybrid(Petrol): # grandchild of Vehicle: two levels
def __init__(self, plate, model, year, price, litres_100km, saving):
super().__init__(plate, model, year, price, litres_100km, tank=43)
self.saving = saving # share of the trip driven on electric
def cost_per_km(self): # EXTENDS Petrol's, doesn't repeat it
return super().cost_per_km() * (1 - self.saving)
@property
def range_km(self): # the property is extended too
return super().range_km / (1 - self.saving)
super().cost_per_km() runs Petrol‘s version with the Yaris’s consumption (4.0 L/100 km) and returns 0.066; the hybrid multiplies it by 0.65. Same with the range: super().range_km (no brackets, it’s a property) gives the 1075 km it would do on petrol alone, and the hybrid divides it by 0.65. The diagram follows both calls:
Also look at the hybrid’s __init__: it passes the 43-litre tank to Petrol with tank=43. The parent has a default value (50) and the child changes it when calling, without needing to store the tank itself.
Output
Vehicle €/km 500 km Range Value now 4821KLM Seat Ibiza Petrol 0.0957 47.85 € 862 km 7260 € 7310MNB Renault Zoe Electric 0.0258 12.90 € 302 km 22724 € 1975LPR Toyota Yaris Hybrid 0.0429 21.45 € 1654 km 15912 € Hybrid looks up cost_per_km in: ['Hybrid', 'Petrol', 'Vehicle', 'ABC', 'object'] isinstance(v, Petrol): ['4821KLM', '1975LPR'] type(v) == Petrol: ['4821KLM'] Hybrid 1975LPR Toyota Yaris (2022), 320 km Error: 4821KLM: km must be positive (-40)
Let’s check one row of each type:
| Car | cost_per_km() | × 500 km | range_km | current_value() |
|---|---|---|---|---|
| Ibiza (petrol) | 5.8 / 100 × 1.65 = 0.0957 | 47.85 € | 50 / 5.8 × 100 = 862 km | 16500 × (1 − 0.08 × 7) = 7260 € |
| Zoe (electric) | 17.2 / 100 × 0.15 = 0.0258 | 12.90 € | 52 / 17.2 × 100 = 302 km | 29900 × (1 − 0.08 × 3) = 22724 € |
| Yaris (hybrid) | 0.066 × 0.65 = 0.0429 | 21.45 € | 1075 / 0.65 = 1654 km | 23400 × (1 − 0.08 × 4) = 15912 € |
Now the three diagnostic lines. The hybrid’s MRO is Hybrid → Petrol → Vehicle → ABC → object: that’s the order in which Python looks up any method of a Hybrid. And the difference between isinstance and type shows by itself: with isinstance(v, Petrol) you get the Ibiza and the Yaris (a hybrid is a petrol car); with type(v) == Petrol, only the Ibiza. If the report were “cars that need to go to the petrol station”, type would have forgotten the hybrid.
Finally, drive(320) adds the kilometres (a method inherited from Vehicle, nobody overrides it) and drive(-40) raises the ValueError with the plate.
Questions you might ask
Why is range_km an abstract property and not a method? Because it reads like data (“the car’s range”), not an action. To make it abstract you stack both decorators, @property and below it @abstractmethod, and the children write it with @property too. If a child wrote it as a normal method, v.range_km would return the function instead of the number.
Why does Hybrid inherit from Petrol and not straight from Vehicle? Because it has a tank, burns litres and its cost starts from the petrol one. If it inherited from Vehicle, it would have to repeat Petrol‘s formulas, which is exactly what happened to the classmate at the start.
What would happen if in Hybrid.cost_per_km I wrote self.cost_per_km() instead of super().cost_per_km()? It would call itself over and over: self is a Hybrid, so Python finds the hybrid’s method again. It would end in RecursionError. super() is how you say “the next class in the MRO, not mine”.
Why does __str__ use type(self).__name__? So the same line says Petrol, Electric or Hybrid without writing three __str__ methods. Notice the hybrid shows as Hybrid even though __str__ lives in Vehicle.
And if the price per litre changes? You change LITRE_PRICE at the top and both the petrol cars and the hybrid notice, because the hybrid uses the parent’s formula. That’s the difference with copying it.
Comparing with type(v) == Petrol for “everything that burns petrol”. It leaves the hybrid out. If the question is “is it a…?”, the answer is isinstance.
Try it yourself
- Add
Diesel(Petrol)with a price per litre of 1.55 €. Do you need to override the wholecost_per_kmor is changing one value enough? Think about how to keep the price in a class attribute so the child only changes that. - Make
current_value()ofElectriclose only 6% a year by extending the parent’s (hint: you’ll need the 0.08 to be a class attribute).
—
Program 3: a shop with IGIC
A shop in the Canary Islands sells physical products (with shipping costs by weight), books (which are physical products, but with 0% IGIC), digital licences (with a discount from 5 on) and services by the hour. We need to print each line of the cart with its pre-tax price, the IGIC applied and the total, plus the cart total and a count by kind. What makes it tricky: the tax depends on the kind of product, and the book is “a special physical product” that changes only one thing.
IGIC is the Canary Islands tax that plays the role of VAT; the general rate is 7% and books are zero-rated. The program’s prices and shipping costs are made up.
The idea
Product (abstract) writes the final formula once: price_with_igic() = final_price() × (1 + igic_rate()). Each child answers final_price() its own way. igic_rate() is not abstract: it’s a hook with a default value (7%) that only the book overrides. The same goes for detail(), which returns an empty string by default.
Before coding, think about what each child must decide no matter what (its price: abstract) and what has a normal value that almost nobody changes (the tax: hook). Not everything has to be abstract.
The code
from abc import ABC, abstractmethod
IGIC_GENERAL = 0.07 # general IGIC rate in the Canary Islands
class Product(ABC):
def __init__(self, name, base_price):
self.name = name
self.base_price = base_price
@abstractmethod
def final_price(self): # before tax: each kind computes it
pass
@abstractmethod
def kind(self):
pass
def igic_rate(self): # hook with a default value: it CAN be overridden
return IGIC_GENERAL
def detail(self): # empty hook by default
return ''
def price_with_igic(self): # the parent orchestrates, the children provide the data
return round(self.final_price() * (1 + self.igic_rate()), 2)
def line(self):
extra = f' ({self.detail()})' if self.detail() else ''
return (f'[{self.kind():<8}] {self.name:<24}{self.final_price():8.2f} € '
f'+ {self.igic_rate():.0%} = {self.price_with_igic():8.2f} €{extra}')
class Physical(Product):
def __init__(self, name, base_price, weight_kg):
super().__init__(name, base_price)
self.weight_kg = weight_kg
def shipping(self):
return 4.95 if self.weight_kg < 2 else 7.95
def final_price(self):
return self.base_price + self.shipping()
def kind(self):
return 'Physical'
def detail(self):
return f'{self.weight_kg} kg, shipping {self.shipping():.2f} €'
class PrintedBook(Physical): # in the Canary Islands books have 0% IGIC
def igic_rate(self):
return 0.0
def kind(self):
return 'Book'
class Digital(Product):
def __init__(self, name, base_price, licences=1):
super().__init__(name, base_price)
self.licences = licences
def final_price(self): # 20% off per licence from 5 on
discount = 0.20 if self.licences >= 5 else 0
return self.base_price * self.licences * (1 - discount)
def kind(self):
return 'Digital'
def detail(self):
return f'{self.licences} licences' if self.licences > 1 else ''
class Service(Product):
def __init__(self, name, hourly_rate, hours):
super().__init__(name, hourly_rate)
self.hours = hours
def final_price(self):
return self.base_price * self.hours
def kind(self):
return 'Service'
def detail(self):
return f'{self.hours} h at {self.base_price:.2f} €/h'
cart = [
Physical('Mechanical keyboard', 59.90, 1.1),
PrintedBook('Python for Everybody', 24.00, 0.6),
Physical('24-inch monitor', 139.00, 4.3),
Digital('Antivirus licence', 19.99, 5),
Service('FP2 private lesson', 18.00, 3),
]
for p in cart:
print(p.line())
total = sum(p.price_with_igic() for p in cart)
print(f'Total with IGIC: {total:.2f} €')
by_kind = {}
for p in cart:
by_kind[p.kind()] = by_kind.get(p.kind(), 0) + 1
print('By kind:', by_kind)
print('Physical (books included):', sum(isinstance(p, Physical) for p in cart))
The parent’s template method and hooks:
def igic_rate(self): # hook with a default value: it CAN be overridden
return IGIC_GENERAL
def detail(self): # empty hook by default
return ''
def price_with_igic(self): # the parent orchestrates, the children provide the data
return round(self.final_price() * (1 + self.igic_rate()), 2)
def line(self):
extra = f' ({self.detail()})' if self.detail() else ''
return (f'[{self.kind():<8}] {self.name:<24}{self.final_price():8.2f} € '
f'+ {self.igic_rate():.0%} = {self.price_with_igic():8.2f} €{extra}')
line() calls four things each child decides (kind, final_price, igic_rate, detail), and PrintedBook overrides only two of them in three lines of code: the tax and the kind’s name. It inherits shipping from Physical and the rest from Product. The diagram shows who answers each question for each product:
Output
[Physical] Mechanical keyboard 64.85 € + 7% = 69.39 € (1.1 kg, shipping 4.95 €)
[Book ] Python for Everybody 28.95 € + 0% = 28.95 € (0.6 kg, shipping 4.95 €)
[Physical] 24-inch monitor 146.95 € + 7% = 157.24 € (4.3 kg, shipping 7.95 €)
[Digital ] Antivirus licence 79.96 € + 7% = 85.56 € (5 licences)
[Service ] FP2 private lesson 54.00 € + 7% = 57.78 € (3 h at 18.00 €/h)
Total with IGIC: 398.92 €
By kind: {'Physical': 2, 'Book': 1, 'Digital': 1, 'Service': 1}
Physical (books included): 3
Let’s check the cart:
| Product | final_price() | IGIC | price_with_igic() |
|---|---|---|---|
| Keyboard | 59.90 + 4.95 = 64.85 | 7% | 64.85 × 1.07 = 69.39 |
| Book | 24.00 + 4.95 = 28.95 | 0% | 28.95 |
| Monitor (4.3 kg) | 139.00 + 7.95 = 146.95 | 7% | 157.24 |
| Antivirus (5 licences) | 19.99 × 5 × 0.80 = 79.96 | 7% | 85.56 |
| FP2 lesson (3 h) | 18.00 × 3 = 54.00 | 7% | 57.78 |
And the total, iteration by iteration of the sum:
| Iteration | Product | Running sum |
|---|---|---|
| 1 | Keyboard | 69.39 |
| 2 | Book | 98.34 |
| 3 | Monitor | 255.58 |
| 4 | Antivirus | 341.14 |
| 5 | Lesson | 398.92 |
The count by kind says there are 2 “Physical” and 1 “Book”, but the last line counts 3 physical products: isinstance(p, Physical) is also true for the book, because PrintedBook inherits from Physical. They’re two different questions and both are right: kind() is the label each class chose; isinstance looks at the hierarchy.
Questions you might ask
Why round(…, 2) in price_with_igic and not just when printing? Because the cart total adds the rounded prices, as on a real receipt. If you added the unrounded values, the total could differ by a cent from the sum of the lines the customer sees.
Why does Service store the rate in base_price? To reuse the parent’s __init__: it passes hourly_rate as base_price and stores the hours. The parameter’s name changes in the child (hourly_rate reads better), but the attribute is the same.
Why does detail() have a default value and final_price() doesn’t? Because a product with no detail makes sense (the line just has no brackets), but a product with no price doesn’t. If you forget final_price in a new class, you want the TypeError when creating it.
What would happen if PrintedBook inherited from Product? It would have to repeat Physical‘s weight and shipping. It inherits from Physical because a paper book is a physical product shipped the same way.
Does the {:.0%} format multiply by 100? Yes: 0.07 prints as 7%. It’s an f-string format and has nothing to do with inheritance, but it saves doing the maths by hand.
Putting the IGIC inside each final_price(). Then the book would have to “remove” a tax that’s already there, every child would repeat the same multiplication and changing the general rate would mean touching all of them. The tax goes in one place, in the parent.
Try it yourself
- Add
Magazine(PrintedBook)whose shipping is always 2.95 € (overrideshipping()). What IGIC does it get and why? - Make services of more than 10 hours get a 10% discount by extending
final_price()withsuper()in a classVoucher(Service).
—
Common mistakes in this practice
- Copying the parent’s formula into the child instead of calling it with
super(): when the parent changes, the child is stuck with the old version. - Using
type(x) == Classto ask “is it a…?”: it leaves the children out (the hybrid, the book). Useisinstance. - Forgetting the
returnwhen extending a method (super().payslip()without returning anything): the manager’s payslip would come out asNone. - Calling
self.method()inside the override of that same method: infinite recursion. For the parent’s version,super().method(). - Writing an abstract property as a method in the child:
v.range_kmwould return a function object, not a number. - Storing with two underscores what the children need:
AttributeErrorwith_Child__data. One underscore or a property.
—
Watch it step by step in Python Tutor
pythontutor.com draws the call stack, which is exactly what you need to see super(). Choose Python 3 and paste this reduced version of the hybrid:
class Vehicle:
def __init__(self, model, litres):
self.model = model
self.litres = litres
def cost_km(self):
return self.litres / 100 * 1.65
class Hybrid(Vehicle):
def __init__(self, model, litres, saving):
super().__init__(model, litres)
self.saving = saving
def cost_km(self):
base = super().cost_km()
return base * (1 - self.saving)
h = Hybrid('Yaris', 4.0, 0.35)
print(round(h.cost_km(), 4))
Output:
0.0429
What to watch step by step:
- When the
Hybridis created, the stack has two__init__frames (the hybrid’s and the vehicle’s) with the sameself. - When calling
h.cost_km(), it entersHybrid‘s version. On thesuper().cost_km()line a second frame appears, the one ofVehicle.cost_km, on top of the first. - Look at the second frame’s return value (0.066 on the stack) before it disappears, and how it reaches the
basevariable of the first. - Change
super().cost_km()toself.cost_km()and run it again: you’ll seeHybrid.cost_kmframes pile up until Python Tutor gives up. That’s what infinite recursion looks like.
—
Summary
- The parent writes the shared recipe once (template method:
total_salary,trip_cost,price_with_igic) and the children provide the data with methods they override. - What each child must decide goes as an abstract method or property; what has a normal value, as a hook with a default.
- Extending is
return super().method() …; replacing is writing the method again with the same signature. super()works on several levels:Hybrid → Petrol → Vehicle, and the MRO gives the order.isinstancerespects the hierarchy (the book is a physical product; the hybrid, a petrol car);typedoesn’t.type(self).__name__in the parent gives the name of the object’s real class.
Checklist: have you practised it?
- Have you run the three programs and checked the outputs?
- Can you explain why
Employee.total_salarycan usebonus()when it has no code inEmployee? - Can you predict what
Hybrid.cost_per_kmreturns without running it? - Do you understand why there are 3 physical products but only 2 labelled “Physical”?
- Have you tried the “Try it yourself” tasks without looking at the theory?
- Could you say what happens if a child forgets an abstract method?
If you answered yes to almost everything, you’re ready for the Python inheritance exercises, where I no longer give you the code. See you there!
Next step: exercisesPython 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)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)