Liskov Substitution Principle

Barbara Liskov was actually building on other folks work, which was described as “Behavorial Subtyping”, so that is the real name of this principle. After this quick read you will see why that is important.

Squares are not Rectangles

Uncle Bob” Martin said his work on object-oriented design principles began when he read about what someone had named Barbara Liskov’s work the “Liskov Substitution Principle” and realized there must be other principles as well. I noticed when I talk to other programmers, the Liskov principle is the principle most often misunderstood.

I recently commented on a blog article that I really liked. The focus was on behavior during substitution, and I suggested to that author that Barbara’s principle had a bearing on the subject. He told me no, that the Liskov principle was about ensuring the same interface when objects are substituted. But that is the definition of simple substituability.

The Liskov Substitution Principle comes down to you cannot substitute an object which alters the expected behavior, which is related to but different from having the same interface. Remember, this applies to both classes and functions. The interface defines if an object may be substituted. Behavior defines it does something its own way, but with expected constraints and results. Note this allows you to add new behavior to a subclass, you just cannot change what the client expects to see when it expects the super-class interface.

I would like to stress at this point that the principle applies to all software engineering, not just OOP. Substitution has been with us for thousands of years in many forms:

  • Dennis Ritchie put function pointers into the C language. This allows substitution of functions; of course they must share the same interface (parameters). And this is still used today in functional languages, like Clojure and F#.
  • Unix pioneered being able to substitute device drivers. The operating system defines an interface the drivers must provide and an adapter may be written to connect any device.
  • Greek, Roman, and other armies excelled because of the substitutability of soldiers. As long as the interface and behavior remain the same, soldiers are interchangeable. However, if you plug a general into a phalanx do not be suprised if their behavior is different!

Most of the time the principles can be distilled to simple, practical rules for programmers to follow. I realize that I tend to oversimplify things, but I am focused on teaching programmers what they need to know 90% of the time without having to earn a doctoral degree. We can dig into the edge cases later if you want to!

Shapes

We are going to build an obviously contrived example, but it is fantastically easy to understand and remember. Start with defining a Shape super-class in the file src/Shape/Shape.py. I will use Python, it is very popular and easy for everyone to follow. The code for this example is in my private stock at https://github.com/jmussman/principles_liskov.

class Shape:

    def __init__(self):
        pass

    def area():
        pass

If you are not a Python connoisseur: Python is not strongly typed, supports both procedural and object-oriented programming, def is used to define functions and methods, methods must have self (this) defined as a parameter, the __init__ method is the constructor, and pass is a placeholder when there is no function body (Shape is technically a pure-abstract class).

The whole point is that Shape defines a method to return the area (what the units are we do not care), and all shapes that inherit from this will have that in their interface.

Define a Rectangle class in the file src/Shape/Rectangle. We can override the method that returns the area to return length times width. Ah ha! That means that a rectangle must also have dimension, and we need to allow the client to set it:

from src/Shape/Shape import Shape

class Rectangle(Shape):

    def __init__(self):
        self._length = 0
        self._width = 0

    @property
    def length(self):
        return self._length

    @length.setter
    def length(self, value):
        self._length = value

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        self._width = value

    def area(self):
        return self.length * self.width

Just to level-set: @property defines a getter that may be accessed like a variable in the client, and the .setter labels the setter half of the property that may be used with an assignment operator.

Now for the client. Define a RectClient class in src/RectClient.py with a method that expects a rectangle and dimensions, and returns two values: the area calculated from the dimentions, and what the class says the area is. The point is to have “client code” that expects an object of type Rectangle:

from src/Shape/Rectangle import Rectange

class RectClient:

    def get_areas(self, rectangle, length, width):

        rectangle.length = length
        rectangle.width = width
        expected_area = length * width
        area = rectangle.area()

        return (expected_area, area)

Create a unit test class TestRectClient in test/test_RectClient.py that feeds a Rectangle to the method and prints out the results. We show the expected results as a comment:

import unittest

from src.RectClient import RectClient
from src.Shape.Rectangle import Rectangle

class TestRectClient(unittest.TestCase):

    def test_get_area_rectangle(self):

        rect_client = RectClient()
        rectangle = Rectangle()

        expected_area, area = rect_client.get_areas(rectangle, 10, 5)

        self.assertEqual(expected_area, 50)
        self.assertEqual(area, 50)

Note the assertions. The expected_area should be the multiplication of the length and the width passed to the client: 50. The area returned by the Rectangle should also be the length multiplied by the width that the it was set to: 50.

Where are we? Oh, yes, Rectangles are Shapes, so you can use them wherever a Shape is expected. The Shape interface is supported, area returns the correct value. This is an awesome implementation of inheritance and substitutability!

Well, what about a square? A square has the same properties as a rectangle, with the additional provision that the length must always equal the width. That makes perfect sense, that is what we learned in school!

Extend Rectangle to make Square in src/Shape/Square.py:

from src/Shape/Rectangle import Rectangle

class Square(Rectangle):

    def __init__(self):
        super()

    @property
    def length(self):
        return self._length

    @length.setter
    def length(self, value):
        self._length = value
        self._width = value

    @property
    def width(self):
        return self._width

    @length.setter
    def width(self, value):
        self._width = value
        self._length = value

    def area(self):
        return self.length * self.width

A Square extends Rectangle, so we can add a test and pass it to RectClient as a Rectangle. Remember the import for Square at the top of the file.The So far everything is basic object-oriented programing with polymorphism:

from src/Shape/Square import Square

...

    def test_get_area_square(self):

        rect_client = RectClient()
        rectangle = Square()

        expected_area, area = rect_client.get_areas(rectangle, 10, 5)

        self.assertEqual(expected_area, 50)
        self.assertEqual(area, 25)

Wait a minute. The actual area is 25? Of course! The length was set before the width, and when the width was set to 5 so was the length. 5 x 5 = 25.

This is exactly what Barbara says we cannot allow to happen: the behavior cannot change. The client expects a rectangle. It expects a rectangle to behave a certain way, and it does not expect the object to behave differently. The square has an additional constraint, and the client did not expect the length to change when the width was set!

To follow the Liskov Substitution Principle, Square can extend Shape but not Rectangle. And a Square cannot be used where the code expects a Rectangle.

Conclusion

Clearly this is a contrived example, but simple, memorable, and clear as to how this principle is applied. It looks like everything is fine and proper OOP, until we take behavior into account.

This example should makes the point that substitution in programming is not just about the interface to the object, it is also about behavior. Remember that an “interface” does not just mean OOP, it could mean the parameters to a function.

If you want a real-world example, think about stacks and queues. The interface is the same, but the behavior is different. Which end does data come off of? If you substitute one for another, it will confuse the client code!

And to be clear, it is not always about programming. The same issue exists in mathematics too, where the same argument about the behavior of squares, rectangles, and other shapes holds true.