Card 07/ 09

GotchaDifficulty: Intermediate2 min

isinstance Checks in the Caller as a Liskov Symptom

if isinstance(bird, Penguin) means the substitution is already broken.

A caller that checks which subtype it was given, and treats one specially, is compensating for a subtype that broke its parent's promise. The check looks like careful defensive code. It is actually the visible symptom of a Liskov violation, and this card shows how to read it that way.

The expectation is that checking is safe: if you know penguins cannot fly, skipping them seems responsible. Here is that defensive version, which runs without errors.

The type check, and the design that removes it

With the check

migrate accepts any Bird and skips penguins. It works, so the check is easy to approve.

py
class Bird:
    def __init__(self, name):
        self.name = name

    def fly(self):
        return f"{self.name} flies south"


class Penguin(Bird):
    def fly(self):
        raise NotImplementedError("penguins cannot fly")


def migrate(birds):
    for bird in birds:
        if isinstance(bird, Penguin):
            continue
        print(bird.fly())


migrate([Bird("swallow"), Penguin("penguin")])
OutputRan with python:3.12-slim (Python 3.12.13)
swallow flies south

Without the need

Bird no longer promises fly. Only FlyingBird does, and migrate only accepts flying birds. There is nothing to check.

py
class Bird:
    def __init__(self, name):
        self.name = name

    def eat(self):
        return f"{self.name} eats"


class FlyingBird(Bird):
    def fly(self):
        return f"{self.name} flies south"


class Sparrow(FlyingBird):
    pass


class Ostrich(Bird):
    def run(self):
        return f"{self.name} runs at 70 km/h"


def migrate(birds: list[FlyingBird]):
    for bird in birds:
        print(bird.fly())


def feed(birds: list[Bird]):
    for bird in birds:
        print(bird.eat())


sparrow, ostrich = Sparrow("sparrow"), Ostrich("ostrich")
migrate([sparrow])
feed([sparrow, ostrich])
OutputRan with python:3.12-slim (Python 3.12.13)
sparrow flies south
sparrow eats
ostrich eats

The first version has three problems hidden behind its clean output. Every caller of fly needs the same check, and only the ones you remember will get it. The check names a concrete subclass, so migrate now depends on Penguin, and adding a kiwi means editing migrate. And the parent's promise still says every bird flies, so the next developer to write a caller will believe it.

The check is not always wrong. Code at a boundary, such as a function that turns any value into JSON, often has to ask what it was given. The symptom is a check that singles out one subclass of a hierarchy you own, in code that was written for the parent. When you see isinstance(x, SomeSubclass) or instanceof in that position, look at the hierarchy before approving the check.