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 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