Card 04/ 09

WalkthroughDifficulty: Intermediate3 min

Refactoring to Liskov Substitution, Step by Step

Split the abstraction so no bird is promised a wing it does not have.

A Liskov violation is fixed by changing the abstraction, not the subclass. If a parent promises something that some of its children cannot do, the promise is in the wrong class. This card walks through moving fly out of Bird into a FlyingBird class that only flying birds extend, so that the caller can no longer be handed an ostrich and the type check disappears.

Two tempting fixes do not work, and it helps to see why. Making Ostrich.fly do nothing silently breaks the postcondition: migrate reports success for a bird that did not move. Raising a clearer error is still raising an error, so every caller is still broken. Both fixes change the subclass, and the problem is in the parent.

Moving fly out of Bird

List what every bird can do

Ask what is true of every subclass, including the awkward ones. Every bird eats. Not every bird flies. Only the first belongs in Bird.

Introduce the narrower abstraction

Create FlyingBird, a subclass of Bird, and move fly into it. Bird keeps eat and the name.

Re-parent the subclasses

Sparrow now extends FlyingBird. Ostrich extends Bird directly, and its overriding fly is deleted. Give it what it can do instead, such as run.

Narrow the callers

Change migrate to accept FlyingBird, since flying is what it needs. Code that only feeds birds keeps accepting Bird. Delete any type check that skipped ostriches; it can no longer be reached.

The bird hierarchy before and after the split

In the final code below, migrate and feed each ask for exactly what they use, and every bird passed to either one can do it.

Birds after the split, with migrate and feed

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
java
import java.util.List;

public class LspBirdsFixed {
    static void migrate(List<? extends FlyingBird> birds) {
        for (FlyingBird bird : birds) {
            System.out.println(bird.fly());
        }
    }

    static void feed(List<? extends Bird> birds) {
        for (Bird bird : birds) {
            System.out.println(bird.eat());
        }
    }

    public static void main(String[] args) {
        Sparrow sparrow = new Sparrow();
        Ostrich ostrich = new Ostrich();
        migrate(List.of(sparrow));
        feed(List.of(sparrow, ostrich));
    }
}

class Bird {
    String eat() {
        return getClass().getSimpleName() + " eats";
    }
}

class FlyingBird extends Bird {
    String fly() {
        return getClass().getSimpleName() + " flies south";
    }
}

class Sparrow extends FlyingBird {}

class Ostrich extends Bird {
    String run() {
        return "Ostrich runs at 70 km/h";
    }
}
OutputRan with eclipse-temurin:21-jdk (Java 21.0.12)
Sparrow flies south
Sparrow eats
Ostrich eats
ts
class Bird {
  constructor(readonly name: string) {}

  eat(): string {
    return `${this.name} eats`;
  }
}

class FlyingBird extends Bird {
  fly(): string {
    return `${this.name} flies south`;
  }
}

class Sparrow extends FlyingBird {}

class Ostrich extends Bird {
  run(): string {
    return `${this.name} runs at 70 km/h`;
  }
}

function migrate(birds: FlyingBird[]): void {
  for (const bird of birds) console.log(bird.fly());
}

function feed(birds: Bird[]): void {
  for (const bird of birds) console.log(bird.eat());
}

const sparrow = new Sparrow("sparrow");
const ostrich = new Ostrich("ostrich");
migrate([sparrow]);
feed([sparrow, ostrich]);
OutputRan with node:22-slim (Node 22.23.1), TypeScript 5.9.3
sparrow flies south
sparrow eats
ostrich eats

No error is raised and no type is checked. The mistake that remains possible, passing an ostrich to migrate, is now caught by the Java and TypeScript compilers and by a Python type checker, before the program runs. The design moved a run-time crash into a type error, which is the best outcome a refactoring of this kind can have.