Welcome to the five-part series of blog posts about SOLID Principles. In each part, I will describe and analyze one of these principles. In the last part, expect a summary of the entire series containing a few tips and thoughts.
Let’s start. What are SOLID Principles? There are five general design principles of object-oriented programming intended to make software more understandable, extendable, maintainable and testable.
Today, more about third principle - Liskov Substitution Principle.
"you should always be able to substitute the parent class with its derived class without any undesirable behaviour"
or in other words
“if S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program.”
Practically, the derived class should always extend its parent class, without changing its behaviour at all.
The following snippet shows the most classical example of LSP violation:
class Rectangle attr_accessor :height, :width def calculate_area width * height end end class Square < Rectangle def width=(width) super(width) @height = width end def height=(height) super(height) @width = height end end rectangle = Rectangle.new rectangle.height = 10 rectangle.width = 5 rectangle.calculate_area # => 50 square = Square.new square.height = 10 square.width = 5 square.calculate_area # => 25
Mathematically everything is correct. This is square so it must have same height and width. We set height (and width) to 10, then we set width (so height too) to 5 and we calculate area.
We performed the same steps on parent and derived class but we observed different behaviour. Interfaces of parent and derived class are not consistent.
So we can say that public interfaces (and of course their behaviour) of parent and derived classes must be the same.
There is a place to say one very important thing. The principle is not violated because we received a different result. The principle is violated because we received a result which we did not expect.
Please consider the following example:
class Shape def draw raise NotImplementedError end end class Rectangle < Shape def draw # Draws rectangle end end class Circle < Shape def draw # Draws circle end end
Methods draw draw different shapes depending on derived class but they draw shapes that we expect.
Let's think about this. If receiving different results violates LSP, then using polymorphism, which is one of the most powerful tools of OOP, will always violate it.
When we override a method from a parent class in a derived class we should not change its behaviour but we can extend this behaviour by a specific aspect of derived class.
"Preconditions cannot be strengthened in a subtype and postconditions cannot be weakened in a subtype".
or in other words
"a subclass should require nothing more and promise nothing less".
Following LSP allows us to use the polymorphism more confidently. We can call our derived classes referring to their base class without concern about unexpected results.
The problem is in our abstraction. Square is a rectangle in mathematics but no in programming (at least in this case). We have just wrongly modeled our abstraction.
Because it shows one very important thing about OOP. OOP is not only about simple mapping real world to objects.
OOP is about creating abstractions, not concepts!
To be honest, there is no perfect solution (as always).
Recognize that these classes do not have common behavior. Do not tie these two classes - just create two separate classes with their own behaviour.
Add another layer of abstraction to “simulate” interface type (solve it by inheritance too but in a different way).
Example of the second solution:
class Shape def calculate_area raise NotImplementedError end end def Rectangle < Shape attr_accessor :height, :width def calculate_area height * width end end def Square < Shape attr_accessor :side_length def calculate_area side_length * side_length end end
The biggest disadvantage of this? We can derive only from one base class (in opposite to real interfaces - of course we do not inherit from interfaces, we implement them). So if there is a reason to share another behaviour through these classes, our solution will prevent that.
Anyway, we still talk about Ruby which is dynamic-typed language. We are not forced to do such things. Common sense is the most important right here. Attempts to forcefully transfer solutions from static-typed language may not always be the best idea.
Composition over inheritance. You have probably heard this statement. But sometimes we really need/want/have to use inheritance and there is nothing wrong with that. It’s part of OOP, really powerful part. Fulfilling LSP could be a symptom of correctly created inheritance relationship.
Two questions that you should ask yourself:
So we can use LSP as a test for the following problem.
Should I inherit from this type?
IMPORTANT: Of course this is not the only one determinant. Remember that primary question is: "whether A is B?". While these two question above could help you in making a decision but they are not the solution in itself.
Creating good inheritance relationship is the topic for completely separate blogpost (or even book :)). What I want to say is that fulfilling LSP is the necessary but not the only one condition to create good inheritance.
We can observe some typical signals which may indicate that LSP has been violated:
As you've probably noticed, we analyze these principles in the context of Ruby - dynamic-typed language. So the meaning of this particular principle could seem to be less important. However, I would not underestimate it in any way.
We are not forced to keep our interfaces consistent. We are even able to return different type from a method in derived classes than from base class. But should we code in that way? I don't think so. I think it will be very, very bad practice. Like creating methods that return both empty array, boolean or string.
The conclusion is simple. Good practice of object-oriented programming is always commendable - no matter what language we use. Again, common sense plays an important role here. Let's use benefits of dynamic-typed language but do it in a responsible way.
The truth is that dynamic-typed languages give us more flexibility but personally I think that it does not make anything easier. We must be much more careful and control ourselves.