Block 1 · Appendix A1
Advanced Classes
- Starter notebook
20-advanced-classes-starter- Fabric path
/lakehouse/default/Files/data/solutions/20.advanced-classes/
- An abstract base class turns the informal car family of Exercise 4 into an enforced contract
- A class method gives every kind of car the same alternative constructor
- Duck typing, a
Protocoland an ABC describe the same interface in three ways, checked at three different moments
You start from the car classes of Exercise 4, including the race car from its closer and without the tank, so every step runs straight through. Open solutions/20.advanced-classes/starter.ipynb in your workspace, or copy solutions/20.advanced-classes/starter/fleet.py into a notebook cell. Run it once : it builds a fleet of three cars and prints what each one costs to run.
Put each step in its own cell, so you can go back and compare. Steps 2 to 5 reuse the classes of Step 1, so work through them in order.
Step 1An abstract Car
A plain Car answers running_cost() with 0.0, which is a made-up number : every real car in the fleet is a fuel car, an electric car or a race car. Make that rule part of the code.
Derive Car from ABC and turn running_cost and describe into abstract methods :
from abc import ABC, abstractmethod
class Car(ABC):
def __init__(self, model: str, plate: str, price: float) -> None:
...
@abstractmethod
def running_cost(self) -> float: ...
@abstractmethod
def describe(self) -> str: ...Run the starter's last line, print(Car("Plain", "GG-44-HH", 1000)), again. What happens now, and at which moment? Wrap the call in try and print the TypeError.
Next give the base class one concrete method that uses the abstract ones, a template method :
def summary(self) -> str:
return f"{self.plate} : {self.describe()}, running cost {self.running_cost():.2f}"Change show_statistics to print car.summary(). No subclass writes summary, yet each one produces its own line.
Finally, add an alternative constructor. Give FuelCar and RaceCar default values for every parameter after price, using the values from Exercise 4 (1.85, 6.5 and 15000 for a fuel car). Then add a class method to Car that reads a line such as "Golf, AA-11-BB, 25000" :
@classmethod
def from_csv_line(cls, line: str) -> "Car":
model, plate, price = (part.strip() for part in line.split(","))
return cls(model, plate, float(price))Call FuelCar.from_csv_line(...) and ElectricCar.from_csv_line(...) and print both results. Which class does each call return, and why is it not Car?
Build the fleet from lines like "Fuel, Golf, AA-11-BB, 25000". Keep a dictionary from the kind to the class, {"Fuel": FuelCar, "Electric": ElectricCar, "Race": RaceCar}, and look the class up before calling from_csv_line.
Step 2Duck typing
Write a function that totals the running cost of anything it is given, and leave its parameter unannotated :
def total_cost(items: list) -> float:
total = 0.0
for item in items:
print(f" adding {item.plate}")
total += item.running_cost()
return totalAdd a Trailer class that is not a Car at all, but has a plate and a running_cost(). Pass a fuel car and a trailer to total_cost. It works, because Python asks only whether the method is there when the line runs.
Now add a Bicycle with a plate and a describe(), but no running_cost(), and pass it in the middle of a list. Study the failure :
- Which error is raised, and on which line?
- Which items were already added before it failed?
Nothing in the source of total_cost records what it needs. Write that contract down in a comment.
Step 3Naming the contract with a Protocol
Declare the same contract as a Protocol, in a file of its own, the way Exercise 2 wrote typing_lab.py :
from typing import Protocol
class Costed(Protocol):
plate: str
def running_cost(self) -> float: ...
def total_cost(items: list[Costed]) -> float:
return sum(item.running_cost() for item in items)A type checker reads files rather than cells, so the classes it checks have to be in files too. Write your Step 1 and Step 2 classes to step1_abstract_car.py and step2_duck_typing.py, and import them into the Protocol file. Keep the call with the Bicycle in it.
Step 4Running the checker
Install mypy into this session and run it on the Protocol file :
%pip install mypyimport subprocess
import sys
report = subprocess.run(
[sys.executable, "-m", "mypy", "step3_protocol.py"], capture_output=True, text=True
)
print(report.stdout or report.stderr)The Bicycle call is now reported before the program ever runs, and the message names the type that does not fit. The Trailer call is accepted, although Trailer never mentions Costed. The checker matches on the shape of the class, not on its base list.
mypy follows the imports, so it also reports the Car(...) call from Step 1. It knows Car is abstract without running anything. Give Bicycle a running_cost method and check that its error goes away.
Step 5The runtime check
Add @runtime_checkable to Costed and check each object with isinstance :
from typing import runtime_checkable
@runtime_checkable
class Costed(Protocol):
def running_cost(self) -> float: ...Print isinstance(item, Costed) and isinstance(item, Car) for a fuel car, a trailer and a bicycle. Which objects are Costed without being a Car?
Use the check to skip what does not fit : total the running cost of only the items that pass isinstance(item, Costed).
Then write a Scooter whose method has the right name and the wrong signature, def running_cost(self, km: float) -> float. Does isinstance(Scooter(), Costed) pass? Call running_cost() on it and explain the result. runtime_checkable tests the member names and nothing else, which is the reason to prefer the static check.
Answer these questions in a comment :
- Does the code that uses the fleet need shared implementation, such as
summary, or only a shape it can rely on? - Do you own every class that has to fit, or might a class from a library need to fit too?
If time permits
- Write a
RecordMixinwith ato_dict()method that returns the kind, the attributes fromvars(self)and the running cost. Mix it into subclasses of your three cars and build a pandas DataFrame from[car.to_dict() for car in fleet]. The same list of dictionaries is whatspark.createDataFrameaccepts. - Write an
OrderedByCostMixinthat defines__lt__on the running cost, mix it in as well, and callsorted(fleet). Print the__mro__of one of the new classes and find the mixins in it. - Make a
Fleetclass derived fromcollections.abc.Sequencethat holds a list of cars. Implement only__len__and__getitem__, then trylen, indexing, slicing,inand aforloop on it.