Nguyên tắc thay thế Liskov (Liskov Substitution Principle - LSP) là một trong những nguyên tắc thiết kế quan trọng trong lập trình hướng đối tượng. Nguyên tắc này được đặt theo tên của Barbara Liskov, người đã giới thiệu khái niệm này trong một bài báo năm 1987. Ý tưởng đằng sau LSP là đối tượng của một lớp cha có thể được thay thế bằng đối tượng của một lớp con mà không ảnh hưởng đến tính chính xác của chương trình. Nói cách khác, nếu bạn có một đoạn mã hoạt động với một lớp cha, bạn có thể thay thế lớp cha đó bằng bất kỳ lớp con nào của nó và chương trình vẫn hoạt động đúng.
Để giải thích nguyên tắc này bằng một ví dụ, hãy tưởng tượng chúng ta đang xây dựng một chương trình để mô phỏng các loài động vật khác nhau. Chúng ta bắt đầu với một lớp Animal có một số thuộc tính và phương thức cơ bản mà tất cả các loài động vật đều có chung:
class Animal { constructor(name) { this.name = name; } eat(food) { console.log(`${this.name} đang ăn ${food}`); } sleep() { console.log(`${this.name} đang ngủ`); }
}
Giả sử chúng ta muốn tạo một lớp con của Animal cho một loại động vật cụ thể, ví dụ như Mèo. Chúng ta có thể làm điều này bằng kế thừa (inheritance):
class Cat extends Animal { constructor(name) { super(name); } meow() { console.log(`${this.name} kêu meo meo`); }
}
Trong trường hợp này, Cat kế thừa tất cả các thuộc tính và phương thức của Animal, nhưng chúng ta đã thêm một phương thức mới cụ thể cho mèo. Đây là một ví dụ khá đơn giản về kế thừa, nhưng thực tế nó có thể dẫn đến các vấn đề với Nguyên tắc thay thế Liskov.
Ví dụ, hãy tưởng tượng chúng ta có một đoạn mã hoạt động với một đối tượng Animal để cho ăn và ngủ:
function feedAndSleep(animal) { animal.eat('một ít thức ăn'); animal.sleep();
}
Chúng ta có thể gọi hàm này với một thể hiện Animal hoặc bất kỳ lớp con nào của nó, bao gồm cả Cat:
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
Mọi thứ đều dễ dàng nhưng giờ chúng ta thêm một lớp con khác của Animal, như RobotAnimal:
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ờ`); }
}
Trong trường hợp này, RobotAnimal vẫn là một lớp con của Animal, nhưng chúng ta có thể thấy nó hoạt động khác nhau ở một số điểm quan trọng. Ví dụ, thay vì ngủ, nó đi vào chế độ chờ và nạp lại pin. Nếu chúng ta cố gắng gọi hàm feedAndSleep với một RobotAnimal, nó vẫn hoạt động (vì RobotAnimal kế thừa phương thức eat từ Animal), nhưng phương thức sleep sẽ không hoạt động đúng:
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ờ"
Điều này gây ra vấn đề vì hàm feedAndSleep giả định rằng bất kỳ thể hiện Animal nào cũng sẽ hoạt động theo cách nhất định khi nó đang ngủ. Bằng cách phá vỡ giả định này với lớp RobotAnimal, chúng ta đã vi phạm Nguyên tắc thay thế Liskov.
Vậy làm sao để khắc phục vấn đề này? Một cách là sử dụng "Composition Over Inheritance". Thay vì tạo một lớp con mới của Animal cho mỗi loại động vật, chúng ta có thể tạo một lớp riêng biệt cho mỗi hành vi mà chúng ta muốn thêm vào. Ví dụ, chúng ta có thể tạo một lớp SleepBehavior:
class SleepBehavior { sleep() { console.log('đang ngủ'); }
}
Sau đó, chúng ta sử dụng lớp này để thêm hành vi ngủ vào các lớp Animal và RobotAnimal của chúng ta:
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ờ'); }
}
Bây giờ, thay vì sử dụng kế thừa để thêm hành vi, chúng ta đang sử dụng tổ hợp - tạo các lớp riêng biệt có thể kết hợp để tạo ra các loại đối tượng khác nhau. Khi chúng ta tạo một thể hiện của Animal hoặc RobotAnimal, chúng ta cũng tạo một thể hiện của SleepBehavior hoặc RobotSleepBehavior tương ứng. Khi chúng ta gọi phương thức sleep trên các đối tượng này, nó sẽ gọi phương thức sleep của đối tượng hành vi ngủ.
Với phương pháp này, chúng ta vẫn có thể sử dụng hàm feedAndSleep, nhưng nó hoạt động với bất kỳ đối tượng nào có hành vi ngủ, bất kể lớp cha của nó là gì:
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ờ"
Bằng cách sử dụng Composition Over Inheritance, chúng ta đã tránh vi phạm Nguyên tắc thay thế Liskov. Chúng ta có thể thêm các hành vi mới vào đối tượng của mình bằng cách tạo các lớp mới và kết hợp chúng một cách khác nhau, mà không phải lo lắng về hành vi của các đối tượng khác trong thứ bậc. Điều này dẫn đến mã linh hoạt, module và dễ bảo trì hơn.