SOLID Principles #3: Liskov Substitution Principle
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.
- Single responsibility principle (SRP)
- Open/closed principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
Today, more about third principle - Liskov Substitution 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.
LSP and polymorphism
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.
Where is the problem?
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.
Why I love this example?
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!
Time to introduce some improvements
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.
LSP as a determinant of good inheritance?
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:
- Does B want to expose the complete interface (all public methods, no less) of A such that B can be used where A is expected?
It (probably) indicates the need for INHERITANCE. - Does B want only some part of the behavior exposed by A?
It (probably) indicates the need for COMPOSITION.
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.
LSP violations symptoms
We can observe some typical signals which may indicate that LSP has been violated:
- Derivates that override a method of the base class method to give it completely new behaviour.
- Derivates that override a method of the superclass by an empty method.
- Derivates that document that certain methods inherited from the superclass should not be called by clients.
- Derivates that throw additional (unchecked) exceptions.
Summary
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.