Chào các bạn, Đây là bài viết tiếp theo trong series bài viết về design pattern nói về nguyên tắc SOLID. Ở bài viết đầu tiên chúng ta đã tìm hiểu 2 trong số 5 nguyên tắc của SOLID. Bạn có thể xem lại bài viết tại đây
- Single Responsibility Principle
- Open/Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion
Liskov Substitution Principle (LSP)
Một định nghĩa ngắn gọn :
Subtypes must be substituable for their base types.
điều đó có nghĩa là
Kiểu con phải có thể thay thế được cho kiểu cơ sở.
Nguyên tắc này được đưa ra bởi bà Barbara Liskov vào năm 1988. Phát biểu của nguyên lý này như sau:
Điều chúng ta cần là một mô hình thỏa mãn đặc tính sau đây của sự thay thế:
Với mỗi object o1 thuộc kiểu S, có một object o2 thuộc kiểu T mà với tất cả các chương trình P được định nghĩa với T, các hành vi của P không thay đổi khi o2 được thay thế bởi o1, và khi đó S là một kiểu con của T.
Giả sử chúng ta có một hàm f thuộc class cơ sở B. Và chúng ta có một class D được tạo ra dựa vào B mà khi chúng ta gọi hàm f với object của D theo cái cách làm với B, f hoạt động sai. Khi đó, D sẽ vi phạm LSP. Để hiểu rõ hơn về nguyên tắc này chúng ta sẽ đi đến ví dụ bên dưới.
Ví dụ về sự vi phạm LSP
public class Rectangle { private double _width; private double _height; public double Width { set => _width = value; } public double Height { set => _height = value; } public double Area() { return _width * _height; } }
Ví dụ về hình chữ nhật ( Rectangle
) có 2 thuộc tính là Width
và Height
. có một phương thức tính diện tích Area()
. Mọi thứ hoạt động hoàn hảo như chưa từng được hoàn hảo. Khách hàng happy, PM happy, mọi người happy. Bỗng dưng một ngày, khách hàng có thêm một hình nữa là hình vuông ( Square
). Mọi người thường nói rằng thừa kế là một quan hệ IS-A (xem thêm về Is-a tại đây). Và hình vuông là một dạng đặc biệt của hình chữ nhật. Mọi người sẽ nghĩ ngay đến việc kế thừa lại Rectangle
để đỡ code cho mệt, chỉ cần set width = height
là sẽ có ngay hình vuông ngon lành. Việc kế thừa lại cũng đảm bảo nguyên tắc Open/Closed Principle (OCP). Thế là a/e coder hì hục viết thêm 1 class Square
như sau:
public class Square : Rectangle { public new double Width { set { base.Width = base.Height = value; } } public new double Height { set { base.Height = base.Width = value; } } }
Trong class Square
chỉ có nhiệm vụ set width = height và ngược lại. Mọi thứ có vẻ good đấy. Giả sử có một hàm nhận vào tham số là đối tượng Rectangle như sau:
public void UpdateDimesion(Rectangle r) { r.Width = 25; r.Height = 10; }
và ta sẽ khởi tạo 2 đối tượng một cái là Rectangle
và một cái là Square
. Kết quả thực thi đoạn code trên như sau:
Đời không như là mơ khiến bạn phải thốt lên: What the hell is this? Đoạn code cho Rectangle
chạy đúng còn Square
thì lại sai. Lý do ở đây là việc thay đổi height = 10 cho square
không làm thay đổi width của square
. Kết luận ở đây là Square
không thể thay thế cho Rectangle
. Quan hệ giữa Rectangle
và Square
đã vi phạm nguyên tắc LSP. Có một cách để fix chỗ này dễ dàng là khai báo virtual
cho thuộc tính width height ở class cơ sở, ở đây là Rectangle
và khai báo override
cho thuộc tính width height ở lớp kế thừa, ở đây là Square
. Chương trình sẽ trở thành như sau:
public class Rectangle { private double _width; private double _height; public virtual double Width { set => _width = value; } public virtual double Height { set => _height = value; } public double Area() { return _width * _height; } } public class Square : Rectangle { public override double Width { set { base.Width = base.Height = value; } } public override double Height { set { base.Height = base.Width = value; } } }
Nhưng không may ở đây chúng ta đã vi phạm nguyên tắc OCP. Đúng là đời không như là mơ. Chúng ta đã sai lầm trong thiết kế về việc không định nghĩa virtual
cho width height. Khó mà nhận ra điều đó ngay từ đầu cho đến khi có sự xuất hiện của Square
. Chúng ta phải sửa sai ngay bằng việc vi phạm nguyên tắc OCP. Mọi thứ có vẻ hoạt động tốt đấy. Bât kì những gì bạn làm với class Square
sẽ tương tự với Rectangle
vì đặc tính hình học của nó vẫn không thay đổi. Bạn có thể truyền Square
vào các hàm nhận tham số là Rectangle
. Ví dụ ta có 1 hàm g()
như sau:
public void g(Rectangle r) { r.Width = 5; r.Height = 4; if (r.Area() != 20) throw new Exception("Bad area!"); }
Hàm này sẽ throw Exception khi bạn truyền một object có kiểu Square
.
Quay lại bản chất vấn đề, việc chấp nhận Square
là trường hợp đặc biệt của Rectangle
cũng là sai lầm.
Bản chất vấn đề
Khi xem xét một thiết kế là đúng hay không, chúng ta không nên chỉ giới hạn trong một phạm vi nhất định. Chúng ta cần xem xét những giả thiết hợp lý có thể xảy ra bởi người dùng của thiết kế đó.
Ai biết được các giả thiết đó sẽ như thế nào? Hầu hết các giả thiết đó không thể dự đoán trước được. Thay vào đó, nếu chúng ta cố dự đoán tất cả, chúng ta sẽ rơi vào bẫy của “Sự phức tạp không cần thiết – Needless Complexity”. Do vậy, cũng như tất cả các nguyên tắc khác, cách tốt nhất là làm thỏa mãn LSP ở mức tối thiểu, lờ đi tất cả những giả thiết có thể cho đến khi bắt đầu có dấu hiệu của một thiết kế dễ vỡ “Fragility”.
Để giải quyết bài toán bên trên. Chúng ta nên tạo 1 class cơ sở Shape
và cho Rectangle
và Square
kế thừa từ Sharpe
.
Kết luận
Đây là một trong số những nguyên tắc của SOLID hay dễ mắc phải nếu bạn thiếu kinh nghiệm trong thiết kế và không hiểu rõ bản chất vấn đề.
Một số dấu hiệu vi phạm LSP:
- Các lớp dẫn xuất có các phương thức ghi đè phương thức của lớp cha nhưng với chức năng hoàn toàn khác
- Các lớp dẫn xuất có phương thức ghi đè phương thức của lớp cha là 1 phương thức rỗng
- Các phương thức bắt buộc kế thừa từ lớp cha ở lớp dẫn xuất nhưng không được sử dụng
- Phát sinh ngoại lệ trong phương thức của lớp dẫn xuất
Tài liệu tham khảo:
https://github.com/levinhtxbt/design-patterns/tree/master/DesignPatterns