Card 04/ 09
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.
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
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])sparrow flies south
sparrow eats
ostrich eatsimport 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";
}
}Sparrow flies south
Sparrow eats
Ostrich eatsclass 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]);sparrow flies south
sparrow eats
ostrich eatsNo 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.
← → move through sections and cards