Thursday, March 5, 2015

SOLID Review: Liskov Substitution Principle


Note: This is part of a series of articles reviewing the five SOLID Principles of object-oriented programming.

Barbara Liskov introduced her substitution principle back in 1987 during her keynote titled Data Abstraction and Heirarchy. Today, it is one of the five SOLID principles in object-oriented programming. The original definition is as follows:

"Let q(x) be a property provable about objects x of type T. Then q(y) is provable for objects y of type S, where S is a subtype of T."

Simply put:

"Instances of any type should be replaceable by instances of its subtypes without creating incorrect behaviors."

How can we ensure that our classes abide by the Liskov Substitution Principle? For starters, we must ensure that any subtype implements the interface of its base type. In the world of dynamic languages, this is better stated as a subtype must respond to the same set of methods as its base type.

We must also ensure that methods in any subtype preserve the original promises of methods in its base type. What "promises" are we talking about? For that, we turn to another design principle known as Design by Contract.

Design by Contract

The concept of Design by Contract was coined by Bertrand Meyer in his book Object Oriented Software Construction. It's official description is much more detailed, but to paraphrase, there are three basic principles:
  • Subtypes should not strengthen any preconditions of its base type. That is, requirements on inputs to a subtype cannot be stricter than in the base type.
  • Subtypes should not weaken any postconditions of its base type. That is, the possible outputs from a subtype must be more than or equally restrictive as from the base class.
  • Subtypes must preserve all invariants of its base type. That is, if the base type has guarantees that certain conditions be true, its subtype should make those same guarantees.

If any of the above are violated, chances are the Liskov Substitution Principle is also violated.

A Liskov Substitution Checklist

Let's look at a simple example. We're going to model several types of birds. We'll start by defining a base type called Bird:
class Bird
  def initialize
    @flying = false
  end

  def eat(food)
    if ['worm', 'seed'].include?(food)
      "Ate #{food}!"
    else
      raise "Does not eat #{food}!"
    end
  end

  def lay_egg
    # The Egg class has a method 'hatch!' that returns a new Bird.
    Egg.new 
  end

  def fly!
    @flying = true
  end
end
Instances of Bird are very simple. They eat only certain types of food, lay eggs, and can go from sitting on the ground to flying in the air. For now, ignore the fact that our Bird cannot go back on the ground. Here's a small program that uses our Bird:
bird = Bird.new

bird.eat('worm') # Ate worm!

egg = bird.lay_egg # Returns an Egg
egg.hatch! # Returns a new Bird

bird.fly! # @flying == true
Remember, any subtypes from Bird should be able to work in our program above. Now, let's create some subtypes of Bird and see how we can apply the Liskov Substitution Principle.

  • The subtype must implement the base type's interface.

In most programming languages, we can achieve this through basic inheritance. Since we already have a base class defined, we'll take this approach. However, there are many ways to achieve this across many languages. In Ruby, we can use modules to share methods (see duck-typing). In Java, we can implement interfaces.

Let's create a Pigeon subclass:
class Pigeon < Bird
end

bird = Pigeon.new # Behaves exactly like Bird!
Success! Pigeon now implements Bird's interface.

  • The subtype should not strengthen preconditions of the base type.

Let's say our Pigeons can only eat bread. We will override the eat method to achieve this:
class Pigeon < Bird
  def eat(food)
    if ['bread'].include?(food)
      "Ate #{food}!"
    else
      raise "Does not eat #{food}!"
    end
  end
end

# bird is now Pigeon
bird.eat('worm') # raises an error: "Does not eat worm!"
Since we've actually made the preconditions to our method stricter than in the Bird class, we've violated the Liskov Substitution Principle! In doing so, we've broken our existing program!

Instead, let's say that Pigeons can eat bread in addition to seeds and worms. Then, we've weakened the preconditions and are well within our rule:
class Pigeon < Bird
  def eat(food)
    if ['worm', 'seed', 'bread'].include?(food)
      "Ate #{food}!"
    else
      raise "Does not eat #{food}!"
    end
  end
end

bird.eat('worm') # "Ate worm!"
And our program works with our subclass!

  • The subtype should not weaken postconditions of the base type.

Let's say our Pigeon is some kind of mutant and doesn't actually lay eggs. We'll call it a MutantPigeon. Instead, no egg comes out at all:
class MutantPigeon < Bird
  def lay_egg
    nil
  end
end

bird = MutantPigeon.new

egg = bird.lay_egg # returns nil
egg.hatch! # raises an error: undefined method 'hatch!' for nil:NilClass
We've broken our program yet again! Since we've actually made the postconditions in our method less restrictive than in the Bird class, we've violated the Liskov Substitution Principle.

Instead, let's say that MutantPigeons actually return a more specific type of Egg. We'll call it MutantPigeonEgg, and it behaves just like Egg with a hatch! method. Then, we've strengthened the postconditions and are well within our rule:
class MutantPigeon < Bird
  def lay_egg
    MutantPigeonEgg.new
  end
end

egg = bird.lay_egg # returns nil
egg.hatch! # Returns a new MutantPigeon
And our program is happy again!

  • The subtype should preserve invariants of the base type.

Let's model a different bird this time. What about Penguins? As many people know, most penguins in the real world don't actually fly. So, we'll override the fly method with a no-op:
class Penguin < Bird
  def fly
    # no-op, do nothing
  end
end

bird = Penguin.new

bird.fly! # @flying != true
Looks like another break in our program! By doing nothing in our new fly method, we've broken the guarantee that the state of our @flying variable would be "true". Again, we've violated the Liskov Substitution Principle.

Now, this introduces an interesting problem. Penguins cannot just be made to fly, right?!

Real-Life Relationships != Inheritance-Model Relationships

Objects in the real world may show an obvious inheritance relationship. However, in object-oriented design, we only care about inheritance relationships regarding object behavior. Think of the classes in our system as representations of real-world objects. Those representations are fully defined by their external behavior (or interface).

Sure, penguins are birds in the real world, but Penguins are not Birds in our system because they do not behave like Birds. They don't have a properly functioning fly method.

Liskov Substitution and the Open/Closed Principle

Consider the examples above. Suppose we actually violated the Liskov Substitution Principle by creating our Pigeon class with a more restrictive eat method? Our existing program would have to be modified to handle our new class:
class Pigeon < Bird
  def eat(food)
    if ['bread'].include?(food)
      "Ate #{food}!"
    else
      raise "Does not eat #{food}!"
    end
  end
end

if bird.instance_of?(Pigeon)
  bird.eat('bread') # "Ate bread!"
else
  bird.eat('worm') # "Ate worm!"
end
As we know from the Open/Closed Principle, we shouldn't have to change existing code to add new requirements or features. By violating the Liskov Substitution Principle, we are forced to violate the Open/Closed Principle!

Conclusion

As with all programming principles, it's important to find a balance when applying the Liskov Substitution Principle in real-world scenarios. There is some debate over the benefits or detriments of the principle. Always keep it simple first, then refactor as needed.

Happy coding!

3 comments:

  1. I would add bread to the eat method's array in Bird. I would add an abstract method, dietary_constraint, to Bird and override that method in Pigeon to reflect a diet of only bread. Perhaps real Pigeons eat a variety of foods, and only PetPigeons have the dietary constraint. Eating is a function of logic (method), what to eat is a choice (parameter). My $0.02.

    ReplyDelete
    Replies
    1. Greetings! I was using a static array as a "naive" example of preconditions being strengthened from within in a method. I agree, dependency injection would allow more flexibility in terms of specifying the preconditions of the "eat" method in our subclasses from within "dietary_constraint".

      However, I'd still like to note: to abide by LSP, you still need apply Design by Contract in this situation. You wouldn't want a subclass of Bird to make the preconditions of "eat" stronger by specifying a stricter "dietary_constraint". If it is "truly" a subclass of Bird, Pigeon would not impose stricter preconditions, regardless of how its implemented.

      Thanks for your input!

      Delete
  2. Very nice breakdown, thanks. I have to meditate on how to determine the invariants of a type and how to signal that in code. I hadn't thought of invariants as guaranteed *behavior* before; I was only thinking about method signatures.

    ReplyDelete