Object Oriented Design Principles

Nguyên lý thiết kế hướng đối tượng là gì?

Nguyên lý thiết kế phần mềm là một tập hợp các guidelines hướng dẫn cụ thể giúp chúng ta tránh tạo ra bad design. Các nguyên tắc được tổng hợp bởi Robert Martin, được viết trong phần “Agile Software Development: Principles, Patterns, and Practices”. Theo Robert Martin, có 3 đặc trưng quan trọng của một bad design chúng ta nên tránh:

  • Rigidity: Rất khó thay đổi bởi vì mỗi thay đổi ảnh hưởng qúa nhiều thành phần khác trong hệ thống.
  • Fragility: Khi bạn tạo một thay dổi, các phần không mong muốn của hệ thống bị ảnh hưởng.
  • Immobility: Khó sử dụng lại trong ứng dụng khác.

Có 5 nguyên tắc được đề cập mà chúng ta vẫn thường biết đến là S.O.L.I.D principles. Sự kết hợp giữa 5 nguyên tắc này giúp developer tạo tạo ra được những ứng dụng dễ bảo trì và mở rộng, tránh được những đoạn code rối rắm. Đó cũng là một phần trong Agile Software Development.

S.O.L.I.D là viết tắt của:

  • S: Single Responsibility Principle
  • O: Open-closed principle
  • L: Liskov substitution principle
  • I: Interface segregation principle
  • D: Dependency Inversion Principle

Single-responsibility Principle

A class should have only one reason to change.

Mỗi class chỉ có một lý do để thay đổi.

Trong trường hợp này, một nhiệm vụ (chức năng) được xem như một lý do thay đổi. Theo nguyên lý này nếu chúng ta có 2 lý do để thay đổi một class thì chúng ta nên chia chức năng đó làm 2 class riêng biệt. Mỗi class chỉ chịu trách nhiệm cho một nhiệm vụ. Trong tương lai nếu chúng ta cần thay đổi một nhiệm vụ nào đó thì chỉ cần sửa ở class đó thôi.

Nguyên lý này khá đơn giản và trực quan nhưng thực tế đôi khi khá khó để lấy áp dụng đúng. Giả sử chúng ta cần 1 object gửi mail. Chúng ta sử dụng Iemail interface và class Email như sau:

// single responsibility principle - bad example
interface IEmail {
        public function setSender($sender);
        public function setReceiver($receiver);
        public function setContent($content);
}

class Email implements IEmail {
        public function setSender($sender) {// set sender; }
        public function setReceiver($receiver) {// set receiver; }
        public function setContent($content) {// set content; }
}

Thoạt nhìn đoạn code trên rất là ổn đúng không? Phân tích kĩ hơn chúng ta có thể thấy IEmail interface và Email class có 2 trách nhiệm.

  • Chúng sẽ sử dụng một vài email protocol như POP3 hoặc IMAP
  • Content email có thể hỗ trợ nhiều định dạng khác nhau như text, HTML hoặc format khác

=> Nếu chúng ta vân giữ chúng trong một class thì mỗi thay đổi chức năng có thể ảnh hưởng tới những cái khác.

Cách giải quyêt là chúng ta sẽ tạo ra một interface và một class gọi IcontentContent để phân chia nhiệm vụ. Chỉ có mỗi nhiệm vụ tương ứng với mỗi class mang lại cho ta một thiết kết linh hoạt.

  • Thêm mới một protocol chỉ phải thay đổi Email class.
  • Thêm mới format gửi mail chỉ phải thay đổi Content class
// single responsibility principle - good example

interface IEmail {
    public function setSender($sender);
    public function setReceiver($receiver);
    public function setContent(IContent content);
}

interface IContent {
    public function getAsString(); // used for serialization
}

class Content implements IContent {
       public function getAsString();
}

class Email implements IEmail {
    public function setSender($sender) {// set sender; }
    public function setReceiver($receiver) {// set receiver; }
    public function setContent(IContent content) {// set content; }
}

Open closed principle

Software entities like classes, modules and functions should be open for extension but closed for modifications.

Thiết kế và viết code sao cho dễ dàng để mở rộng (kế thừa) mà không thay đổi code cũ đã có.

Một thiết ké ứng dụng thông minh luôn phải quan tâm đến việc sẽ có sự thay đổi thường xuyên trong quá trình phát triển và bào trì ứng dụng. Thông thường, sự thay đổi thường là thêm mới chức năng. Việc thay đổi này thì phải hạn chế tối đa sự thay đổi code cũ đã được test. Vì nếu code cũ bị thay đổi nhiều sẽ ảnh hưởng tới các chức năng khác đã có mà chúng ta không mong muốn. Theo nguyên lý này chúng ta phải thiết kế và viết code sao cho khi thêm chức năng mới thì code cũ ít bị thay đổi nhất bằng cách viết class mới kế thừa class cũ. Chúng ta xem xét đoạn code vẽ drawShape sau:

// Open-Close Principle - Bad example
class GraphicEditor
{
    public function drawShape(Shape $s)
    {
        if ($s->type == 1) {
            $this->drawRectangle($s);
        } elseif ($s->type == 2) {
            $this->drawCircle($s);
        }
    }
    
    public function drawCircle(Circle $r)
    {
        echo 'draw circle';
    }
    
    public function drawRectangle(Rectangle $r)
    {
         echo 'draw rectangle';
    }
}

class Shape
{
    public $type;
}

class Rectangle extends Shape
{
    public function __construct()
    {
            $this->type = 1;
    }
}

class Circle extends Shape
{
    public function __construct()
    {
            $this->type = 2;
    }
}

$circle = new Circle();
$rectagle = new Rectangle();
$graphicEditor = new GraphicEditor();
$graphicEditor->drawShape($circle);
echo "\n";
$graphicEditor->drawShape($rectagle);

Dựa vào đoạn code trên ta thấy có một số không ổn khi thêm một hình mới.

  • Unit test của GraphicEditor cũng phải chỉnh sửa và test lại.
  • Thêm thời gian để hiểu và chỉnh sửa lại code của GraphicEditor cho các type tương ứng.
  • Có thể ảnh hưởng tới chức năng đã viết ko mong muốn.

Vậy giải pháp ở đây là như thé nào? Chúng ta sẽ viết một phương thức abstract draw() trong class Shape. Việc thực thi code vẽ hình sẽ để ở class hình học cụ thể.

// Open-Close Principle - Good example
class GraphicEditor
{
    public function drawShape(Shape $s)
    {
        $s->draw();
    }
}

abstract class Shape
{
    public abstract function draw();
}

class Rectangle extends Shape
{
    public function draw()
    {
        echo 'draw the rectangle';
    }
}

class Circle extends Shape
{
    public function draw()
    {
        echo 'draw circle';
    }
}

$circle = new Circle();
$rectangle = new Rectangle();
$graphicEditor = new GraphicEditor();
$graphicEditor->drawShape($circle);
echo "\n";
$graphicEditor->drawShape($rectangle);

Như vậy theo thiết kế mới này thì các vấn đề đã được giải quyết bởi vì class GraphicEditor không bị thay đổi. Khi một hình mới được thêm vào thì :

  • Không cần viết thêm unit test.
  • Không cần thay đổi class GraphicEditor.
  • Do việc vẽ hình được chuyển vào từng class hình học cụ thể nên đã giảm được rủi ro ảnh hưởng tới chức năng cũ .

Liskov's Substitution principle

Derived types must be completely substitutable for their base types.

Class con phải được thay thế hoàn toàn cho class cha của nó.

Nguyên lý này như một sự mở rộng của Open-closed principle về hành vi. Lớp con kế thừa từ một class cha có thể thay thể class cha mà không thay đổi chức năng nào trên class cha. Chúng ta có 2 class RectangleSquare . Gỉa sử chúng Rectangle object được sử dụng một số nơi trong ứng dụng. Chúng ta mở rộng ứng dụng và thêm class Square.

// Violation of Liskov's Substitution Principle
class Rectangle
{
    protected $mWidth;
    protected $mHeight;
    
    public function setWidth($width)
    {
        $this->mWidth = $width;
    }
    
    public function setHeight($height)
    {
        $this->mHeight = $height;
    }
    
    public function getWidth()
    {
        return $this->mWidth;
    }
    
    public function getHeight()
    {
        return $this->mHeight;
    }
    
    public function getArea()
    {
        return $this->mWidth * $this->mHeight;
    }
}

class Square extends Rectangle
{
    public function setWidth($width)
    {
        $this->mWidth = $width;
        $this->mHeight = $width;
    }
    
    public function setHeight($height)
    {
        $this->mWidth = $height;
        $this->mHeight = $height;
    }
}

class LspTest
{
    public static function getNewRectangle()
    {
        // it can be an object returned by some factory ...
        return new Square();
    }
}

$r = LspTest::getNewRectangle();
$r->setWidth(5);
$r->setHeight(10);
// user knows that r it's a rectangle.
// It assumes that he's able to set the width and height as for the base class
echo $r->getArea();
// now he's surprised to see that the area is 100 instead of 50.

Từ ví dụ trên ta thấy. người dùng nghĩ rằng lúc mình set height, width là 5, 10 thì nó là hình chữ nhật nên diện tích = 50. Thực té kết quả lại là 10 * 10 = 100. Điều này đã vi phạm nguyên lý Liskov's Substitution Principle. Bài toán trên để hợp lý mình có thể bỏ hẳn class Square vì bản chất hình vuông cũng là một hình chữ nhật và sử dụng cách tính diện tích giống hệt nhau.

Interface Segregation Principle

Clients should not be forced to depend upon interfaces that they don't use

Chúng ta không nên implements một interface lớn với nhiều phương thức. Nên tách nhỏ thành các interface con theo chức năng nhỏ. Điều này tránh được implements các interface có các hàm không sử dụng.

// interface segregation principle - bad example
interface IWorker {
        public function work();
        
        public function eat();
}

class Worker implements IWorker{
        public function work() {
           // ....working
        }
        
        public function eat() {
           // ...... eating in launch break
        }
}

class SuperWorker implements IWorker{
        public function work() {
           //.... working much more
        }
        
        public function eat() {
           //.... eating in launch break
        }
}

Nếu thêm một class Robot mà không có chức năng eat thì chúng ta chia interface IWorker thành 2 interface IFeedableIWorkable.

// interface segregation principle - good example
interface IWorkable {
        public function work();
}

interface IFeedable{
        public function eat();
}

class Worker implements IWorkable, IFeedable{
        public function work() {
                // ....working
        }
        public void eat() {
                //.... eating in launch break
        }
}

class Robot implements IWorkable{
        public function work() {
                // ....working
        }
}

class SuperWorker implements IWorkable, IFeedable{
        public function work() {
                //.... working much more
        }
        public function eat() {
                //.... eating in launch break
        }
}

Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

High-level module không nên phụ thuộc low-level module. Cả 2 nên phụ thuộc vào một lớp trìu tương (abstraction).
Lớp trìu tượng không nên phụ thuộc lớp chi tiết. Lớp chi tiết thì phụ thuộc vào lớp trìu tượng.

Theo nguyên lý này thì chúng ta nên tách module high-level từ module low-level, tạo ra một lớp trìu tượng abstraction layer giữa lớp high-level và lớp low-level . Thay vì viết lớp trìu tượng (abstractions ) dựa trên lớp chi tiết (details), chúng ta nên viết lớp chi tiết dựa trên lớp trìu trượng. Nguyên lý này thường được gọi với các thuật ngữ Dependency Inversion hoặc Inversion of Control hoặc Inversion of Control Container trong các framework. Ví dụ chúng ta cần đọc ký tự từ bàn phím (keyboard) và viết chúng ra thiết bị in (printer device). High-level class có chứa logic là Copy class. Low-level class là KeyboardReaderPrinterWriter. Trong một bad design thì high-level class sử dụng trực tiếp và phụ thuộc nhiều vào low-level class. Trong trường hợp này chúng ta muôn thay đổi thiết kế trực tiếp xuất file như một class mới FileWriter thì chúng ta phải sửa class Copy. Nếu class Copy rất phức tạp thì việc sửa này rất mất thời gian và khó khăn. Để giải quyết thì chúng ta chỉ cần tạo một lớp trìu tượng giữa high-levellow-level class thôi và low-level class sẽ phụ thuộc vào lớp trìu tượng. Như vậy theo nguyên lý này thì thiết kế của chúng ta sẽ bắt đầu từ high-level module tới low-level module như sau: High Level Classes --> Abstraction Layer --> Low Level Classes. Phần này tưong đối khó nắm bắt nên chúng ta sẽ đi sâu vào ví dụ sau. Giả sử chung ta có một Manager class là high-level class và low-level class gọi là Worker. Chúng ta cần thêm một module mới để ứng dụng chúng ta mô phỏng sự thay đôi trong cấu trúc công ty được xác định bởi nhân lực cấp cao SupperWorker class.

// Dependency Inversion Principle - Bad example
class Worker
{
    public function work()
    {
    }
}

class Manager {
    public $worker;
    
    public function setWorker(Worker $w) {
        $this->worker = $w;
    }
    
    public function manage()
    {
        $this->worker->work();
    }
}

class SuperWorker
{
    public function work()
    {
        //.... working much more
    }
}

Gỉa sử class Manager khá là phức tạp, bây giờ chúng ta cần thay đổi nó để giới thiệu class mới SupperWorker. Ta có thể thấy có một số vấn đề sau:

  • Chúng ta phải thay đổi Manager class (nên nhớ rằng class này rất phức tạp và khó sửa`).
  • Một số chức năng hiện tại của Manager class sẽ có thể bị ảnh hưởng bởi sự sửa đổi này.
  • Unit test phải được test lại. => Tất cả vấn đề trên cần rất nhiều thời gian để xử lý bao gồm cả lỗi phát sinh trên chức năng cũ.

Bây giờ ứng dụng nguyên lý Dependency Inversion Principle chúng ta sẽ giải quyết bài toán này. Chúng ta sẽ tạo ra class Mananger, một interface Iworker. Worker class sẽ implements Iworker interface. Khi thêm SupperWorker class chúng ta chỉ cần để nó implements Iworker` interface. Không cần thay đổi một dòng code cũ nào. Các vấn đề này sinh ở trên đều đã được giải quyết.

// Dependency Inversion Principle - Good example
interface IWorker
{
    public function work();
}

class Worker implements IWorker
{
    public function work()
    {
        // ....working
    }
}

class SuperWorker  implements IWorker
{
    public function work()
    {
        //.... working much more
    }
}

class Manager
{
    public $worker;
    public function setWorker(IWorker $w)
    {
        $this->worker = $w;
    }
    
    public function manage()
    {
        $this->worker->work();
    }
}

Kết luận

Việc hiểu rõ và ứng dụng 5 nguyên lý lập trình này thực sự cần thiết và giúp code của bạn dễ mở rộng, bảo trì và chuyên nghiệp hơn. Nó cũng là cơ sở để chúng ta có thể nghiên cứu và áp dụng các Design Pattern. Cũng là 5 nguyên lý nền tảng để trở thành một tay code cứng (SOLID).

Tham khảo

https://scotch.io/bar-talk/s-o-l-i-d-the-first-five-principles-of-object-oriented-design http://www.oodesign.com/design-principles.html