SOLID – Nguyên tắc 3: Tính khả dĩ thay thế – Liskov substitution principle (LSP)

Chúng ta tiếp tục tìm hiểu bộ nguyên tắc lập trình SOLID – là các nguyên tắc giúp chúng ta thiết kế chương trình và viết code tốt hơn: code trong sáng và rành mạch, dễ bảo trì, dễ mở rộng trong tương lai. SOLID gồm 5 nguyên tắc lập trình sau đây:

  1. Single Responsibility principle.
  2. Open-Closed principle.
  3. Liskov substitution principle.
  4. Interface segregation principle.
  5. Dependency inversion principle.

Chúng ta đã tìm hiểu được 2 nguyên tắc đầu tiên trong bộ nguyên tắc này. Hôm nay, chúng ta sẽ tiếp tục phân tích và tìm hiểu nguyên tắc thứ ba: Liskov substitution principle – Tính khả dĩ thay thế.

Nguyên tắc thứ 3 này nói gì?

Phát biểu: Các instance của lớp con có thể thay thế được instance lớp cha mà vẫn đảm bảo tính đúng đắn của chương trình.

(objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program)

Cũng có tài liệu nói về nguyên tắc này như sau: “Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it”. Mình không dịch ra tiếng Việt vì ý nghĩa trong tiếng Anh đôi khi trọn vẹn hơn, mọi ngời cố gắng hiểu nhé 🙂

Nguyên tắc này trông có vẻ hơi khó hiểu một chút, mình sẽ thử diễn giải nó theo quan điểm cá nhân một cách đơn giản hơn xem sao. Theo mình nghĩ, thì nguyên lí này sẽ giúp chúng ta giữ được tính đúng đắn một việc: đảm bảo tính đa hình trong lập trình hướng đối tượng.

Hình dung trong thực tế một ví dụ thế này: Bạn lên kế hoạch sẽ đi du lịch trong dịp hè, do di chuyển dài ngày và đi nhiều người nên bạn quyết định sẽ thuê một chiếc xe du lịch để phục vụ chuyến đi. Bạn đến một cửa hàng cung cấp dịch vụ cho thuê xe hơi du lịch, theo quảng cáo thì tất cả các xe đều có đầy đủ chức năng: xe có thể chạy được (đương nhiên), điều hòa mát lạnh, có loa thông minh với khả năng kết nối với điện thoại, chở được 5-7 người, …

sikkimtaratours_14.jpg
Figure 1 – Dịch vụ cho thuê xe hơi với đầy đủ tiện nghi

Bạn đặt thuê một chiếc xe, bạn không cần quan tâm tới nhãn hiệu xe hay loại xe, miễn là có đầy đủ chức năng như đã giới thiệu. Hợp đồng được kí kết. Tới ngày nhận xe, bạn nhận được một chiếc xe như sau: Xe 7 chỗ, ngoại hình rất đẹp … Mọi thứ gần như OK, thế nhưng … loa của xe chỉ có thể nghe được đĩa CD chứ không thể kết nối với điện thoại. Như vậy là đối tượng (instance) chiếc xe này đã không giống với mô tả ban đầu (abstraction). Bạn cảm thấy không hài lòng về điều đó.

Đây là một trong những ví dụ điển hình về việc vi phạm nguyên lí Liskov substitution.

Áp dụng nguyên tắc này trong lập trình như thế nào?

Như đã nói ở trên, nguyên tắc này đảm bảo các instance của lớp con có thể thay thế instance của lớp cha mà chương trình vẫn chạy ổn định, khi mở rộng phần mềm của mình bằng các lớp con kế thừa, chúng ta cần đảm bảo rằng các lớp con này có thể chạy được và chạy đúng những functions mà lớp cha đã cung cấp trước đó.

Chúng ta sẽ tiếp tục mở rộng ví dụ về Student ở bài trước để có thể nhìn thấy tính chất của nguyên tắc này nhé.

Ở bài trước chúng ta đã có lớp cha là lớp Student, chúng ta cũng có 2 lớp con kế thừa đại diện cho 2 loại sinh viên khác nhau đó là lớp AdvancedStudent (Sinh viên tài năng) và lớp ForeignStudent (Sinh viên nước ngoài). Để chuẩn bị mở rộng ví dụ, mình thêm một lớp NormalStudent kế thừa từ lớp Student đại diện cho các đối tượng sinh viên chính quy, như vậy cây tổ chức của chúng ta như sau:

17842521_449057285431013_838788299_n.png
Figure 2 – Mô hình tổ chức các lớp sinh viên

Chúng ta sẽ xem xét mở rộng một tính năng như sau: ứng cử vào chức bí thư Đoàn khoa chẳng hạn. Theo quy trình hoạt động trong thực tế, giả định rằng chỉ có sinh viên trong nước mới được ứng cử vào chức bí thư Đoàn, tức là các ForeignStudent không được phép ứng cử, vậy chúng ta sẽ xử lí như thế nào?

Cách giải quyết vi phạm qui tắc Liskov:

Có một suy nghĩ thường hay xảy đến với chúng ta khi giải quyết tình huống này, đó làm thêm hàm ứng cử runForSecretary() vào lớp cha là Student, tất cả lớp con đều được kế được lớp này, duy chỉ có lớp ForeignStudent là sẽ không có chức năng này. Để ngăn việc các lập trình viên khác không hiểu nghiệp vụ và sử dụng hàm này trong lớp ForeignStudent, ta sẽ quăng exception nếu có ai gọi chạy function này:

class Student
{
   // other functions and properties ...

   int runForSecretary()
   {
      // do something 
   }
}

class ForeignStudent extends Student
{
   // other functions and properties ...

   int runForSecretary()
   {
      // ALERT - DO NOT USE THIS FUNCTION
      throw Exception("Not Allowed Action!!!");
   }
}

class NormalStudent extends Student
{
   // other functions and properties ...

   int runForSecretary()
   {
      // do something here ...
   }
}

class AdvancedStudent extends Student
{
   // other functions and properties ...

   int runForSecretary()
   {
      // do something here ...
   }
}

Khi đó giả sử chúng ta có một danh sách sinh viên, đầu mỗi học kì chúng ta sẽ cho sinh viên ứng cử chức vụ bí thư Đoàn khoa, lúc đó chương trình sẽ trông như sau:

List<Student> allStudentsRunForSecretary = new List<Student>();
allStudentsRunForSecretary.add(new NormalStudent());
allStudentsRunForSecretary.add(new ForeignStudent());

// ...

//Run for Secretary
foreach (Student in allStudentsRunForSecretary)
{
   Student.runForSecretary();
}

Nếu trong trường chỉ có sinh viên trong nước (Sinh viên tài năng, sinh viên chính quy) thì chương trình chạy ổn, không có lỗi biên dịch, và ta nghĩ rằng ta đã lập trình đúng. Bỗng một ngày trường có thêm sinh viên nước ngoài, chương trình bị crash. Cách thiết kế như thế này tiền ẩn nguy cơ chạy ổn trong ngắn hạn, nhưng gặp lỗi nghiêm trọng trong dài hạn khi mở rộng chương trình. Đây chính là điểm mấu chốt mà nguyên tắc Liskov substitution muốn nói với chúng ta.  

Vậy thì chúng ta sẽ giải quyết tình huống này như thế nào đây?

Áp dụng quy tắc Liskov substitution để giải quyết vấn đề

Như chúng ta thấy ở trên, cách thiết kế mà chúng ta vừa áp dụng đã không sử dụng “tính đa hình” trong lập trình hướng đối tượng một cách đúng đắn. Để vừa giải quyết được yêu cầu của phần mềm, vừa làm chương trình ổn định trong lâu dài, chúng ta cần đảm bảo rằng chương trình sẽ báo lỗi ngay với chúng ta khi vừa biên dịch, tránh tình trạng bị quăng Exception như ví dụ trên.

Ở đây, mình sẽ đề xuất một cách giải quyết đó là tách hàm runForSecretary() ra một interface riêng, chỉ những class nào được sử dụng các chức năng trong đó thì chúng ta mới implement function rõ ràng. Code mới sẽ có thiết kế như sau:

Thiết kế lớp cơ sở và interface cần thiết như sau:

class Student
{
   // other functions and properties ...

   string getStudentInfoJson()
   {
      return json_encode( array(name, age) );
   }
}

interface NationalSocialActivityInterface
{
  int runForSecretary();
}

Sau đó thực hiện các hành động kế thừa và implement như sau:

class NormalStudent extends Student implements NationalSocialActivityInterface
{
   int runForSecretary()
   {
      // do something here ...
   }
}

class AdvancedStudent extends Student implements NationalSocialActivityInterface
{
   int runForSecretary()
   {
      // do something here ...
   }
}

class ForeignStudent extends Student
{
   // ...
}

Đến lúc này, thì chức năng ứng cử vào chức bí thư đoàn trường sẽ được cài đặt như sau:

List<NationalSocialActivityInterface> allStudentsRunForSecretary = new List<NationalSocialActivityInterface>();
allStudentsRunForSecretary.add(new NormalStudent());

//Dòng này sẽ báo lỗi biên dịch ngay lập tức
allStudentsRunForSecretary.add(new ForeignStudent());   

// ...

//Run for Secretary
foreach (Student in allStudentsRunForSecretary)
{
   Student.runForSecretary();
}

Với cách thiết kế ứng dụng như thế này, chúng ta sẽ phát hiện được những sinh viên không được phép ứng cử vào chức bí thư Đoàn khoa ngay từ lúc biên dịch chương trình, vì thế chúng ta sẽ chắc chắn không bỏ sót một trường hợp hi hữu nào đó (như ví dụ ở trên đã trình bày).

Một chút bàn luận và suy nghĩ cá nhân

Nếu bạn thắc mắc rằng tại sao chúng ta lại phải có những thiết kế rất rườm rà và phức tạp như vậy, trong khi làm đơn giản như cách đầu tiên (ở phần “cách thiết kế sai”) cũng được mà, mình có thể trả lời rằng: nếu chương trình của bạn nhỏ, bạn không có nhu cầu mở rộng và bạn chắc chắn kiểm soát được mọi tình huống, bạn có thể thiết kế như thế. Tuy nhiên …

Tuy nhiên, chúng ta đang bàn tới những ứng dụng dễ mở rộng, dễ bảo trì, … những ứng dụng như vậy thường sẽ có rất nhiều người lập trình chứ không chỉ một mình bạn, bạn có đảm bảo rằng những người khác cũng sẽ “không bị quên” mà kiểm soát tốt logic đó không? Bạn có đảm bảo rằng khi phát sinh những kiểu sinh viên mới (Sinh viên diện trao đổi, sinh viên dạng tại chức, …) thì mọi lập trình viên đều nhớ đến logic xử lí đó? Nếu chúng ta thiết kế theo kiểu cũ thì khi lập trình viên quên xử lí, thì rất có thể ứng dụng của ta sẽ vượt qua vòng test (nếu bộ dữ liệu test không đủ) nhưng lại bị lỗi lúc khách hàng chạy thực tế, rất nguy hiểm. Nếu chúng ta áp dụng quy tắc khả dĩ thay thế – Liskov substitution, thì chúng ta sẽ tránh được lỗi ngay khi biên dịch, quá hay phải không nào?

Tất nhiên, như mình đã nói, đây cũng chỉ là một nguyên tắc chứ không phải luật, những nguyên tắc trong SOLID có thể giúp ích cho ta nhưng ta cũng nên cân nhắc kĩ càng trước khi sử dụng chúng. Không thể nhắm mắt áp dụng đại mà lúc nào cũng thành công đâu.

Let’s enjoy coding 🙂

Tham khảo:

  1. https://www.codeproject.com/Articles/703634/SOLID-architecture-principles-using-simple-Csharp
  2. http://thaotrinh.info/nguyen-ly-solid-trong-lap-trinh-huong-doi-tuong-va-vi-du-su-dung-c-p2/
  3. https://scotch.io/bar-talk/s-o-l-i-d-the-first-five-principles-of-object-oriented-design

5 thoughts on “SOLID – Nguyên tắc 3: Tính khả dĩ thay thế – Liskov substitution principle (LSP)

  1. Pingback: [ SOLID là gì ] Nguyên tắc 4: Chia nhỏ interface – Interface segregation principle (ISP) – Webbynat

  2. Pingback: [ SOLID là gì ] Tìm hiểu SOLID để trở thành Dev chất! – Webbynat

  3. Pingback: [ SOLID là gì ] Nguyên tắc 1: Đơn nhiệm – Single Responsibility principle (SRP). – Webbynat

  4. Pingback: [ SOLID là gì ] Nguyên tắc 2: Đóng và Mở – Open / Closed principle (OCP) – Webbynat

  5. Pingback: [ SOLID là gì ] Nguyên tắc 5: Tính tương thích động – Dependency Inversion principle (DIP) – Webbynat

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s