Card 02/ 09
Spotting a Liskov Violation: The Ostrich That Cannot Fly
fly() raises an error in one subclass, and every caller now needs a type check.
A Liskov violation shows up as a subclass method that refuses to do what its parent promised, usually by raising an error or doing nothing. This card shows the bird hierarchy in Python, Java and TypeScript, with a migrate function written for any bird, and the error each language prints when it reaches the ostrich.
An Ostrich that refuses its parent's fly method
class Bird:
def __init__(self, name):
self.name = name
def fly(self):
return f"{self.name} flies south"
class Sparrow(Bird):
pass
class Ostrich(Bird):
def fly(self):
raise NotImplementedError("ostriches cannot fly")
def migrate(birds):
for bird in birds:
print(bird.fly())
try:
migrate([Sparrow("sparrow"), Ostrich("ostrich")])
except NotImplementedError as error:
print(f"NotImplementedError: {error}")sparrow flies south
NotImplementedError: ostriches cannot flyimport java.util.List;
public class LspBirds {
static void migrate(List<Bird> birds) {
for (Bird bird : birds) {
System.out.println(bird.fly());
}
}
public static void main(String[] args) {
try {
migrate(List.of(new Sparrow(), new Ostrich()));
} catch (UnsupportedOperationException error) {
System.out.println("UnsupportedOperationException: " + error.getMessage());
}
}
}
class Bird {
String fly() {
return getClass().getSimpleName() + " flies south";
}
}
class Sparrow extends Bird {}
class Ostrich extends Bird {
@Override
String fly() {
throw new UnsupportedOperationException("ostriches cannot fly");
}
}Sparrow flies south
UnsupportedOperationException: ostriches cannot flyclass Bird {
constructor(readonly name: string) {}
fly(): string {
return `${this.name} flies south`;
}
}
class Sparrow extends Bird {}
class Ostrich extends Bird {
fly(): string {
throw new Error("ostriches cannot fly");
}
}
function migrate(birds: Bird[]): void {
for (const bird of birds) {
console.log(bird.fly());
}
}
try {
migrate([new Sparrow("sparrow"), new Ostrich("ostrich")]);
} catch (error) {
console.log(`Error: ${(error as Error).message}`);
}sparrow flies south
Error: ostriches cannot flyIn each language the sparrow migrates and the ostrich throws. The compilers had no objection: Java checked that Ostrich.fly returns a String, and TypeScript checked the same. Both are true. The method exists, has the right signature, and breaks the promise anyway.
Look at what the caller has to do next. The listing catches the error around the whole migration, so one ostrich stops every bird after it. To make migrate work, someone will add a check: skip the bird if it is an ostrich. Then a penguin is added and the check grows. Every function that calls fly needs the same check, and each one must be found and edited whenever a new flightless bird appears.
That is the cost of a substitution failure. The subclass saved its author one design decision, and it charged every caller instead. Notice too that the check is an open/closed violation: a type conditional that grows with each new case. A broken substitution often creates one.
← → move through sections and cards