As we all know, software requirements always change, and we, as developers, need to make sure these changes don’t break the existing code. For this reason, the SOLID principles were introduced in Object-Oriented design to ease this process.
The SOLID principles are a set of principles created by Robert C. Martin (Uncle Bob). These principles help us create more flexible, maintainable, and understandable software. These principles are:
- Single Responsibility Principle
- Open-Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion
After introducing the Open-Closed Principle in the previous article, we will discuss the third principle, the Liskov Substitution Principle (LSP), which is the “L” in the SOLID acronym.
Let’s introduce the mathematical definition of the LSP and then jump into the details. Barbara Liskov introduced the mathematical definition in 1988:
“If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.”
The basic object-oriented design controls the relationship between objects using either Inheritance or Composition. Inheritance, the IS-A relationship, occurs when something IS A kind of another thing. For example, a horse IS AN animal.
On the other hand, Composition, the HAS-A relationship with something else. For example, an address HAS A city related to it. The LSP brings additional constraint to the Object-Oriented Design and states that these relationships aren’t sufficient and should be replaced with IS-SUBSTITUTABLE-FOR.
But what does that mean? Simply, it means a supertype should be replaceable with its subtype without breaking the existing code. In other words, the supertype should behave the same as its subtype.
Having said that, how can we guarantee that replacing a supertype with a subtype has no side effects on our existing code?
Keep in mind that the five SOLID principles are somehow related. And following one principle does not ensure you follow the others correctly.
As we will see, the LSP extends the Open-Closed principle, and following the rules of the OCP isn’t enough to ensure that your code is open for extension and closed for modifications. But your code must also conform to the Liskov Substitution Principle to avoid side effects.
Below is an example that will help you better understand this point:
As you see, this example follows the OCP perfectly. That is, if we want to add a new employee role, all we have to do is add a new class, including the new role functionality that conforms to the IEmployee
contract.
That sounds good. But let me ask you a question, how would you build the Guest
role? Yes, the guest role can do listPosts
, but how would you deal with the login
functionality as there is no authentication for a guest employee?
I think you could respond, I can leave it blank with no functionality at all, or I can throw an unsupported exception in the login
method. Yes, these solutions are intuitive if you don’t consider the LSP.
Again, you could ask me, since I have fulfilled the OCP perfectly, and that’s the most important thing to me, then what’s the problem if I violate the LSP? I think this question has an implicit correct point: some principles are more important than others. However, we should not ignore a principle because it is less important.
As we already knew, the LSP is about substituting a supertype and a subtype without impacting the existing client code. Keep this point in mind, and let’s jump into your solutions again:
- Leaving it blank with no functionality: Now, your client code expects the
login
function to return an authenticated user token. What will happen if you used theGuest.login
that returns nothing? It will break your existing code, won’t it? - Throwing an unsupported exception: Again, your existing code doesn’t handle this new exception from
Guest.login
. So, as a result, using theGuest.login
will break the existing code.
Surprisingly, this design perfectly follows the OCP. However, it violates the LSP.
Unfortunately, there is no easy way to enforce this principle in your code. However, to apply this principle correctly in your code, you must follow two types of rules: Signature and Behavior.
Although you can enforce the Signature rules using a compiled language like Java, you can’t enforce the Behavior rules. Instead, you must implement checks to enforce a specific behavior.
First, let’s introduce the Signature rules
- Countervariance of method arguments: It is a conversion from a more specific to a more general type. In other words, the overridden subtype method argument has to be the same as the supertype or wider.
If the client code expects a string
-only argument in the SuperType
, and you replace that a SuperType
with SubType
that accepts string
or number
arguments (wider), the client code will notice no differences.
- Covariance of return types: It converts from a more general type to a more specific one. In other words, the return type of the overridden subtype method has to be the same as the supertype or narrower.
The client code has already handled a string
or number
response coming from the SuperType
. So if you replaced the SuperType
with the SubType
that returns only a string
response, the client code wouldn’t break.
- Exceptions: The subtype method has to throw the same exceptions of the supertype or narrower. All compiled languages couldn’t enforce this rule. Some languages can enforce it, like Java, and others that can’t enforce it, like TypeScript.
Like the previous rule, as long as the client code depends on the SuperType
handles more exceptions. If you replaced this SuperType
with the SubType
which handles fewer exceptions, the client code would notice no difference.
Second, let’s introduce the Behavior rules
- Class Invariants (property rule): The subtype methods have to preserve or strengthen the supertype’s class invariants.
The SubType
has to maintain the same SuperType
invariants or strengthen them. Think of it, if the SubType
doesn’t maintain the same SuperType
invariants, it wouldn’t be substitutable for the SuperType
and would break the client code that depends on a specific behavior from the SuperType
.
- History Constraint (property rule): The subtype methods shouldn’t allow a state change that the supertype doesn’t allow.
Here, if the SubType
ignored the constraints imposed by the SuperType
, this will break any client code that relies on these constraints. Therefore, SubType
can’t be substitutable for SuperType
.
- Preconditions (method rule): The subtype method should preserve or weaken the preconditions of the overridden supertype method. Here, if you weaken the condition, you relax its constraints.
In the previous example, any client code that provides the hour
input imposed to the SuperType
condition hour < 0 && hour > 12
will be imposed on a wider range hour < 0 && hour > 23
from the SubType
. In other words, SubType
could be substitutable for SuperType
without any side effects.
- Postconditions (method rule): The subtype method should preserve or strengthen the postconditions of the overridden supertype method.
Like the previous example, if the client code expects the returned value from SuperType
to have a maximum value of 50
will consequently be valid if you replace it with a SubType
returns a value with a maximum value of 30
.
At first glance, you might think the Liskov Substitution Principle is all about inheritance, but this is not true. I preferred to dedicate a separate section for this point to stress it more because it confused me a lot while learning this principle. I thought LSP could be applied only if I used inheritance.
The Liskov Substitution Principle has nothing to do with inheritance. The LSP is just about subtyping. Regardless, this subtyping comes from inheritance or composition. Since the LSP has nothing to do with inheritance, whether or not you use inheritance is irrelevant to whether or not the LSP applies. Take a look at this solution on StackExchange.
So, don’t tightly couple the LSP and inheritance concepts. Instead, keep in mind the LSP if you are forced to use inheritance. Look again at the example of the “How Does LSP Extend OCP?” section.
Let’s introduce the most common violations of LSP and try to redesign it to follow the LSP.
1) Type checking
If you’re checking the type of a variable inside a polymorphic code. Have a look at the below example:
As you see, this loop has two different functionalities based on the employee type. But what is the problem with this implementation?
Think again. The first problem here is that any time you work with employees, you might have to perform a check to see if this employee is a Guest
type to do a specific functionality or another type to do another functionality.
The second problem is that you might add new types in the future and have to visit everywhere this check exists to add specific behaviors to support these new types. On top of that, this violates the Open-Closed Principle.
So, how can we solve this problem? One solution is using the Tell, Don’t Ask Principle or Encapsulation. It means don’t ask an instance for its type and then perform a specific action conditionally. Instead, encapsulate that logic in the type and tell it to perform an action. Let’s apply this to the previous example:
2) Null checking
It has the same behavior as type-checking. Have a look at the below example. Instead of checking on Guest
type, you are checking on null
value like this if (employee === null)
. This violates the LSP as well.
But how can we solve this problem? One common solution for this problem is using the Null Object design pattern. Have a look at this redesign:
3) Throwing not implemented exception
This is a common one because of the partial implementation of an interface or a base class. Look again at the example of the “How Does LSP Extend OCP?” section. You have to throw a Not Implemented Exception in the method login
of the Guest
subtype because it can’t fully implement the IEmployee
interface (supertype).
The solution for this problem is to make sure to fully implement the supertype, whether it is an interface or a base class.
However, you might argue that it is sometimes difficult to implement the interface fully, like in our example. That’s true. If you have such a case, you probably need to double-check the relation between the supertype and subtype. The subtype might not be qualified to be a subtype for this supertype, or in other words, this is probably a violation of the Interface Segregation Principle.
In this article, we introduced the Liskov Substitution Principle. We knew that LSP adds a new constraint to the object-oriented design. It states that relationships are insufficient, and you need to make sure subtypes are substitutable for supertypes.
We also knew the rules you must follow to apply this principle correctly. And these rules could be categorized under Signature and Behavior rules.
After that, we introduced some common violations of this principle and solutions for them.
- SOLID Principles for C# Developers
- Liskov Substitution Principle in Java
- SOLID Design Principles Explained: The Liskov Substitution Principle with Code Examples
- Confusion about strengthening/weakening preconditions/postconditions
- Is this a violation of the Liskov Substitution Principle?
If you liked this article please rate and share it to spread the word, really, that encourages me a lot to create more content like this.
If you found this article useful, check out these articles as well:
- Do You Really Know, What Is Single Responsibility?
- Open-Closed Principle: The Hard Parts
- Strategy vs State vs Template Design Patterns
- MongoDB GridFS, Made Simple
Thanks a lot for staying with me up till this point. I hope you enjoy reading this article.