Concepts / Object-oriented foundations / Abstraction and interfaces

Abstraction and interfaces

Describing what an object does, not how, so callers depend on a promise.

01The intuition

The problem it solves

You are writing a program that sends a message to a student when their library book is due. The first version sends an email, so the reminder code calls the email class directly.

class EmailSender:
    def send_email(self, address: str, subject: str, body: str) -> None:
        print("email to", address)

class DueReminder:
    def remind(self, student) -> None:
        sender = EmailSender()
        sender.send_email(student.email, "Book due", "Please return it")

A month later the school wants SMS as well, and some students want both. Now DueReminder needs an if-else on the student's preference, and it must know how to call an SMS class, which has different method names and different arguments. Every new channel means editing DueReminder, and testing it means really sending email, because the sender is built inside the method.

The deeper problem is that DueReminder depends on how messages are sent. It only needs to know that a message can be sent. Describing that promise on its own, separately from any one way of keeping it, is abstraction, and the written-down promise is an interface.

Build it one piece at a time

Step 1: write the promise as a class with no body. The reminder needs one ability: deliver some text to a student. Name it and give it one method.

from abc import ABC, abstractmethod

class Notifier(ABC):
    @abstractmethod
    def notify(self, student, text: str) -> None:
        """Deliver text to the student. Raise if it cannot be delivered."""

Step 2: make the existing email code keep that promise. The email class gains a notify method that translates the promise into its own vocabulary.

class EmailNotifier(Notifier):
    def notify(self, student, text: str) -> None:
        print("email to", student.email, ":", text)

class SmsNotifier(Notifier):
    def notify(self, student, text: str) -> None:
        print("sms to", student.phone, ":", text)

Step 3: let the reminder depend on the promise only. It receives a Notifier from outside and never says the word "email" again.

class DueReminder:
    def __init__(self, notifier: Notifier):
        self._notifier = notifier

    def remind(self, student) -> None:
        self._notifier.notify(student, "Your book is due")

Step 4: compose promises out of promises. "Both channels" is itself a notifier, so the reminder still has one dependency.

class MultiNotifier(Notifier):
    def __init__(self, parts: list[Notifier]):
        self._parts = parts

    def notify(self, student, text: str) -> None:
        for part in self._parts:
            part.notify(student, text)

reminder = DueReminder(MultiNotifier([EmailNotifier(), SmsNotifier()]))
StepWhat DueReminder knows aboutAdding SMS meansTesting needs
StartEmailSender and its method namesediting DueRemindera real email
1 and 2still EmailSendera new class onlya real email
3the Notifier promisea new class and one line of wiringa fake Notifier
4the Notifier promisenothing in the remindera fake Notifier

Why it works

An interface splits a program into two sides. The caller side knows the method names, the argument types and the meaning of the result, and nothing else. The implementer side can do anything it likes as long as it keeps that meaning. The invariant is the contract written in the docstring: "deliver text, or raise". As long as both sides respect the contract, either side can change without the other hearing about it.

This is also what makes tests possible. A FakeNotifier that records calls in a list satisfies the contract, so DueReminder can be tested in a millisecond with no network. In an interview, naming the fake is often the sentence that shows you understand why the interface is there.

Where it breaks: an interface that leaks its implementation. If Notifier.notify took an smtp_server argument, the SMS class would have to accept and ignore it, and the promise would be a lie. Also watch the fat interface: a Notifier with twelve methods, most of which only email needs, forces every implementation to fake the rest. Keep each promise small.

The trade-off you must say out loud

An interface is an extra file and an extra name to learn, and an interface with exactly one implementation that will never have a second is ceremony. The honest rule: introduce the abstraction when you have two implementations, or when the one you have must be swapped for a fake in tests. Expect the follow-up "which parts of your design are interfaces and why", and answer by pointing at the places that vary or that you need to fake.

Where it shows up in the 100 Google problems

02Used in these problems