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 endInstances 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 == trueRemember, 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.
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.
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.
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:NilClassWe'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 MutantPigeonAnd our program is happy again!
The subtype should preserve invariants of the base type.
class Penguin < Bird def fly # no-op, do nothing end end bird = Penguin.new bird.fly! # @flying != trueLooks 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!" endAs 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!
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.
ReplyDeleteGreetings! 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".
DeleteHowever, 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!
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.
ReplyDeleteI give praise to Mr Benjamin enough for his help in securing a loan to buy our new home for our family. Benjamin was a wealth of information and he helped educate me and my family as to why a home loan was the best option for our particular situation. After conferring with Benjamin and our financial advisor everyone agreed that a home loan was the perfect solution.You can contact Mr Benjamin if you also looking for any kind of loan on Email/Whatsappemail: 247officedept@gmail.com Whatsapp: +1-989-394-3740
ReplyDelete