Series SOLID cho thanh niên code CỨNG: Liskov substitution principle

Giới thiệu

Đây là đây là bài viết thứ 3 trong series “SOLID cho thanh niên code cứng”. Ở bài viết này, mình sẽ nói về Liskov Substitution Principle – Nguyên lý Thay Thế Lít Kốp (LSP).

  1. Single Responsibility Principle
  2. Open/Closed Principle
  3. Liskov Substitution Principle
  4. Interface Segregation Principle
  5. Dependency Inversion Principle

Nội dung nguyên lý:

Trong một chương trình, các object của class con có thể thay thế class cha mà không làm thay đổi tính đúng đắn của chương trình

Giải thích nguyên lý

Xin cảnh báo trước một chút là nguyên lý này hơi trừu tượng và khó hiểu (các bác developer nước ngoài cũng tranh cãi khá nhiều về nó), do đó mình sẽ cố gắng giải thích một cách đơn giản nhất có thể. Nếu đọc lần đầu không hiểu, các bạn cố gắng đọc kĩ lại vài lần nhé, nếu vẫn không hiểu thì… chịu khó google tìm bài khác vậy.

Để giữ tính đúng đắn của chương trình, class con phải thay thế được class cha. Nói dễ hiểu là thế này: Ngày xửa ngày xưa, hẳn bạn nào cũng có 1 cái Individual Portable Brick Game, tên tiếng Việt là máy chơi game xếp hình “thần thánh”. Thuở ấy, mỗi lần máy hết pin, mình lại xin mấy nghìn ra ngoài hàng mua pin con ó gắn vào chơi tiếp. Một lần nọ, hết pin mà không có tiền, mình đi lượm mấy cục pin con heo gắn vào chơi tạm. Không ngờ gắn pin vào xong, vừa hí hửng bật máy lên thì máy bị cháy vì điện thế của pin hơn bình thường. Thế là đi tong luôn cái máy chỉ vì… tiếc tiền mua pin.

may-tro-choi-dien-tu-brick-game (3)

Theo lý thuyết, class PinConHeo là con của class Pin, khi ta dùng PinConHeo gắn vào làm Pin thì máy phải chạy bình thường. Tuy nhiên trong trường hợp của mình, class Pin Con Heo đã vi phạm LSP vì đã gây lỗi khi dùng thay cho class Pin (Sau này, mình gặp một trải nghiệm tương tự với class PhimConH.., nhưng mà thôi để lần khác kể vậy).

Ví dụ minh họa

Mình sẽ đưa ra 2 ví dụ thường gặp về việc vi phạm LSP:

Ví dụ thứ nhất, class con quăng exception khi gọi hàm

Giả sử, ta muốn viết một chương trình để mô tả các loài chim bay. Đại bàng, chim sẻ, vịt bay được, nhưng chim cánh cụt không bay được. Do chim cánh cụt cũng là chim, ta cho nó kế thừa class Bird. Tuy nhiên, vì cánh cụt không biết bay, khi gọi hàm bay của chim cánh cụt, ta sẽ quăng NoFlyException.


public class Bird {
public virtual void Fly() { Console.Write("Fly"); }
}
public class Eagle : Bird {
public override void Fly() { Console.Write("Eagle Fly"); }
}
public class Duck : Bird {
public override void Fly() { Console.Write("Duck Fly"); }
}
public class Penguin : Bird {
public override void Fly() { throw new NoFlyException(); }
}
var birds = new List { new Bird(), new Eagle(), new Duck(), new Penguin() };
foreach(var bird in birds) bird.Fly();
// Tới pengiun thì lỗi vì cánh cụt quăng Exception

view raw

bird.cs

hosted with ❤ by GitHub

Ta tạo 1 mảng chứa các loài chim rồi duyệt các phần tử. Khi gọi hàm Fly của class Penguin, hàm này sẽ quăng lỗi. Class Penguin gây lỗi khi chạy, không thay thế được class cha của nó là Bird, do đó nó đã vi phạm LSP.Penguins

Ví dụ thứ 2, class con thay đổi hành vi class cha

Đây là ví dụ kinh điển về hình vuông và hình chữ nhật mà mọi người thường dùng để giải thích LSP, mình chỉ viết và giải thích lại đôi chút.

Đầu tiên, hãy cùng đọc đoạn code dưới đây. Ta có 2 class cho hình vuông và hình chữ nhật. Ai cũng biết hình vuông là hình chữ nhật có 2 cạnh bằng nhau, do đó ta có thể cho class Square kế thừa class Rectangle để tái sử dụng code.


public class Rectangle
{
public int Height { get; set; }
public int Width { get; set; }
public virtual void SetHeight(int height)
{
this.Height = height;
}
public virtual void SetWidth(int width)
{
this.Width = width;
}
public virtual int CalculateArea()
{
return this.Height * this.Width;
}
}
public class Square : Rectangle
{
public override void SetHeight(int height)
{
this.Height = height;
this.Width = height;
}
public override void SetWidth(int width)
{
this.Height = width;
this.Width = width;
}
}

view raw

rec.cs

hosted with ❤ by GitHub

Do hình vuông có 2 cạnh bằng nhau, mỗi khi set độ dài 1 cạnh thì ta set luôn độ dài của cạnh còn lại. Tuy nhiên, khi chạy thử, hành động này đã thay đổi hành vi của của class Rectangle, dẫn đến vi phạm LSP.


// code from http://prasadhonrao.com/solid-principles-liskov-substitution-principle-lsp/
Rectangle rect = new Rectangle();
rect.SetHeight(10);
rect.SetWidth(5);
System.Console.WriteLine(rect.CalculateArea()); // Kết quả là 5 * 10
// Below instantiation can be returned by some factory method
Rectangle rect1 = new Square();
rect1.SetHeight(10);
rect1.SetWidth(5);
System.Console.WriteLine(rect1.CalculateArea());
// Kết quả là 5 x 5. Nếu đúng phải là 10×5, vì diện tích 1 hình chữ nhật là dài x rộng
// Class Square sửa hành vi của class cha Rectangle, set cả dài và rộng về 5

view raw

lsp.cs

hosted with ❤ by GitHub

Trong trường hợp này, để code không vi phạm LSP, ta phải tạo 1 class cha là class Shape, sau đó cho SquareRectangle kế thừa class Shape này.

specialgrams

Lưu ý và kết luận

Đây là nguyên lý… dễ bị vi phạm nhất, nguyên nhân chủ yếu là do sự thiếu kinh nghiệm khi thiết kế class. Thuông thường, design các class dựa theo đời thật: hình vuông là hình chữ nhật, chim cánh cụt là chim. Tuy nhiên, không thể bê nguyên văn mối quan hệ này vào code. Hãy nhớ 1 điều:

Trong đời sống, A là B (hình vuông là hình chữ nhật, chim cánh cụt là chim) không có nghĩa là class A nên kế thừa class B. Chỉ cho class A kế thừa class B khi class A thay thế được cho class B.

Pin con heo là pin nhưng không thay thế được cho pin, chim cánh cụt là chim nhưng không thay thế được cho chim, do đó 2 ví dụ này vi phạm LSP.

Nguyên lý này ẩn giấu trong hầu hết mọi đoạn code, giúp cho code linh hoạt và ổn định mà ta không hề hay biết. Ví dụ như trong C#, ta có thể chạy hàm foreach với List, ArrayList, LinkedList bởi vì chúng cùng kế thừa interface IEnumerable.  Các class List, ArrayList, .. đã được thiết kế đúng LSP, chúng có thể thay thế cho IEnumerable mà không làm hỏng tính đúng đắn của chương trình.

liskov_substitution_principle_thumb

Một số tài liệu để tham khảo thêm:

11 thoughts on “Series SOLID cho thanh niên code CỨNG: Liskov substitution principle”

  1. Hình như bạn nhầm lẫn:
    // Class Rectangle(theo mình là Square) sửa hành vi của class cha, set cả dài và rộng về 5
    Square và Triangle(theo mình là Rectangle) kết thừa class Shape

    Like

  2. Cám ơn bác. Thấy ít comment quá nên e comment phát cho rôm rả. :v
    Chỗ em làm giờ ai cũng biết trang này của bác rồi. Trước nói chuyện hỏi SOLID là cái gì mấy ông ấy bảo vào code dạo mà xem. =))

    Liked by 2 people

  3. Theo mình (chỉ theo mình thôi nhé) khái niệm và ví dụ của bạn chưa chính xác (nếu có gì sai mong bạn khai sáng cho mình)

    – Theo khái niệm trên wikip thì đúng là nó nói là “đối tượng của kiểu cha được thay thế bằng đối tượng của kiểu con” : khái niệm này dường như khá chung chung nên không thể áp dụng.
    – Theo các link còn lại thì nó ghi khái niệm là “Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.” –> Đây là khái niệm hoàn toàn chính xác.

    Cần phải phân biệt rõ ràng giữa Class và Object/Instance trước khi làm rõ khái niệm này.
    Class không là gì cả, nó chỉ là cấu trúc mà thôi. Thứ quang trọng nhất là instance, tức là đối tượng thật nằm trong bộ nhớ. Coder thường sử dụng thứ gọi là reference/pointer để điều khiển nó.

    Theo đúng quy trình kế thừa thì cho dù trong tay bạn đang nắm một reference kiểu class như Object ( lớp cơ sở nhất, class trên cùng) thì bạn vẫn là đang sử dụng một đối tượng thật sự trong vùng nhớ chứ không phải là kiểu của cái reference kia.

    Trong ví dụ về con chim bạn nói, cái chưa đúng ở đây là cách gom nhóm/phân loại theo cấu trúc kế thừa chứ không phải là không làm được. Điểm chung nhất của chim là gì, sau đó đến bay và không bay .. và cụ thể loại chim gì.

    Trong ví vụ về Rectange và Square của bạn và trong các ref link cũng không nói chính xác vấn đề.
    Khi bạn new Square tức là bạn đã tạo mới một Square cho dù bất kể thứ trả về bạn cầm giữ nó là cái gì thì trong bộ nhớ nó vẫn là Square. Nên kết quả của nó là chính xác.

    Trong thực tế sử dụng thường lấy reference của interface từng loại để thực hiện cập nhật theo từng nhóm hành vi mà không quan tâm đến đối tượng thực sự.

    Like

  4. Ví dụ hình vuông và chữ nhật là sai rồi. Hình chữ nhật bạn setHeight và setWidth riêng thì lý do gì xuống hình vuông, method setHeight lại có set witdh ở trong đấy và ngược lại (vi phạm nguyên lý SR, tên hàm là setHeight mà lại có cả set Width)
    Nếu bạn làm giống y như hình chữ nhật thì code chạy tốt chả vấn đề gì cả.

    Like

  5. Em đọc bao nhiêu blog vẫn không hiểu, đọc tới bài của anh, nó như chiếc cầu nối gắn kết lại tất cả. E thông rồi, e cảm ơn a.

    Like

Leave a reply to Tin Cancel reply