So you know how to code in general, understand the object-oriented programming, learned C++, and completed at least one Software Development Course (if you're not there yet, these articles aren't for you). You can write software easily if you know at least one programming language, but is your code any good? Could it be done any better? Is it clean (and what on earth does that mean)? Is your architecture any good? Should you use a different one? What about Design Patterns? These were some of the questions I've had when I started, and answering them helped me to step up to a professional level. Which is why I have written these series SOLID as a Rock design principle. Liskov's Substitution Principle in C++ is the second principle in this series which I will discuss here.
/!\: This article has been originally published on my blog. If you are interested in receiving my latest articles, please sign up to my newsletter.
By the way, If you haven't gone through my previous articles on design principles, then below is the quick links:
- SRP -- Single Responsibility Principle
- OCP -- Open/Closed Principle
- LSP -- Liskov Substitution Principle
- ISP -- Interface Segregation Principle
- DIP -- Dependency Inversion Principle
The code snippets you see throughout this series of articles are simplified not sophisticated. So you often see me not using keywords like override
, final
, public
(while inheritance) just to make code compact & consumable(most of the time) in single standard screen size. I also prefer struct
instead of class
just to save line by not writing "public:
" sometimes and also miss virtual destructor, constructor, copy constructor, prefix std::
, deleting dynamic memory, intentionally. I also consider myself a pragmatic person who wants to convey an idea in the simplest way possible rather than the standard way or using Jargons.
Note:
- If you stumbled here directly, then I would suggest you go through What is design pattern? first, even if it is trivial. I believe it will encourage you to explore more on this topic.
- All of this code you encounter in this series of articles are compiled using C++20(though I have used Modern C++ features up to C++17 in most cases). So if you don't have access to the latest compiler you can use https://wandbox.org/ which has preinstalled boost library as well.
Intent
Subtypes must be substitutable for their base types without altering the correctness of the program
- If I address this in the context of C++, this literally means that functions that use pointers/references to base classes must be able to substitute by its derived classes.
- The Liskov Substitution Principle revolves around ensuring that inheritance is used correctly.
Violating the Liskov's Substitution Principle
- A great & traditional example illustrating LSP was how sometimes something that sounds right in natural language doesn't quite work in code.
- In mathematics, a
Square
is aRectangle
. Indeed it is a specialization of a rectangle. The "IS A" makes you want to model this with inheritance. However if in code you madeSquare
derive fromRectangle
, then aSquare
should be usable anywhere you expect aRectangle
. This makes for some strange behaviour as follows:
struct Rectangle {
Rectangle(const uint32_t width, const uint32_t height) : m_width{width}, m_height{height} {}
uint32_t get_width() const { return m_width; }
uint32_t get_height() const { return m_height; }
virtual void set_width(const uint32_t width) { this->m_width = width; }
virtual void set_height(const uint32_t height) { this->m_height = height; }
uint32_t area() const { return m_width * m_height; }
protected:
uint32_t m_width, m_height;
};
struct Square : Rectangle {
Square(uint32_t size) : Rectangle(size, size) {}
void set_width(const uint32_t width) override { this->m_width = m_height = width; }
void set_height(const uint32_t height) override { this->m_height = m_width = height; }
};
void process(Rectangle &r) {
uint32_t w = r.get_width();
r.set_height(10);
assert((w * 10) == r.area()); // Fails for Square <--------------------
}
int main() {
Rectangle r{5, 5};
process(r);
Square s{5};
process(s);
return EXIT_SUCCESS;
}
- As you can see above, we have violated Liskovs's Substitution Principle in the
void process(Rectangle &r)
function. ThereforeSquare
is not a valid substitute ofRectangle
. - If you see from the design perspective, the very idea of inheriting
Square
fromRectangle
is not a good idea. BecauseSquare
does not have height & width, rather it has the size/length of sides.
Liskov's Substitution Principle Example
Not so good
void process(Rectangle &r) {
uint32_t w = r.get_width();
r.set_height(10);
if (dynamic_cast<Square *>(&r) != nullptr)
assert((r.get_width() * r.get_width()) == r.area());
else
assert((w * 10) == r.area());
}
- A common code smell that frequently indicates an LSP violation is the presence of type checking code within a code block that is polymorphic.
- For instance, if you have a
std::for_each
loop over a collection of objects of typeFoo
, and within this loop, there is a check to see ifFoo
is in factBar
(a subtype ofFoo
), then this is almost certainly an LSP violation. Rather you should ensureBar
is in all ways substitutable forFoo
, there should be no need to include such a check.
An OK way to do it
void process(Rectangle &r) {
uint32_t w = r.get_width();
r.set_height(10);
if (r.is_square())
assert((r.get_width() * r.get_width()) == r.area());
else
assert((w * 10) == r.area());
}
- No need to create a separate class for
Square
. Instead, you can simply check forbool
flag within theRectangle
class to validateSquare
property. Though not a recommended way.
Use proper inheritance hierarchy
struct Shape {
virtual uint32_t area() const = 0;
};
struct Rectangle : Shape {
Rectangle(const uint32_t width, const uint32_t height) : m_width{width}, m_height{height} {}
uint32_t get_width() const { return m_width; }
uint32_t get_height() const { return m_height; }
virtual void set_width(const uint32_t width) { this->m_width = width; }
virtual void set_height(const uint32_t height) { this->m_height = height; }
uint32_t area() const override { return m_width * m_height; }
private:
uint32_t m_width, m_height;
};
struct Square : Shape {
Square(uint32_t size) : m_size(size) {}
void set_size(const uint32_t size) { this->m_size = size; }
uint32_t area() const override { return m_size * m_size; }
private:
uint32_t m_size;
};
void process(Shape &s) {
// Use polymorphic behaviour only i.e. area()
}
With Factory Pattern
- Still, creation or change is needed to process
Shape
, then you should try to use Virtual Constructor & Virtual Copy Constructor i.e. Factory Pattern.
struct ShapeFactory {
static Shape CreateRectangle(uint32_t width, uint32_t height);
static Shape CreateSquare(uint32_t size);
};
Benefits of Liskov's Substitution Principle
=> Compatibility
- It enables the binary compatibility between multiple releases & patches. In other words, It keeps the client code away from being impacted.
=> Type Safety
- It's the easiest approach to handle type safety with inheritance, as types are not allowed to vary when inheriting.
=> Maintainability
- Code that adheres to LSP is loosely dependent on each other & encourages code reusability.
- Code that adheres to the LSP is code that makes the right abstractions.
Yardstick to Craft Liskov's Substitution Principle Friendly Software
- In most introductions to object-oriented programming, inheritance discussed as an "IS-A" relationship with the inherited object. However, this is necessary, but not sufficient. It is more appropriate to say that one object can be designed to inherit from another if it always has an "IS-SUBSTITUTABLE-FOR" relationship with the inherited object.
- The whole point of using an abstract base class is so that, in the future, you can write a new subclass & insert it into existing, working, tested code. A noble goal, but how to achieve it? First, start with decomposing your problem space --- domain. Second, express your contract/interfaces/virtual-methods in plain English.
Closing Notes
Don't get me wrong, I like SOLID and the approaches it promotes. But it's just a shape of deeper principles lying in its foundation. The examples above made it clear what this principle is striving for i.e. loose coupling & ensuring correct inheritance.
Now, go out there and make your subclasses swappable, and thank Dr. Barbara Liskov for such a useful principle.
Have Any Suggestions, Query or Wants to Say Hi
? Take the Pressure Off, You Are Just a Click Away.🖱️
Top comments (0)