🖋️
This article is one part of my series on Robert (Bob) C. Martin's SOLID principles.

Not to be confused with the Language Server Protocol, part three of SOLID is the Liskov Substitution Principle. It is named after Barbara Liskov, who described her ideas in a keynote address in 1987 — Data Abstraction and Hierarchy.

Definition

I struggle to find a concise and readable definition of LSP from Liskov herself. Bob Martin, in Clean Architecture, quotes Liskov's keynote address mentioned above:

What is wanted here is something like the following substitution property: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T

To simplify, I would offer:

Where a type is required, a sub-type of that type can instead be provided without resulting in unexpected behaviour.

But with regard to languages like Ruby (see the aside below) I think it makes more sense to define LSP as:

Where an object with a certain behaviour is required, any other object implementing the same behaviour can instead be provided without resulting in unexpected behaviour.

LSP is therefore concerned with the behaviour of objects in inheritance hierarchies.

Aside: On Typing

My aim with these posts is to offer a Ruby-centric view of the SOLID principles. While the SOLID principles are applicable to all object-oriented languages — of which Ruby is, deeply — they are more or less applicable depending on the style of typing in said language.

In the case of LSP, it seems less applicable in dynamically typed languages, but it is just as conceptually applicable. Let's refresh our understanding of typing and how it applies to Ruby before we move on.

Ruby, like JavaScript and Python, is dynamically (or latently) typed. This means that a variable's type is only "checked" at runtime. Further, these languages are "duck-typed," such that there is no enforcement of types beyond what their behaviour allows; if we ask an object to "quack," and it does, then that's good enough for us; or, more conventionally:

If it walks like a duck and quacks like a duck, it’s a duck.

By design, duck-typing focuses on behaviour of objects — what they are able to do — rather than explicit typing or structure.

Take an example:

class Hen; end
class Wolf; end

class HenHouse
  def initialize
    @hens = []
  end

  def add_hen(hen)
    @hens << hen
  end
end

HenHouse.new.add_hen(Wolf.new)

We inherently know that we shouldn't put a wolf in a hen house, yet there's nothing stopping us from doing this in Ruby — and there's no enforcement in the code above.

If we did add a Wolf, we would typically find an error further down the line where the Wolf is asked to do something it can't — like lay an egg. But a wolf could lie in waiting for a long time before it's detected, and then detecting it and removing it may be somewhat more difficult.

Instead of thinking in terms of what we are allowed and not allowed to do by the language, we need to consider the intention behind the code. We learn this contextually from naming and other documentation.

So in these dynamic languages, we rely more on an implicit contract — a social construct — telling us that, by convention, we should be adding hens to the hen house and nothing else. It's not very different from calling private methods from outside of an object — we know we shouldn't, but we can if we want to. But we shouldn't!

Statically typed languages do, however, enforce types. If we say that add_hen can only take objects of type Hen, then the program will not compile if we attempt to pass anything else.

We can enforce types in Ruby at runtime, if we intend to be more defensive:

def add_hen(hen)
  raise TypeError unless hen.is_a?(Hen)
  @hens << hen
end

An is_a? check will pass for the given class or its sub-classes.

Type enforcement breaks duck-typing, though, which goes against Ruby's philosophy. Back to duck-typing being about behaviour: there might be cases in which we want to allow other egg-laying animals into the hen house, like ducks or geese, which would not be allowed because neither are sub-types of Hen. With a type check, we would need to adjust our type hierarchy to include all egg-laying animals; without a type check, we need only ensure that we only rely on methods that all oviparous (cool word) animals will implement.

That should be enough background to let us move back onto the Liskov Substitution Principle. If you'd like to read more about good object-oriented practices in Ruby, however, I cannot recommend Sandi Metz's Practical Object-Oriented Design in Ruby enough.

Example

Let's work with a similar example.

We have a HenHouse, to which we can add and feed our animals. We define three other classes: Hen, Turkey, and RoboticDog.

class HenHouse
  def initialize
    @animals = []
  end
  
  def add_animal(animal)
    @animals << animal
  end
  
  def feed_animals = @animals.each(&:feed)
end


class Hen
  def feed = puts "Cluck."
end

class Turkey
  def feed = puts "Gobble."
end

class RoboticDog
  def charge = puts "Bzzt"
end

Hen and Turkey are animals that can be fed; RoboticDog is not.

Missing Method

There is nothing stopping us from adding a Hen, a Turkey, and a Robot to a HenHouse...

hen_house = HenHouse.new
hen_house.add_animal(Hen.new)
hen_house.add_animal(Turkey.new)
hen_house.add_animal(RoboticDog.new)

Except that our implicit contract is that we should only add animals with add_animal, so we should never try to add a RoboticDog.

The LSP is broken because we have passed an object without the certain behaviour that we implicitly require: we cannot feed a robotic dog. The behavioural substitutability of objects added to the hen house has been violated, because RoboticDog will throw an error when feed is called on it.

Different Behaviour

Okay, new hen house. Let's introduce a Goose.

class Egg
  @eggs = 0
  
  def self.lay
    @eggs += 1
  end
end

class Goose
  def feed
    puts "Honk!"
    Egg.lay
  end
end

hen_house = HenHouse.new
hen_house.add_animal(Hen.new)
hen_house.add_animal(Goose.new)

This seems fine. Both hens and geese satisfy the requirement that they respond to feed. If we call hen_house.feed_animals then both animals get fed and no errors are thrown.

Yet it violates the LSP because Goose has an unexpected, different behaviour: whenever we feed her, she lays an egg (let's hope it's golden).

Here we highlight how LSP is primarily concerned with behaviour. The same issue could be present in both statically and dynamically typed languages. If all animals lay an egg when they are fed, it may be considered expected behaviour and not a violation.

Alternatively, maybe we're okay with some animals laying eggs when they're fed. That also makes it expected behaviour, and acceptable. It's entirely open to interpretation.

Summary

The two scenarios illustrate the same problem, but with different levels of visibility.

Passing an object that doesn't implement an expected method is highly visible and can easily be caught by static typing. Implementing expected methods but with unexpected behaviour, however, is very difficult to detect but also leads to (subtle) bugs in the system. At face value, both violate the principle, but the latter might not violate the principle if the designer deems it expected behaviour.

Takeaway

The Liskov Substitution Principle is about substituting objects with other objects and the expectations around those objects. It should be possible to substitute objects with logically equivalent sets of behaviour — i.e. if the set of behaviour we are concerned with is that an object can be fed, then all animals would satisfy this — with the caveat that their equivalent behaviour does not cause unexpected behaviour, like side-effects — e.g. the animal bites and kills you when it is fed.

On the surface, LSP is easy in that objects need to respond to the same set of methods. As we go deeper, it becomes increasingly open to interpretation.

It is therefore important to describe expected behaviour clearly, whether in code, documentation, or both; in order to specify what behaviour is expected.