SOLID Principles #3: Liskov Substitution Principle

Photo of Marcin Jakubowski

Marcin Jakubowski

Updated Aug 3, 2023 • 9 min read
simon-migaj-475998-unsplash

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.

  1. Single responsibility principle (SRP)
  2. Open/closed principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. 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).

  1. Recognize that these classes do not have common behavior. Do not tie these two classes - just create two separate classes with their own behaviour.

  2. 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:

  1. 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.
  2. 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.

Photo of Marcin Jakubowski

More posts by this author

Marcin Jakubowski

Marcin graduated from Civil Engineering at the Poznań University of Technology. One year before...
Lost with AI?  Get the most important news weekly, straight to your inbox, curated by our CEO  Subscribe to AI'm Informed

Read more on our Blog

Check out the knowledge base collected and distilled by experienced professionals.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business