Skip to content

← The Type System as a Design Tool step 19 of 24

Easy Primitives

@override and the notifier that stopped notifying

Rename Base.handle to Base.process. Forget one subclass. Now Child.handle is dead code that no test exercises, because every call goes through the base class’s implementation instead — and the base class’s implementation is plausible, so nothing crashes. It just quietly stops doing the thing the subclass was written to do.

This is one of the highest-frequency, lowest-visibility refactoring bugs in object-oriented Python, and @override (PEP 698, Python 3.12) costs one line to eliminate:

class EmailNotifier(Notifier):
    @override
    def deliver(self, message: str) -> bool: ...

The checker now verifies that a method of that name exists on some base. Rename the base method and Child.deliver becomes an error at the definition site, naming the method that no longer overrides anything.

Two things to know:

  • explicit-override is not in --strict. Plain @override catches the method that claims to override and does not. The reverse direction — a method that overrides without saying so — needs --enable-error-code explicit-override, and that is what this problem is graded with. Turning it on in an existing codebase produces a large, boring, entirely mechanical diff, and afterwards every override in the repo is machine-checked.
  • Decorator ordering matters. @override goes innermost, closest to the def, below @property, @staticmethod, @functools.cache and friends. Above them it decorates the wrong object.

The task

class Notifier(ABC):
    @abstractmethod
    def deliver(self, message: str) -> bool: ...

class EmailNotifier(Notifier): ...   # delivers when 0 < len(message) <= 100
class SmsNotifier(Notifier): ...     # delivers when 0 < len(message) <= 20

def dispatch(notifiers: Sequence[Notifier], message: str) -> list[bool]: ...
def solve(kinds: list[str], message: str) -> tuple[list[bool], int, list[str]]: ...

Both subclasses carry @override. dispatch returns one result per notifier, in order.

solve maps the strings "email" and "sms" to instances, in order; any other string is skipped and recorded. It returns (dispatch results, number of successful deliveries, skipped kinds).

The length bounds are inclusive on the upper side and exclusive of the empty string: a 20-character SMS delivers, a 21-character one does not, and an empty message delivers nowhere.

ABC or Protocol?

Notifier is an ABC here, deliberately, because the two implementations are ours and inheritance is documenting a deliberate hierarchy. Use an ABC when you own every implementation and want a shared base, abstractmethod‘s instantiation guard, and shared helper methods. Use a Protocol when the implementations are somebody else’s and demanding they subclass you would be viral. That is the T2 story; the relevant part here is that @override applies to both.

Worth knowing while you are here: an explicit Protocol subclass that omits a member is a mypy [abstract] error but instantiates fine at runtime — the runtime guard you get from ABC is not something Protocol gives you.

Types

dispatch takes Sequence[Notifier] rather than list[Notifier] — it only iterates and indexes, so accepting a tuple costs nothing. See the variance problems for why that matters more than it looks.

sum(results) on a list[bool] is an int, because bool is an int subclass. That is normally the trap; here it is the feature.