The Sad and Happy Faces of Liskov

Fundamentally, the Liskov Substitution Principle (LSP) is concerned with well-behaved sub-types. What does it mean for subtypes to be well-behaved or, more specifically, substitutable?

What even is a subtype?

Subtypes

A child class is a subtype, and an interface implementation is also a subtype.

For example, a supertype Animal is a parent class or interface, and possible subtypes are Dog, Bird, and Cat.

Liskov Recap

The LSP states that when a program is written for Animalin general, say, in a vet program, and an actual, specific Animal instance, e.g. Dog, is used in the program, then nothing unexpected should happen. In other words, the Animal program will run as expected with an instance of Dog. The subtype is Dog, and an instance of Dog can be substituted into this program that only references the Animal.

Substitutability

Okay, so we can useDog (or Bird or Cat) in the Anmal program, and it will run as expected. No problem.

Can we drill down on some properties that subtypes must have that make them suitable for substitution?

Yes, we can.

For a subtype to be substitutable for its supertype,

  • Its behaviour pre-conditions must be more permissive than the supertype, and
  • Its behaviour post-conditions must be more restrictive than its supertype[1].

In simple terms, the subtype methods allow for more varied input than the supertype, while the method return values are more restricted as that expected from the super-type.

An Example

Let’s carry on with our veterinarian software example. The program is written in terms of abstract class Animal, yet allows for specific derived classes: Dog, Cat, Hamster.

The program was originally developed for 4-legged, walking animals. As part of the ‘Healthy Animal Module’ in this program, a daily exercise routine of a 15-minute walk for the animals is recommended. The Animal class has a Walk() method, which is called in this ‘Healthy Animal Module’ for animals that are staying at the vet’s for observation.

Pre-Condition Violation

Now, the veterinarian is getting more customers who are having issues with aquarium fish. Therefore, the program needs to be amended to allow for the inclusion of fish.

Since fish don’t walk, the programmer writing class Fish, subtype to Animal, implements Fish.Walk() like this:

  public WalkStatistics overrides Walk()
  {
     thrown new InvalidOperationException("Fish don't walk!");
  }

 

What will happen when the ‘Healthy Animal Module’ calls the Walk() method, and the animal is an instance of Fish?

That’s right; the program will probably crash.

Why is that?

The programmer inadvertently created a sub-type of Animal that has a more restrictive behavioural precondition than Animal. At least withAnimals other than Fish, we can call Walk(), and the call will succeed. That is not the case here. The call to Walk() is guaranteed to fail as a pre-condition since the program cannot execute Fish.Walk() and succeed—it will throw an exception.

Post-Condition Violation

The programmer has realised that the exception-throwing implementation of Fish.Walk() is problematic and has decided to change the implementation to

  public WalkStatistics overrides Walk()
  {
     return null;
  }

Unfortunately, this new code is problematic too: The programmer did not check out what happens with the return value, an instance of class WalkStatistics. As it happens, the WalkStatistics return value is used to calculate an animal health metric. It’s expected to always be non-null, so now the program blows up for an LSP post-condition violation.

Problem Solved

So, how do we solve the Fish.Walk() conundrum?

The best way to overcome this inconsistency would be not to implement and call Fish.Walk() at all. It doesn’t make sense, and in that way, class Fish is not a suitable subtype candidate for a supertype Animal, that forces children to implement the Walk() method. More likely, type Animal is the wrong name for a base class since not all animals walk.

A program defined in terms of the current walking animals does not work for fish. Class Fish will not end up deriving from class Animal. And that is wrong on another level since a fish is clearly an animal. Unfortunately, the inclusion of Fish in the program’s operation may require a review of the inheritance hierarchy to restore consistency.

 

Conclusion

To overcome Liskov problems, we want our subtypes to be more permissive on inputs and more restrictive on outputs than the supertype.

This diagram compares the pre- and post-conditions for Liskov violations and compliance:

 

And when we turn these red and green triangles into mouths on a smiley face we get sad and happy faces, depending on whether we violate Liskov or not.

The LSP is about us constructing well-behaved, substitutable subtypes into our programs. We will only succeed in this task if we have a subtype with a smiley face: Wide/permissive on input and narrow/restrictive on output.

0 replies

Leave a Reply

Want to join the discussion?
Feel free to contribute!

Leave a Reply