Skip to main content

Liskov substitution principle

When extending a class, you should be able to pass objects of the subclass wherever the parent was expected, without breaking the client code.

Most design principles are open to interpretation and taste. This one comes with a checklist, which makes it the easiest of the five to actually verify - and the one whose violations hurt most, because they only show up at runtime, in somebody else's code.

That "somebody else" is the point. The principle matters most when you are writing libraries and frameworks, where your subclasses end up inside programs you cannot see, let alone fix.

Formal statement

Barbara Liskov's original formulation is stated in terms of a proof obligation: let q(x) be a property provable about objects x of type T. Then q(y) should be provable for objects y of type S, where S is a subtype of T. Translated out of proof-theory language, a subtype has to honor every contract the base type made, phrased as three families of condition:

  • Preconditions cannot be strengthened. If the base method accepts any int, the override cannot start rejecting negatives - every caller who has been passing negatives for two years is now broken by code they never touched.
  • Postconditions cannot be weakened. If the base method guarantees the connection is closed when it returns, the override cannot leave it open "for performance," because the caller's cleanup logic was written trusting that guarantee.
  • Invariants must be preserved. Whatever must always be true of an object for it to make sense - a bird's ability to eat, a document's filename never being empty - has to remain true across the entire hierarchy, not just at the base type.

Everything else people quote about Liskov - matching or widening parameter types, matching or narrowing return types, never throwing a new exception type - is a specific, checkable instance of one of these three conditions applied to a statically typed method signature.

The checklist

A subclass stays substitutable when its overrides obey all of these:

  • Parameter types match or get more abstract. The base declares feed(Cat c). Overriding it as feed(Animal c) is fine, because callers still pass cats and cats are animals. Overriding it as feed(BengalCat c) is not, because the client's ordinary cat no longer fits.
  • Return types match or get more specific. The inverse rule. buyCat(): Cat may be narrowed to buyCat(): BengalCat, because the caller wanted a cat and got one. Widening it to buyCat(): Animal breaks code that was built around cat-shaped results. In dynamically typed languages the equivalent sin is returning a number where the base returned a string.
  • No new exception types. Client code catches what the base method is documented to throw. An override that raises something unrelated slips straight past the try/catch and takes the process with it.
  • No stronger pre-conditions. If the base accepts any int and your override rejects negatives, code that has been passing negative values for two years starts throwing.
  • No weaker post-conditions. If the base method always closed its database connections, your connection-reusing override leaves callers - who terminate right after the call because they trusted the contract - leaking connections into the void.
  • Invariants are preserved. The conditions under which an object makes sense at all. Cats have four legs and a tail; your subclass does not get to remove one. This is the rule most often broken by accident, because invariants live partly in interface contracts, partly in assertions, and partly in unit tests nobody reread.
  • Private state stays private. Reflection, and languages with no real access control, make it possible to reach into a superclass's private fields. Possible is not permission.

Statically typed languages such as Java and C# enforce the first three at compile time, which means the interesting failures are almost always in the last four.

The example

A Document class can be opened and saved. Somebody adds ReadOnlyDocument, and since saving makes no sense there, the override throws. Reasonable-looking, and wrong: the client code now has to check the concrete type before saving anything, which drags it into knowing about document subclasses and quietly breaks the open/closed principle too. Add another document type and the client changes again.

The fix is not a smarter override, it is a redesigned hierarchy. A subclass should add to the base behavior, so make the read-only document the base class and let a writable document extend it with the ability to save. Now the type system says exactly what is true, and the type check disappears.

// The hierarchy says every document can be saved.
// One subclass disagrees, at runtime, by exploding.
class Document is
field data: string
field filename: string
 
method open() is
// load data from disk
 
method save() is
// write data back to disk
 
class ReadOnlyDocument extends Document is
method save() is
throw new Error("Unable to save a read-only file.")
 
// Client code now has to know the concrete type,
// which is the coupling we were trying to avoid.
class Project is
field documents: list of Document
 
method openAll() is
foreach doc in documents
doc.open()
 
method saveAll() is
foreach doc in documents
// a type check, in code that should not care about types
if (doc is not ReadOnlyDocument)
doc.save()
Document- data- filename+ open()+ save()ReadOnlyDocument...+ save()Project- documents+ openAll()+ saveAll()foreach (doc in documents) doc.open()foreach (doc in documents) if (!(doc is ReadOnlyDocument)) doc.save()throw new Exception("cannot save a read-only document")
extendscomposition
BEFORE: the subclass cancels a promise the base class made.
Document- data- filename+ open()WritableDocument...+ save()Project- allDocs- writableDocs+ openAll()+ saveAll()foreach (doc in allDocs) doc.open()foreach (doc in writableDocs) doc.save()
extendscomposition
AFTER: the base class promises less, so nobody has to take it back.

A second example

The other canonical case: Bird declares fly(), Sparrow inherits it happily, and Ostrich overrides it to throw, because ostriches cannot fly and the hierarchy never asked their opinion. Aviary.releaseAll() now needs an instanceof check before calling fly() on anything, which is exactly the symptom from the document example, in feathers.

The fix is the same shape too: flight was never a property of "being a bird" in the first place, so it should never have been on Bird. Move it to a FlyingBird capability that only the birds which actually fly implement, and Aviary iterates the list that is honest about what it contains.

// Every bird can fly. Except the ones that can't.
class Bird is
method fly() is
// flap wings, gain altitude
 
class Sparrow extends Bird is
// inherits fly() as-is
 
class Ostrich extends Bird is
method fly() is
throw new Error("Ostriches cannot fly.")
 
// Anything iterating a list of Bird now has to know
// which concrete birds are secretly liars.
class Aviary is
field birds: list of Bird
 
method releaseAll() is
foreach bird in birds
if (bird is not Ostrich)
bird.fly()

The smell

Two reliable tells, both visible without reading a single postcondition:

  • A subclass overrides a method to throw UnsupportedOperationException (or your language's equivalent). That override is not implementing the base behavior, it is refusing it - the base class promised something this subclass cannot deliver.
  • A caller does an instanceof (or is not ReadOnlyDocument) check before calling a method the interface already declares. If polymorphism worked, that check would be unnecessary. Its presence means some concrete type in the hierarchy is not actually substitutable.

Patterns that lean on it

Template Method depends entirely on every subclass honoring the base algorithm's contract - a subclass that skips a step or reverses an invariant the template relies on breaks every caller of the template, not just its own callers. The Null Object pattern exists specifically to avoid the failure mode above: instead of a subclass throwing or returning null where a real implementation was expected, a null object implements the same interface with a harmless no-op, so every caller stays substitutable and nobody needs an instanceof guard.

Where it goes wrong

There is no "overkill" version of this one in the way there is for the other principles - substitutability is not an optional flourish. What does go wrong is over-application:

  • Not every awkward subclass needs the hierarchy inverted. Sometimes the honest answer is that these two things were never in an "is a" relationship, and composition dissolves the problem entirely.
  • Do not contort a design to preserve an invariant nobody depends on. The safest way to extend a class is to add fields and methods and touch nothing existing, but "safest" is not always "possible", and a heroic hierarchy built to satisfy a rule literally can be worse than the violation.
  • Beware the rectangle-and-square rabbit hole. Long arguments about whether a square is a rectangle are usually a sign that the model, not the principle, needs the attention.

The practical test costs nothing: take every place the superclass is used, imagine your subclass there instead, and ask whether anything gets surprised. The honest cost of chasing substitutability past the point it is needed is a hierarchy redesigned around a case that never actually occurs in the calling code - solving a problem the client base does not have.

Try it yourself: Rework the Document/ReadOnlyDocument hierarchy so ReadOnlyDocument no longer overrides save() with a thrown exception, then rewrite Project.saveAll() so it no longer needs a type check against ReadOnlyDocument before calling it.

Check yourself

Question 1 of 4

A superclass method is feed(Cat c). Which override keeps the subclass substitutable?