I'll Be Backend
Principles · Python

Liskov Substitution: fix a subtype that breaks callers

Make every subtype work wherever its base type already works.

13 steps4 chapters~5 min at 1.25×#solid#liskov#subtypes#contracts
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Python video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–5

Refactor

Steps 6–9

Payoff

Steps 10–11

Recap

Steps 12–13

The code: before and after

Before

class PaymentMethod(ABC):
    @abstractmethod
    def charge(self, amount: int) -> None: ...

    @abstractmethod
    def refund(self, amount: int) -> None: ...


class Card(PaymentMethod):
    def charge(self, amount: int) -> None:
        print(f"card charged {amount}")

    def refund(self, amount: int) -> None:
        print(f"card refunded {amount}")


class Checkout:
    def pay(self, method: PaymentMethod, amount: int) -> None:
        method.charge(amount)


class Returns:
    def give_back(self, method: PaymentMethod, amount: int) -> None:
        method.refund(amount)


if __name__ == "__main__":
    card = Card()
    Checkout().pay(card, 40)
    Returns().give_back(card, 40)

After

class Refundable(ABC):
    @abstractmethod
    def refund(self, amount: int) -> None: ...


class PaymentMethod(ABC):
    @abstractmethod
    def charge(self, amount: int) -> None: ...


class Card(PaymentMethod, Refundable):
    def charge(self, amount: int) -> None:
        print(f"card charged {amount}")

    def refund(self, amount: int) -> None:
        print(f"card refunded {amount}")


class GiftCard(PaymentMethod):
    def charge(self, amount: int) -> None:
        print(f"gift card charged {amount}")


class Checkout:
    def pay(self, method: PaymentMethod, amount: int) -> None:
        method.charge(amount)


class Returns:
    def give_back(self, method: Refundable, amount: int) -> None:
        method.refund(amount)


if __name__ == "__main__":
    card = Card()
    Checkout().pay(card, 40)
    Returns().give_back(card, 40)
    gift = GiftCard()
    Checkout().pay(gift, 25)
    # Returns().give_back(gift, 25)  # mypy: incompatible type

Transcript, step by step

Problem

Step 1: A shop that takes cards Before

Payment Method is abstract, and every way to pay inherits from it, so it's the base type. It promises its callers a charge method and a refund method, and that promise is its contract. Card is the one way the shop has to pay, and it overrides both methods. Checkout and Returns are the callers, and they only know that base type. The main block pays forty dollars with the card and gives it back, so it prints card charged and card refunded. Every payment so far could be charged and refunded, so putting both methods in the base type made sense.

Step 2: Finance adds gift cards Code smell

Finance adds gift cards, and a gift card can pay, but its money is never refunded. The quick way adds Gift Card as a subtype of Payment Method, and its charge prints gift card charged. The base type asks for a refund too, so this one raises Not Implemented Error. The main block pays twenty five with a gift card, then gives it back. Mypy accepts every line, because a Gift Card is a Payment Method. So what happens when this last line runs? It crashes, because give back calls refund, and refund raises.

Step 3: Returns checks the class Code smell

To stop the crash, you'd put a type check at the top of give back. If the method is a Gift Card, it prints gift card no refund and returns early. So the program runs to the end again, with two gift card lines in its output.

Step 4: A subtype that breaks its contract Code smell

The contract includes refund here, the gift card breaks it here, and Returns now needs a type check here. Returns was written for any Payment Method, but now it has to know Gift Card by name. And the base type still says refund is safe to call on any payment, which is no longer true.

Step 5: Liskov Substitution Principle

So code that uses a base type must work with any subtype, and that rule is the Liskov Substitution Principle. Gift Card breaks it, and the type check in Returns is the symptom.

Refactor

Step 6: Refactor first Back it out

So let's take the gift card and the type check out again. We'll refactor first, so the contract fits every payment, and then add gift cards back.

Step 7: Name the refund Extract Interface

First we give refund a type of its own, an abstract class called Refundable with that one method. Card inherits from it as well, and its refund method already matches, so nothing inside Card changes. Payment Method still declares refund, and the next two moves fix that.

Step 8: Returns asks for Refundable Change Function Declaration

Next, give back takes a Refundable instead of a Payment Method, because refund is the only thing it calls. The main block still passes the card, and a card is a Refundable, so it prints the same two lines.

Step 9: Refund leaves the base type Push Down Method

Now no caller needs refund from Payment Method, so it moves down out of the base type. The contract of Payment Method is charge only, and refund lives in Refundable, which Card inherits from.

Payoff

Step 10: Gift cards, again Payoff

So now gift cards can come back in. Gift Card inherits from Payment Method with charge only, so there's no refund method and nothing to raise. The main block pays twenty five with it, and the program prints gift card charged twenty five. Mypy flags the give back line from before, because a Gift Card isn't a Refundable. Python doesn't check that hint at run time, so the call would fail with an Attribute Error when give back calls refund.

Step 11: Every subtype keeps the contract Payoff

Card, Checkout and Returns did not change to add gift cards. The contract has no refund here, the gift card has nothing to break here, and Returns has no type check here. Gift Card can stand in for Payment Method everywhere, because the contract holds only what every payment can do. But the split has a cost: code holding only a Payment Method can't refund it, so it must ask for a Refundable.

Recap

Step 12: Where we started, where we ended Before / after

On the left, Payment Method promised refund to every payment it would ever have. On the right, Payment Method promises charge only, and refund has its own abstract class. Gift Card keeps the whole contract it inherits, and Returns asks only for something it can refund.

Step 13: When to reach for it Takeaways

Reach for it when a subtype overrides a method only to raise, or when its callers need a special case for it. Skip it when the subtype can keep the contract, like gift cards that pay back in store credit. You'll spot it by a caller with a type check, or an override that raises Not Implemented Error for good. Python's collections A B C module splits Sequence from Mutable Sequence, so read-only code asks for a Sequence, without append.