Lập trình hướng đối tượng với PHP và những điều cần biết (Phần 3)

Mục lục

Nội dung

Lập trình hướng đối tượng (OPP) có 4 tính chất cơ bản, nếu chưa rõ 4 tính chất này thì các bạn có thể đọc phần 1 trong loạt bài viết này. Với các tính chất này của OOP giúp chúng ta xây dựng được các chương trình giải quyết được nhiều vấn đề cụ thể khác nhau trong thế giới thực. Tuy nhiên, để vận dụng và phối hợp các tính chất này với nhau để tăng hiệu quả của ứng dụng thì quả thật cũng phải cần một thời gian khá dài và không phải ai cũng nắm được. Một trong những nguyên tắc (chỉ dẫn) để giúp chúng ta xây dựng được các ứng dụng OOP hiệu quả hơn đó là phương pháp thiết kế SOLID (đây là từ viết tắt), nó là một bộ 5 chỉ dẫn đã được nhắc tới từ lâu bởi các nhà phát triển phần mềm và được tổng hợp và phát biểu thành nguyên tắc bởi Robert C. Martin, cũng chính là tác giả của cuốn sách The Clean Coder nổi tiếng. Năm nguyên tắc (chỉ dẫn) này bao gồm:

  • S: Single Responsibility principle (nguyên lý đơn nhiệm).
  • O: Open-Closed principle (nguyên lý mở rộng - hạn chế).
  • L: Liskov substitution principle (nguyên lý thay thế Liskov).
  • I: Interface segregation principle (nguyên lý giao diện phân biệt - hay phân tách interface).
  • D: Dependency inversion principle (nguyên lý nghịch đảo phụ thuộc).

Đây là những nguyên lý được đúc kết bởi máu xương vô số developer, rút ra từ hàng ngàn dự án thành công và thất bại. Một project áp dụng những nguyên lý này sẽ có code dễ đọc, dễ test, rõ ràng hơn. Và việc quan trọng nhất là việc maintainace code sẽ dễ hơn rất nhiều (Ai có kinh nghiệm trong ngành IT đều biết thời gian code chỉ chiếm 20-40%, còn lại là thời gian để maintainance: thêm bớt chức năng và sửa lỗi). Nắm vững những nguyên lý này, đồng thời áp dụng chúng trong việc thiết kế + viết code sẽ giúp bạn tiến thêm 1 bước trên con đường thành senior nhé.

8. Các phương pháp thiết kế hướng đối tượng (SOLID).

S: Single Responsibility principle (nguyên lý đơn nhiệm).

Mỗi class chỉ nên chịu trách nhiệm về một nhiệm vụ cụ thể nào đó mà thôi. Hay chỉ có thể sửa đổi class với 1 lý do duy nhất.

Giả sử rằng nhân viên của một công ty phần mềm cần phải làm 1 trong 3 việc sau đây: lập trình phần mềm (developer), kiểm tra phần mềm (tester), bán phần mềm (salesman). Mỗi nhân viên sẽ có một chức vụ và dựa vào chức vụ sẽ làm công việc tương ứng. Khi đó bạn có nên thiết kế lớp “Employee” với thuộc tính “position” và 3 phương thức developSoftware(), testSoftware() và saleSoftware() không?

class Employee
{
    public $position;
 
    public function developSoftware(){};
    public function testSoftware(){};
    public function saleSoftware(){};
}

Có lẽ bạn sẽ thấy rằng cách trên là nhanh và có thể xử lý được bài toán ở trên. Nhưng câu trả lời cho cách thiết kế trên là không. Thử hình dung nếu có thêm một chức vụ nữa là quản lí nhân sự, không lẽ ta phải vào sửa lại lớp “Employee” và thêm phương thức mới vào? Nếu có thêm 10 chức vụ nữa thì sao? Ngoài ra, khi đối tượng được tạo ra sẽ dư thừa rất nhiều phương thức: Developer thì đâu cần dùng hàm testSoftware() và saleSoftware() đúng không nào, lỡ may dùng lầm phương thức cũng sẽ gây hậu quả khôn lường.

Nguyên tắc này nói rằng: mỗi lớp thực hiện 1 trách nhiệm. Ta sẽ tạo 1 lớp trừu tượng là “Employee” có phương thức là “working()”, từ đây bạn kế thừa ra 3 lớp cụ thể là Developer, Tester và Salesman. Ở mỗi lớp này bạn sẽ implement phương thức “working()” cụ thể tuy theo nhiệm vụ của từng người.

abstract class Employee
{
    abstract public function working();
}
class Developer extends Employee
{
    public function working()
    {
        // TODO
    }
}
// Tương tự cho 2 class Tester và Salesman

Với cách thiết kế này thì ta đã tuân thủ nguyên tắc S trong SOLID, mỗi class Develop, Tester và Salesman sẽ chỉ đảm nhiệm một nhiệm vụ duy nhất.

O: Open-Closed principle (nguyên lý mở rộng - hạn chế).

Có thể thoải mái mở rộng 1 class, nhưng hạn chế sửa đổi class đó.

Nhìn vào phát biểu trên, chúng ta thấy rằng, có hai vấn đề cần lưu tâm trong nguyên lí này:

  • Hạn chế sửa đổi: Ta không nên chỉnh sửa source code của một module hoặc class có sẵn, vì sẽ ảnh hưởng tới tính đúng đắn của chương trình.
  • Ưu tiên mở rộng: Khi cần thêm tính năng mới, ta nên kế thừa và mở rộng các module/class có sẵn thành các module con lớn hơn. Các module/class con vừa có các đặc tính của lớp cha (đã được kiểm chứng đúng đắn), vừa được bổ sung tính năng mới phù hợp với yêu cầu.

Hình dung rằng “tiện nghi sống” của bạn đang là 1 căn nhà, bây giờ bạn muốn có thêm 1 tính năng là “hồ bơi” để thư giãn. Bạn có 2 cách để làm điều này:

  • Cách 1: thay đổi hiện trạng của căn nhà, xây thêm 1 tầng nữa để làm hồ bơi.
  • Cách 2: không làm thay đổi căn nhà đang có, mua thêm 1 mảnh đất cạnh nhà bạn và xây hồ bơi ở đó.

Mặc dù cả 2 cách đều giải quyết được vấn đề nhưng cách đầu tiên có vẻ rất thiếu tự nhiên và kì cục. Cách này làm thay đổi hiện trạng của căn nhà, và nếu không cẩn thận có thể làm hư luôn những thứ đang có. Cách thứ 2 an toàn hơn rất nhiều và đáp ứng tốt được nhu cầu muốn có hồ bơi của bạn.

Nguyên tắc này có ý rằng: không được thay đổi hiện trạng của các lớp có sẵn, nếu muốn thêm tính năng mới, thì hãy mở rộng bằng cách kế thừa để xây dựng class mới. Làm như vậy sẽ tránh được các tình huống làm hỏng tính ổn định của chương trình đang có.

Bây giờ giả sử bạn muốn xây dựng một chương trình tạo ưu đãi cho customer. Ban đầu bạn chỉ có 2 loại customer là 'normal' và 'vip'. Chúng ta sẽ giảm cho khách hàng 'vip' và 'normal' theo một mức nào đó bằng phương thức getDiscount().

class Customer
{
    protected $typeCustomer; // 'normal' hoặc 'vip'
    
    public function setTypeCustomer($type)
    {
        if ($type == 'vip') {
            $this->typeCustomer = 'vip';
        } else {
            $this->typeCustomer = 'nomal';
        }
     }
   
   public function getDiscount($total)
   {
       if ($this->typeCustomer == 'vip') {
           return $total * 0.2;
       } else {
           return $total * 0.1;
       }
   }
}

Bây giờ ta muốn thêm một kiểu customer mới là "silver", thì ta cần thay đổi lại hàm "setTypeCustomer()" và "getDiscount()" để tính toán lại cho loại customer mới này, như thế là ta đã thay đổi class "Customer". Nếu giờ muốn thêm nhiều loại customer mới nữa thì sao? Ta lại phải tiếp tục thay đổi class "Customer". Như thế liệu có đảm bảo các đoạn code khác đã sử dụng class này có hoạt động bình thường. Đây chính là điều mà nguyên lý này muốn nói để giải quyết vấn đề này. Ta có thể giải quyết vấn đề mở rộng bằng cách sau:

abstract class Customer
{
    abstract public function getDiscount($total);
}

class VipCustomer extends Customer
{
    public function getDiscount($total)
    {
        return $total * 0.2;
    }
}
class SilverCustomer extends Customer
{
    public function getDiscount($total)
    {
        return $total * 0.15;
    }
}
class NormalCustomer extends Customer
{
    public function getDiscount($total)
    {
        return $total * 0.1;
    }
}

Với các trên thì ta thì khi thêm một loại customer mới thì ta có thể extends class "Customer", có thể thoải mái mở rộng các loại customer mà không cần thay đổi lại class "Customer". Lớp “Customer” bây giờ được hiểu là “Đóng” với mọi thay đổi, nhưng nó luôn “Mở” đối với việc mở rộng các thuộc tính hay phương thức của lớp “Customer".

L: Liskov substitution principle (nguyên lý thay thế Liskov).

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ờ ta có ví dụ sau: 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ẻ 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".

class Bird 
{
    public function fly()
    {
        return "Fly";
    }
}
class EagleBird extends Bird
{
    public function fly()
    {
        return "Eagle Fly";
    }
}
class SparrowBird extends Bird
{
    public function fly()
    {
        return "Sparrow Fly";
    }
}
class PenguinBird extends Bird
{
    public function fly()
    {
        throw new NoFlyException();
    }
}

$birds = [new Bird(), new EagleBird(), new SparrowBird(), new PenguinBird()];
foreach ($birds as $bird) {
    $bird->fly();
}

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 "PenguinBird", hàm này sẽ quăng lỗi. Class "PenguinBird" 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.

I: Interface segregation principle (nguyên lý giao diện phân biệt - hay phân tách interface).

Thay vì dùng 1 interface lớn, ta nên tách thành nhiều interface nhỏ, với nhiều mục đích cụ thể

Interface là một lớp rỗng chỉ chứa khai báo về tên phương thức không có khai báo về thuộc tính hay thứ gì khác và các phương thức này cũng là rỗng. Bởi vậy bất kỳ lớp nào sử dụng lớp interface đều phải định nghĩa các phương thức đã khai báo ở lớp interface.

Để thiết kế một hệ thống linh hoạt, dễ thay đổi, các module của hệ thống nên giao tiếp với nhau thông qua interface. Mỗi module sẽ gọi chức năng của module khác thông qua interface mà không cần quan tâm tới implementation bên dưới. Như đã nói ở trên, do interface chỉ chứa khai báo rỗng về method, khi một class implement một interface, class đó phải implement toàn bộ các method được khai báo trong interface đó.

Đây là nguyên lý dễ hiểu nhất trong SOLID, các bạn chỉ đọc code một chút là hiểu ngay! Giả sử ta muốn viết một chương trình giới thiệu thuộc tính của các loài động vật. Động vật nào cũng có thể ăn, uống, ngủ, ta thiết kế interface "AnimalInterface" như sau:

interface AnimalInterface
{
    public function eat();
    public function drink();
    public function sleep();
}
class Dog implements AnimalInterface {
   public function eat () {} 
   public function drink () {} 
   public function sleep () {} 
}
class Cat  implements AnimalInterface {
   public function eat () {} 
   public function drink () {} 
   public function sleep () {} 
}

Khi ta muốn thêm 1 số loài động vật mới và tính năng vào, ta phải thêm có thêm method vào trong interface như: bơi lội, bay, săn mồi, … Điều này làm interface phình to ra. Khi một loài động vật kế thừa interface, nó phải implement luôn cả những hàm không dùng đến. Khi thêm method vào interface "AnimalInterface", những class cũ như Dog, Cat đều phải implement những method mới nên mất thời gian. Giải pháp trong tình huống này là tách interface "AnimalInterface" ra thành các interface nhỏ như sau:

interface AnimalInterface
{
    public function eat();
    public function drink();
    public function sleep();
}
interface BirdInterface
{
    public function fly();
}
interface FishInterface
{
    public function swim();
}

// Các class chỉ cần kế thừa những interface có chúc năng chúng cần
class Dog implements AnimalInterface
{
   public function eat() {} 
   public function drink() {} 
   public function sleep() {} 
}
class Bird implements AnimalInterface,  BirdInterface
{
   public function eat() {} 
   public function drink() {} 
   public function sleep() {} 
   public function fly() {}
}
class Fish implements AnimalInterface,  FishInterface
{
   public function eat() {} 
   public function drink() {} 
   public function sleep() {} 
    public function swim() {}
}

Để có thể phân tách một interface lớn thành các interface nhỏ một cách hợp lý, các bạn nên áp dụng nguyên tắc đầu tiên trong nguyên tắc SOLID trong bài viết này. Tuy nhiên, đôi khi việc tách ra nhiều interface có thể làm tăng số lượng interface, tăng số lượng class, ta cần cân nhắc lợi hại trước khi áp dụng nhé.

D: Dependency inversion principle (nguyên lý nghịch đảo phụ thuộc).

  • Các module cấp cao không nên phụ thuộc vào các modules cấp thấp. Cả 2 nên phụ thuộc vào abstraction.
  • Interface (abstraction) không nên phụ thuộc vào chi tiết, mà ngược lại. Các class giao tiếp với nhau thông qua interface, không phải thông qua implementation.

Nguyên tắc này nói cho bạn rằng bạn không nên viết code gắn chặt với nhau bởi vì sẽ là cơn ác mộng cho việc bảo trì khi ứng dụng trở lên lớn dần. Nếu một class phụ thuộc một class khác, bạn sẽ cần phải thay đổi class đó nếu một trong những class phụ thuộc phải thay đổi. Chúng ta nên cố gắng viết các class ít phụ thuộc nhất có thể.

Chúng ta đều biết 2 loại đèn: đèn tròn và đèn huỳnh quang. Chúng cùng có đuôi tròn, do đó ta có thể thay thế đèn tròn bằng đèn huỳnh quanh cho nhau 1 cách dễ dàng. Ở đây, interface chính là đuôi tròn, implementation là bóng đèn tròn và bóng đèn huỳnh quang. Ta có thể swap dễ dàng giữa 2 loại bóng vì ổ điện chỉ quan tâm tới interface (đuôi tròn), không quan tâm tới implementation.

Giả sử chúng ta có một hệ thống thông báo sau khi lưu vài thông tin vào DB.

class Email
{
    public function sendEmail()
    {
        // code to send mail
    }
}

class Notification
{
    private $email;
    public function notification()
    {
        $this->email = new Email();
    }

    public function promotionalNotification()
    {
        $this->email->sendEmail();
    }
}

Giờ class Notification hoàn toàn phụ thuộc vào class Email, vì nó chỉ gửi một loại của thông báo. Nếu bạn muốn thêm một cách thông báo mới như SMS chẳng hạn? Chúng ta cũng phải thay đổi cả hệ thống thông báo? Đó gọi là liên kết chặt (tightly coupled). Bạn có thể làm gì để giúp nó giảm phụ thuộc vào nhau. OK, bạn xem ví dụ sau đây:

interface MessengerInterface
{
    public function sendMessage();
}
class Email implements MessengerInterface
{
    public function sendMessage()
    {
        // code to send email
    }
}

class SMS implements MessengerInterface
{
    public function sendMessage()
    {
        // code to send SMS
    }
}

class Notification
{
    private $messenger;
    public function notification(MessengerInterface $messenger)
    {
        $this->messenger = $messenger;
    }
    public function doNotify()
    {
        $this->messenger->sendMessage();
    }
}

Như vậy lớp Notification không còn phụ thuộc vào các lớp cụ thể (SMS hay Email) mà chỉ phụ thuộc vào 1 lớp trừu tượng MessengerInterface, ta có thể thêm các lớp cụ thể (SMS hay Email) mà không hề ảnh hưởng đến lớp Notification.

Kết luận

Mình xin kết thúc bài viết tại đây, bài viết này mình tổng hợp từ nhiều nguồn và cố gắng làm cho các bạn dễ hiểu nhất có thể. Bài viết này chưa thể tổng hợp hết các vấn đề khi thiết kế theo nguyên lý SOLID, tuy nhiên cũng đã tổng hợp lại những điểm cần lưu ý nhất trong việc ứng dụng nó. Việc hiểu và áp dụng được nguyên lý SOLID cũng cần một thời gian nghiên cứu và ứng dụng nhiều mới có thể nhuẩn nhuyến được. Hy vọng bài viết cung cấp hoặc củng cố thêm kiến thức của các bạn về OPP trong lập trình.