Liskov’s substitution is one of those topics that are self-evident once you get it. However, explaining it can be tricky since the concept itself relies on other ideas. Besides, there are different aspects that you can deepen on, so you can always find something new to learn about it. Anyway, let’s talk about the main ideas and let the minutiae for another day.
The piano metaphor
I’ll assume that everyone knows what is a piano. If you have seen a piano, you will recognize it even if it is grand piano a digital piano, or even a virtual piano! Likewise, if you know how to play the piano, you can play any kind of piano as long as it behaves as such. Let’s break this down.
The shape
How is it that once you have seen a piano, you have seen them all? I mean, a digital piano is made in a very different way than a grand piano, how come you can identify both? Well, turns out all the pianos in the world share 2 kinds of keys (naturals & flats/sharps) that are set up in a very specific order. No matter what, if you see these keys arranged in that way, you know is a piano. Even if you can’t see under the hood!
The behavior
The reason a piano player can play any piano, is because he knows that a key in a given position will produce a note in a given tone. Since this is the same in every piano, he can confidently play a piece in any kind of piano. There may be some differences in the strength needed to hit the key, but that is irrelevant. The fact that the same key on any piano will give the same tone is a warranty.
The contract metaphor
Bertrand Mayer lay the ideas upon which the Liskov’s substitution was built. The main one is the idea of a contract. It basically says that 2 pieces of code can collaborate safely by doing it through a contract. The contract specifies what services are available, what is required to make use of them (pre-conditions), and what can you expect from them (post-conditions). As you will soon see, this idea of contract has 2 different instances in OOP: at the class level and at the method level.
Class contracts
To better understand Liskov’s substitution principle, you have to understand the idea of contracts, applied to classes.
Simply put is a contract around the “shape”: it specifies the services provided by objects of a certain class. Back to the piano analogy, it states that all pianos have keys in a given order, no matter the size, color, or shape of the body. In code, this kind of contract is expressed as interfaces, classes, or abstract classes.
Another aspect of a class contract it’s around its state: Invariants. Invariants are the rules that must be followed by all instances of the class to be valid, i.e. an hour object cannot have more than 60 minutes and not less than 0.
Method contracts
On the other hand, a contract applied to a method is a contract around “behavior”. It states that not only shape is important for a piano to be a piano: whenever you hit the C key, it has to make a sound on the C tone. The tricky part is that in most languages there are no artifacts (like interfaces) that enforce this kind of contract. Even worse, we ourselves are very lousy at defining this kind of contract.
Imagine the following code:
int Add2(int plus){...}
What would you expect of the following expression?
int result = Add2(plus: 3);
What would you think if the result was anything other than 5 or if it threw an exception? It makes no sense right? The function says that is going to add 2 (name) to an integer (parameter) and return the resulting integer (return type). So given we provide it with a valid value (pre-condition), we would expect a valid value in return (post-condition). There is more to say about this, but I’ll stop here.
The Substitution principle vs the Liskov’s substitution principle
A common confusion when trying to understand Liskov’s substitution principle is with regards to the substitution principle. Let’s fix that.
The substitution principle basically states that a class contract must be respected by it’s subclasses.
The Liskov’s substitution principle states that the invariants and methods’ contracts in a class must be respected by it’s subclasses.
Let’s look at the following example:
public class Piano { protected Dictionary<string, Action> Keys; public Piano() { Keys = new Dictionary<string, Action> { {"C", makeSound(tone:"C")}, {"C#", makeSound(tone:"C#")}, {"D", makeSound(tone:"D")}, {"D#", makeSound(tone:"D#")}, {"E", makeSound(tone:"E")}, {"F", makeSound(tone:"F")}, {"F#", makeSound(tone:"F#")}, {"G", makeSound(tone:"G")}, {"G#", makeSound(tone:"G#")}, {"A", makeSound(tone:"A")}, {"A#", makeSound(tone:"A#")}, {"B", makeSound(tone:"B")} }; } public void Play(string key) => Keys[key].Invoke(); protected Action makeSound(string tone) { //do some magic } }
So the class contract on this Piano class says that there is a Play method. The pre-conditions are to receive a valid key, and the post-condition is that some sound is made. Now check this class:
public class BadPiano : Piano { public BadPiano() { Keys["C#"] = () => throw new NotImplementedException(); Keys["A"] = makeSound("E"); } }
This class violates the Play method contract. If we try to play the tone C# is going to throw an exception and if we try to play the tone “A” it’s going to play “E” instead, whereas its parent won’t. Get it? is not just that the behavior is different than its parent, it’s actually out of the expectations of the client. Using this would at least produce some weird interpretation and at most, it will blow the whole program. Again there’s more to say here but I’ll refrain.
Closing thoughts
I hope this helps you grasp the ideas behind Liskov’s substitution principle. While there’s more to be said about the topic, I wanted this to be an introduction. If you’re interested in learning more, let me know in the comments and I will try to follow up later. Enjoy!