Read the Original Article here
This is the 5th and last part of the series of understanding **SOLID* Principles where we explore what is Liskov Substitution Principle and why it helps with coding to abstractions rather than always coding to concrete implementations thus make code maintainable and reusable.*
As a small reminder, in SOLID there are five basic principles which help to create good (or solid) software architecture. SOLID is an acronym where:-
S stands for SRP (Single responsibility principle)
O stands for OCP (Open closed principle)
L stands for LSP (Liskov substitution principle)
I stand for ISP ( Interface segregation principle)
D stands for DIP ( Dependency inversion principle)
We’ve discussed Dependency Inversion, Single Responsibility, Open-Closed Principle and Interface Segregation Principle before.
Now we are going to address Liskov Substitution Principle.
Liskov Substitution Principle
Objects should be replaceable with instances of their subtypes without altering the correctness of that program.
What it really means is that if you pass a subclass of an abstraction you need to make sure you don’t alter any behavior or state semantics of the parent abstraction.
If you choose not to do that you will suffer:
- The class hierarchies would be a mess. Strange behavior would occur.
- Unit tests for the superclass would never succeed for the subclass. That will make your code difficult to test and verify.
Typically this would happen if you modify unrelated internal or private variables of the parent object that are used in other methods. This is kind of a sneak attack on the object itself and it can happen anytime if your are not careful, not just though the subclasses.
Let’s see an example of what do we mean about modifying behavior and how it affects the results. Let’s say we have a Basic Store
Abstraction that stores messages in a memory data-structure up to a maximum store Limit. Now the clients using this store expect when they call the retrieveMessages
to get all the messages back.
interface Store {
store(message: string);
retrieveMessages(): string[];
}
const STORE_LIMIT = 5;
class BasicStore implements Store {
protected stash: string[] = [];
protected storeLimit: number = STORE_LIMIT;
store(message: string) {
if (this.storeLimit === this.stash.length) {
this.makeMoreRoomForStore();
}
this.stash.push(message);
}
retrieveMessages(): string[] {
return this.stash;
}
makeMoreRoomForStore(): void {
this.storeLimit += 5;
}
}
class RotatingStore extends BasicStore {
makeMoreRoomForStore() {
this.stash = this.stash.slice(1);
}
}
Notice how the RotatingStore
is doing something sneaky and weird, and how subtle it can modify the semantics of the BasicStore
. It modifies the stash and destroys a message in the process to allow space for more messages. If we were trying to utilize a Storer abstraction but we ended up with the destructive RotatingStore
we would have issues with our messages.
const st: Store = new RotatingStore()// or from a factory
st.store("hello")
st.store("world")
st.store("how")
st.store("are")
st.store("you")
st.store("today")
st.store("sir?")
st.retrieveMessages() // Ooops some messages are gone
Some messages will be gone, violating the base requirement that messages should be available when retrieved.
In order to avoid those weird cases, it’s recommended to call public parent methods to get your results in the subclasses and not directly using the internal variables. That way you are making sure the parent abstractions get to the required state without side effects or invalid state transitions.
Its also recommended keeping your base Abstractions as simple and minimal as possible, making it easy to extend by the subclasses. There is no point in making a Fat Base class that makes it easy for the subclasses to override; thus introducing behavior changes.
It may or may not be feasible to do always but its a good idea to do post-condition checking in the sense of internal system health check in order to verify that the subclasses are not messing around some critical code paths. I haven’t seen this idea in practice though so I cannot give you an example.
Code reviews on the other hand help very good. While developing you may accidentally do more damage than you realise, so it’s important to get extra pairs of eyes on your code changes. It’s important to keep the design of the code consistent and pinpoint potential modifications that alter object hierarchies early and often.
Lastly, did I mention before to consider using Composition instead of Inheritance? If not you should read that .
Conclusion
As you have seen it take such a small effort to make things hairy and buggy when you develop software. Keeping those principles close at heart, understanding where they came from and what they try to solve makes your job easier and more reasonable. That’s not a small thing, instead, it should be part of your thinking process when you are on the task. Try to make your designs resilient against unfavorable modifications and sneaky throws
References
Sadly this is the end of the Series. I hope you learned a lot and I helped you better understand those principles in practice. Stay put for more Series of articles as I have quite a lot of them in draft form.
If this post was helpful please share it and stay tuned on my other articles. You can follow me on GitHub and LinkedIn. If you have any ideas and improvements feel free to share them with me.
Happy coding.
If you would like to schedule a mentoring session visit my Codementor Profile.
Top comments (2)
Objects should be replaceable with instances of their subtypes without altering the correctness of that program.
Who said that?
Liskov paper talks about program behavior not the program correctness.
Your Dependency Inversion link is broken. Just thought I'd let you know.