The Liskov Substitution Principle (LSP) is one of the important design principles in object-oriented programming. This principle is named after Barbara Liskov, who introduced the concept in a 1987 paper. The idea behind LSP is that the object of a superclass can be replaced with the object of a subclass that is does not affect the correctness of the program. In other words, if you have a piece of code that works with a superclass, you can replace that superclass with any of its subclasses and the program will still work properly.
To explain this principle with an example, imagine we are building a program to simulate different animals. We start with an Animal class that has some basic properties and methods that all animals have in common:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | class Animal { constructor(name) { this.name = name; } eat(food) { console.log(`${this.name} đang ăn ${food}`); } sleep() { console.log(`${this.name} đang ngủ`); } } |
Let’s say we want to create a subclass of Animal for a specific type of animal, for example Cat. We can do this by inheritance:
1 2 3 4 5 6 7 8 9 10 | class Cat extends Animal { constructor(name) { super(name); } meow() { console.log(`${this.name} kêu meo meo`); } } |
In this case, Cat inherits all the properties and methods of Animal, but we have added a new method specific to cat. This is a fairly simple example of inheritance, but it can actually lead to problems with the Liskov Substitution Principle.
For example, imagine we have a piece of code that works with an Animal object for feeding and sleeping:
1 2 3 4 5 | function feedAndSleep(animal) { animal.eat('một ít thức ăn'); animal.sleep(); } |
We can call this function with an instance of Animal or any of its subclasses, including Cat:
1 2 3 4 5 6 | const animal = new Animal('Động vật không xác định'); feedAndSleep(animal); // hoạt động tốt const cat = new Cat('Mèo con'); feedAndSleep(cat); // hoạt động tốt |
Everything is easy but now we add another subclass of Animal, like RobotAnimal:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | class RobotAnimal extends Animal { constructor(name) { super(name); } recharge() { console.log(`${this.name} đang nạp lại pin`); } sleep() { console.log(`${this.name} đang ở chế độ chờ`); } } |
In this case, RobotAnimal is still a subclass of Animal, but we can see it behave differently in some important points. For example, instead of sleeping, it goes into standby mode and recharges the battery. If we try to call feedAndSleep with a RobotAnimal, it still works (because RobotAnimal inherits the eat method from Animal), but the sleep method won’t work properly:
1 2 3 | const robot = new RobotAnimal('Chó Robot'); feedAndSleep(robot); // in "Chó Robot đang ăn một ít thức ăn" và "Chó Robot đang ở chế độ chờ" |
This causes problems because the feedAndSleep function assumes that any Animal instance will behave in a certain way while it is sleeping. By breaking this assumption with the RobotAnimal class, we violate the Liskov Substitution Principle.
So how to fix this problem? One way is to use “Composition Over Inheritance”. Instead of creating a new subclass of Animal for each type of animal, we can create a separate class for each behavior we want to add. For example, we can create a SleepBehavior class:
1 2 3 4 5 6 | class SleepBehavior { sleep() { console.log('đang ngủ'); } } |
We then use this class to add sleep behavior to our Animal and RobotAnimal classes:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | class Animal { constructor(name) { this.name = name; this.sleepBehavior = new SleepBehavior(); } eat(food) { console.log(`${this.name} đang ăn ${food}`); } sleep() { this.sleepBehavior.sleep(); } } class RobotAnimal extends Animal { constructor(name) { super(name); this.sleepBehavior = new RobotSleepBehavior(); } } class RobotSleepBehavior { sleep() { console.log('đang ở chế độ chờ'); } } |
Now, instead of using inheritance to add behavior, we are using Composition – creating separate classes that can be combined to create different types of objects. When we create an instance of Animal or RobotAnimal, we also create an instance of SleepBehavior or RobotSleepBehavior respectively. When we call sleep method on these objects, it will call sleep behavior object’s sleep method.
With this method, we can still use the feedAndSleep function, but it works with any object that has a sleep behavior, regardless of its superclass:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | function feedAndSleep(animal) { animal.eat('một ít thức ăn'); animal.sleep(); } const animal = new Animal('Động vật không xác định'); feedAndSleep(animal); // hoạt động tốt const cat = new Animal('Mèo kêu meo meo'); cat.sleepBehavior = new CatSleepBehavior(); feedAndSleep(cat); // hoạt động tốt const robot = new RobotAnimal('Chó robot'); feedAndSleep(robot); // hoạt động tốt, in ra "Chó robot đang ăn một ít thức ăn" và "đang ở chế độ chờ" |
By using Composition Over Inheritance, we have avoided violating the Liskov Substitution Principle. We can add new behaviors to our objects by creating new classes and combining them differently, without worrying about the behavior of other objects in the hierarchy. This results in more flexible, modular and maintainable code.