SpiralTrain
Exercises › Block 1 · Appendix A1

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 Protocol and 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 :

python
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 :

python
    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" :

python
    @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 :

python
def total_cost(items: list) -> float:
    total = 0.0
    for item in items:
        print(f"  adding {item.plate}")
        total += item.running_cost()
    return total

Add 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 :

python
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 :

python
%pip install mypy
python
import 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 :

python
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 RecordMixin with a to_dict() method that returns the kind, the attributes from vars(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 what spark.createDataFrame accepts.
  • Write an OrderedByCostMixin that defines __lt__ on the running cost, mix it in as well, and call sorted(fleet). Print the __mro__ of one of the new classes and find the mixins in it.
  • Make a Fleet class derived from collections.abc.Sequence that holds a list of cars. Implement only __len__ and __getitem__, then try len, indexing, slicing, in and a for loop on it.

Tried it yourself first?

The solution is a spoiler. Work through the hints first : a wrong attempt teaches more than a solution you only read.