This is the third post of a series dedicated to the use of SOLID principles at Codavel. As you remember, we're showcasing SOLID principles in the light of a “make it work, make it right, make it fast” philosophy. So, take this post with a little grain of salt.
Back to the five SOLID principles. We have:
- Single responsibility principle (SRP)
- Open / closed principle (OCP)
- Liskov substitution principle (LSP)
- Interface segregation principle (ISP)
- Dependency inversion principle (DIP)
We’ve already discussed the first and second principles, so let’s move to the third one: the Liskov substitution principle (LSP). Yes, there's definitely some usefulness to the LSP concept. It will make your code highly replaceable and provide a very nice dev time trade-off. You can spend more time in the design process, rather than in unit test implementation (you’ll see why!).
SOLID: The Liskov Substitution Principle
“Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it.”
This principle can be described in many forms. Essentially, it introduces a behavioral subtyping relationship between abstract/base classes and derived classes. In other words, a contract between base classes and derived classes should be well defined - behavior included - and (not joking on this one...) it should make sense!
(image source)
LSP introduces, via such contracts, “stricter” ground rules that enable you to comply with the open/closed principle. This ensures that your code is truly replaceable (in the sense that switching between derived classes does not require any further code modifications, either in the base class or other derived classes).
Contracts and rules?
These rules are related to two aspects: method signatures and behavioral conditions.
Signatures
With respect to method signatures, LSP requires:
- the argument of derived classes to be contravariant (meaning that its argument must be equally or more generic - less derived - than those required for the base class),
- return type to be covariant (meaning that the outputs should be equally or less generic - more derived - than those required by the base class),
- that no new exceptions can be thrown by the derived class (with respect to those already thrown by the base class).
Why?
This way, one is able to ensure that any potential call to a base class that ends up being a derived class is not broken, due to providing a more generic input than the method was expecting and that it is not able to deduce its type. On the other way around, if return outputs are more generic, whatever use of the output of a base class your component was using, it still will be able to use it!
Behavioural conditions
With respect to behavioural conditions, the Lyskov substitution principle requires that:
- pre-conditions cannot be strengthened in a derived class,
- post-conditions cannot be weakened in a a derived class,
- invariants of the base class must be preserved in a derived class,
- state of the base class should be preserved if such constraint is present on the base class (a.k.a. History constraint).
The logic
It doesn't allow new stronger pre-conditions due to backwards compatibility. If you create a derived class with stronger pre-conditions, it is highly likely that previous code that used this class will break when trying to use the derived classes (unless you go about every call of the method ensuring that it is no called when the new pre-conditions are not met...).
The reason for not allowing any stronger post-conditions is similar, but with respect to the output. When switching to a newly derived class, the exit state should not be more strict than what it was. Otherwise, you’ll probably break your old code (it could assume some condition that was previously met, but now this condition would not be met when using the new derived class).
The last two points are mostly related to complying with what type of state the base class is supposed to have, and what can be changed or not. If a base class says that something is unchangeable, your contract should respect that. You should not change these parts of the base class state! Otherwise, undefined behaviour will be looking at you from a window, laughing.
Phew, that’s about it w.r.t. definitions… You can take a deep breath.
Just follow each of the above definition as if it were a checklist and you should be fine. Oh, did I forgot to mention why you should care?
What are the main benefits?
So... Why is this principle useful? Well, for multiple reasons.
First and foremost: It ensures that you are getting your abstractions right. The whole point of being able to create a class based on an abstract class, is that you can use the derived class seamlessly, without having to touch any of the other derived classes and without breaking any builds.
Second reason: It’s an excepcional tool for detecting poor designs. For instance, if you have an abstraction and one of your derived classes introduces a field or method that requires special treatment (“an if clause” for early return), you’ll probably be in violation of the LSP (you are breaking the contract with the expected behaviour of the abstract class). If this is the case, you should definitely revisit your abstraction!
Third reason: Your unit test code and integration code for the base class is fully reusable for your derived classes. This is highly relevant, since in any cases, unit tests are something that is very time-consuming, so if you can reuse that code, you may save a lot of time!
Fourth reason (this one is particularly nice for me!): If you’re doing some sort of comparison or A/B testing of software components that must have the same behaviour, it allows you to easily replace these components, even on the fly!
Summarising, what do you get from following the Liskov Substitution Principle? Well, code that is highly flexible, code that is testable almost out-of-the-box and code that is adherent to the OCP! It can’t get better than this!
Happy derivations!