Allyx Gomes

Software Development Blog

The Liskov Substitution Principle (LSP) — Talking About S.O.L.I.D.

If it looks like a duck, quacks like a duck, but needs batteries — You’re probably using abstraction incorrectly.

The image above is an analogy that explains this principle. More technically, another definition of LSP would be:

Subclasses should be substitutable for their Superclasses.

LSP is often linked to OCP (Open Close Principle). Beyond the concern of providing an abstraction where the client can work with different implementations, LSP states that the behavior of values in an abstraction should vary independently.

But what do you mean by “vary independently,” Allyx? Let’s break it down. First, let’s clarify a grotesque and more straightforward violation of LSP. I’ll use an example similar to the one from the OCP article. Imagine an abstract class PrintType, which should provide a base contract for all the necessary printing types, and the specializations should implement their print method as needed.

class PrintType
  attr_reader :file

  def initialize(file)
    @file = file
  end

  def print
    puts "A generic print " + @file.to_s
  end
end

However, for some reason, the developers responsible for implementing the specialized classes PDF, CSV, and Matricial decided to create classes inherited from PrintType, each with its custom printing methods. The result was code that looked like this:

def print_files(files)
  files.each do |file|
    if file.print_type.is_a?(PDFPrintType)
      file.print_type.print_pdf
    elsif file.print_type.is_a?(CSVPrintType)
      file.print_type.print_csv
    elsif file.print_type.is_a?(MatricialPrintType)
      file.print_type.print_matricial
    end
  end
end

Notice that when working with the specialized classes, the client needs to know the type and call the specific method for each specialized class to print as intended. A better solution would be to override the base method print of PrintType in each specialized class. With this approach, the client could work with the abstraction without having to handle different printing types, thus avoiding violations of the OCP and LSP principles. The new code would look like this:

def print_files(files)
  files.each do |file|
    file.print_type.print
  end
end

It’s easy to see the immediate benefit of hiding the implementations and following the design principle of “Program to an interface, not an implementation,” as proposed in the previous example. Here, the caller of the print method doesn’t need to know the specialized class, and we can add more implementations as needed, which will be supported as long as they follow the contract of the base classe.

Now, let’s put this into context using a popular example (adapted from C++) involving squares and rectangles. Every square is a rectangle, right? A rectangle where all sides are equal has the same shape, with equal width and height, so why would it be bad to use the rectangle abstraction for a square specialization? It seems like a good idea at first glance, doesn’t it? Let’s check the rectangle implementation:

class Rectangle
  attr_accessor :width, :height
end

Now consider the square implementation inheriting from the rectangle. Take a look at the behavior of the methods that change width and height.

class Square < Rectangle
  def width=(width)
    @width = width
    @height = width
  end

  def height=(height)
    @width = height
    @height = height
  end
end

Did you notice the problem here? The behavior of changing the width and height of a square differs from that of a rectangle. In this case, changing either the width or height will affect both attributes. This issue becomes evident when looking at a unit test.

RSpec.describe 'Square' do
  it 'calculates the area by multiplying width and height' do
    square = Square.new
    square.width = 5
    square.height = 4
    expect(square.width * square.height).to eq(20)
  end
end
The unit test above will fail

The person who wrote the unit test above did something logical: the expected behavior for calculating a rectangle’s area is width * height. At this point, we reach an important conclusion — a model can’t be fully validated in isolation, it must be validated based on the client’s perspective. We analyzed both a rectangle and a square, and built classes that worked consistently based on the developers’ assumptions about these abstractions. However, when we shift the perspective to the client’s specific behavior, the model breaks down.

But the question of the year is: is a square a rectangle? From the perspective of object-oriented design (OOD), no.

Notice how the word “behavior” appeared several times? This was intentional to emphasize that the behavior of a square is not the same as a rectangle. To avoid violating LSP and OCP, it’s better not to “reuse” the rectangle abstraction for a square.

For a more detailed explanation of this example, I highly recommend reading this article.

Useful links

http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod
https://robsoncastilho.com.br/2013/03/21/principios-solid-principio-de-substituicao-de-liskov-lsp/ (pt-br)

Leave a Reply

Your email address will not be published. Required fields are marked *