What Is Liskov Substitution?
Inheritance starts as a cool idea. You have one existing concept and then want to add another. And this new concept is just a more specific implementation of the original. Easy, derive a new class!
But over time, it’s easy to abuse inheritance. And when there are too many layers of inheritance, it’s natural to lose track of what specific classes are supposed to be doing. Eventually, some class gets added that can’t do something its parent classes need to do, and the system breaks.
This is a violation of Liskov substitution, which says:
Child class objects should be able to replace parent class objects without breaking the integrity of the application.
Or, to put this another way:
Any code calling methods on objects of a specific type should continue to work when those objects get replaced with instances of a subtype.
What’s a subtype?
A subtype can be either a class extending another class or a class implementing an interface.
This principle is named after Barbara Liskov, one of the first women in the United States to earn a doctorate in computer science. She is also the creator of the Argus and CLU programming languages.
She introduced the Liskov substitution principle in her conference keynote talk, “Data Abstraction,” in 1987.
Being a computer scientist, she defined this formally as:
Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.
It's very theoretical, so if you’re anything like me, you’ll probably find it easier to think about this principle using the previous definitions!
Why Use the Liskov Substitution Principle?
Let’s say you have a class called cat. It has a method in it called eat()
. You can pass a standard cat food brand - i.e., Kitten Munch - into the eat method, and the cat is fine. 🐈 If you add a new class, Tiger, which is a kind of cat, it will reimplement the eat method. 🐅
However, tigers won't eat dry food that comes from a bag. They eat raw meat. That’s not a behavior you’ve seen before. Surprise! 😬
The problem happens when you think passing Kitten Munch into the eat method works for every cat. A tiger is a cat, so it should work. But it doesn't. So, you can’t just put a tiger in your system in places where you've had a cat.
It violates the Liskov substitution principle: you can't replace a base class (Cat), with a derived class (Tiger), without affecting the rest of the system.
The Liskov substitution principle helps by limiting your use of inheritance. While it is easy to come up with low-level, concrete implementation classes (hey, they do what you want them to do), it is better to step back and think of a high-level abstraction.
For example, let's try to fix the cat and tiger problem. You have two concrete (that is, specific implementation) classes. You need to introduce a couple of higher-level interfaces/abstractions:
Carnivore, which only eats meat.
Omnivore, which eats meat and other things (like Kitten Munch).
Now when you add any animal, you can have it implement one of those two interfaces.
It made sense at the start to have a tiger be a kind of cat. But then you saw the problem when you got to implementation. It’s often hard to recognize when Liskov is going to be violated ahead of time. But when you discover a violation, you need to rethink your use of inheritance. So, when looking to use inheritance, ask yourself:
Does the derived class have a meaningful implementation for all overridden methods? If so, that's a good thing. ✅
Would implementing an overridden method be out of the ordinary, possibly resulting in throwing an exception? If so, that's bad. ❌
Would implementing an overridden method ignore the call, and do nothing? Usually, that's a bad thing, but might be justifiable. You should keep an eye out if the derived class does this for:
A single method. ✅
Many of them. ❌
How Do We Apply Liskov Substitution to Our Code?
In our card game, View is responsible for collecting necessary responses from the user, like the player names, and presenting the game's progress.
Imagine if we wanted to broadcast our exciting card game to a worldwide audience. We could create a subtype of View, perhaps called BroadcastView, which does extra things for the broadcast like displaying the local time and adding commentary. Because we're using MVC, it should be easy to replace our View object with a BroadcastView object.
Take a look over the current View class and see whether you can spot the Liskov-related problem this would cause.
class View:
def prompt_for_new_player(self):
# code
def show_player_and_hand(self, player_name, hand):
# code
def prompt_for_flip_cards(self):
# code
def show_winner(self, winner_name):
# code
def prompt_for_new_game(self):
# code
The problem is that BroadcastView can’t accept instructions from the worldwide audience on what the players’ names should be, when to flip the cards, or whether to play another game. Only the players should be given these options!
BroadcastView doesn’t obey Liskov substitution, as it can’t do some of the things that its parent class—View—implements. The class hierarchy is wrong.
So what can we do instead?
Whenever we want a view component for broadcasting, we’ll need another component that interacts with the players. So we could create a class called PlayersAndBroadcastView that handles everything.
But this contradicts one of the other SOLID principles - can you see which one?
That’s right - it’s the single responsibility principle! PlayersAndBroadcastView is presenting to the audience and the players. It would be better to keep these separate in two classes.
Once you’ve seen the remaining SOLID principles, we’ll implement an excellent solution for handling collections of views while respecting all of these principles.
Let’s Recap!
Liskov substitution principle applies to inheritance hierarchies. It is violated when a derived class cannot take the place of a base class without the system breaking.
To make sure you avoid violating this rule, try to first think of high-level abstractions/interfaces instead of low-level/concrete implementations.
In the next chapter, we'll look at the value of keeping interfaces small with the interface-segregation principle.