SOLID 5 nguyên lý vàng trong thiết kế hướng đối tượng

PHP là một ngôn ngữ lập trình hướng đối tượng với 4 đặc tính mà ai trong chúng ta cũng đều biết khi học lập trình.

  • Tính trìu tượng
  • Tính kế thừa
  • Tính đa hình
  • Tính đóng gói

Tuy nhiên, bên cạnh những đặc tính ấy, có những nguyên lý vàng trong thiết kế và xây dựng hướng đối tượng, các kiến thức này không được học tại trường lớp mà chỉ đơn giản là những đúc kết từ rất nhiều dự án thất bại của các lập trình viên kỳ cựu. Các nguyên lý thiết kế hướng đối tượng này là gì mà nghe có vẻ cần thiết vậy, chúng ta sẽ cùng tìm hiểu trong phần tiếp theo.

1. SOLID là gì?

SOLID là viết tắt các chữ cái đầu của 5 nguyên lý trong thiết kế hướng đối tượng được đưa ra bởi Robert C. Martin. Các nguyên lý này giúp cho lập trình viên phát triển các ứng dụng dễ dàng duy trì và mở rộng. Nắm vững các nguyên tắc trong SOLID, áp dụng chúng trong thiết kế và viết code, bạn đã sẵn sàng trở thành một lão làng trong nghề code.

SOLID là 5 nguyên lý sau trong thiết kế hướng đối tượng

  • S – Single-responsiblity principle (Nguyên lý đơn chức năng)
  • O – Open-closed principle (Nguyên lý đóng mở)
  • L – Liskov substitution principle (Nguyên lý thay thế Liskov)
  • I – Interface segregation principle (Nguyên lý phân tách interface)
  • D – Dependency Inversion Principle (Nguyên lý đảo ngược phụ thuộc)

Mới nhìn sơ qua, chúng ta có cảm giác các nguyên lý trên đây có vẻ phức tạp, cao siêu và khó áp dụng. Nhưng không, các nguyên lý này rất dễ hiểu, chúng ta cùng tìm hiểu chi tiết về chúng nhé.

1.1 Nguyên lý đơn chức năng (Single-responsiblity principle)

A class should have one and only one reason to change, meaning that a class should have only one job.

Một Class chỉ nên có duy nhất một lý do để thay đổi nghĩa là class chỉ chịu trách nhiệm về một công việc nào đó.

Nguyên lý đơn chức năng - Single-responsiblity principle

Trong hình mình họa trên, một người đầu bếp có rất nhiều tay có thể làm nhiều việc cùng lúc, nếu có một người như vậy thì thật tuyệt vời. Tuy nhiên, chúng ta nhận thấy một điều, người đầu bếp không thể làm mọi việc đều tốt đẹp, hơn nữa nếu người đầu bếp bị đau tay, tất cả các món sẽ ngừng phục vụ và nhà hàng sẽ phải đóng cửa. Thay vào đấy, nhà hàng này nên có nhiều người làm bếp, mỗi người một nhiệm vụ riêng một chức năng riêng, ai nghỉ thì chỉ thay một người khác vào là vẫn hoạt động bình thường.

Trong lập trình cũng vậy, mỗi module (class) chỉ nên đảm nhận một chức năng, như vậy khi cần nâng cấp, chỉnh sửa code cũng rất đơn giản không ảnh hưởng đến các module khác. Nếu mỗi class thực hiện nhiều nhiệm vụ, nó sẽ thường xuyên phải thay đổi vì cần thực hiện mỗi nhiệm vụ ngày một tốt hơn. Chúng ta cùng bắt đầu với ví dụ về tính toán diện tích các hình (hình chữ nhật, hình tròn):

class Circle {
    public $radius;
    public function __construct($radius) {
        $this->radius = $radius;
    }
}
class Square {
    public $length;
    public function __construct($length) {
        $this->length = $length;
    }
}

Mỗi lớp trên có một phương thức khởi tạo để thiết lập các tham số cần thiết, tiếp theo chúng ta tạo ra một lớp AreaCalculator với nhiệm vụ tính tổng diện tích các hình này:

class AreaCalculator {
    protected $shapes;
    public function __construct($shapes = array()) {
        $this->shapes = $shapes;
    }
    public function sum() {
        // logic to sum the areas
    }
    public function output() {
        return implode('', array(
            "",
                "Sum of the areas of provided shapes: ",
                $this->sum(),
            ""
        ));
    }
}

Để sử dụng AreaCalculator chúng ta tạo ra một instance và truyền cho nó mảng các hình cần tính tổng diện tích và hiển thị kết quả ra:

$shapes = array(
    new Circle(2),
    new Square(5),
    new Square(6)
);
$areas = new AreaCalculator($shapes);
echo $areas->output();

Vấn đề với phương thức output() trong AreaCalculator, nó thực hiện các logic để hiển thị dữ liệu đầu ra, nếu một người dùng muốn hiển thị dữ liệu ở dạng json hoặc một dạng nào đó sẽ phải như thế nào? Tất cả các logic trong lớp AreaCalculator đều không tuân thủ nguyên lý đơn chức năng, lớp AreaCalculator chỉ nên tính tổng diện tích các hình, nó không nên quan tâm đến việc người dùng muốn định dạng dữ liệu JSON hoặc HTML.
Để giải quyết vấn đề này, chúng ta có thể tạo ra lớp SumCalculatorOutputter và sử dụng nó để kiểm soát việc hiển thị tổng diện tích các hình.

$shapes = array(
    new Circle(2),
    new Square(5),
    new Square(6)
);

$areas = new AreaCalculator($shapes);
$output = new SumCalculatorOutputter($areas);

echo $output->JSON();
echo $output->HAML();
echo $output->HTML();
echo $output->JADE();

Như vậy từ một class chúng ta đã tách thành hai class để mỗi class nhận một chức năng duy nhất.

1.2 Nguyên lý đóng mở – Open-closed Principle

Objects or entities should be open for extension, but closed for modification.

Các đối tượng hoặc thực thể có thể mở rộng nhưng không được thay đổi.

Nguyên lý đóng mở - Open-closed principle

Nghe qua thấy nguyên lý có sự mâu thuẫn do thường chúng ta thấy rằng dễ mở rộng là phải dễ thay đổi, đằng nay dễ mở rộng nhưng không cho thay đổi. Minh họa cho nguyên lý này, chú hề phải dùng đến cưa để thay đổi kích thước chân khi có một đôi giầy mới. Một ví dụ khác trong thực tế về khẩu súng, để khẩu súng bắn được chính xác hơn có thể lắp thêm ống ngắm, để giảm tiếng súng lắp thêm ống giảm thanh, để có thể đánh cận chiến lắp thêm lưỡi lê… Như vậy, khẩu súng đã được thiết kế tuân thủ nguyên lý đóng mở, dễ dàng mở rộng thêm tính năng mà không cần phải thay đổi bản thân khẩu súng.

Quay lại vấn đề này trong lập trình, các class phải được thiết kế sao cho dễ dàng mở rộng mà không cần thay đổi. Chúng ta tiếp tục với ví dụ ở phần trên, phương thức sum() trong lớp AreaCalculator:

public function sum() {
    foreach($this->shapes as $shape) {
        if(is_a($shape, 'Square')) {
            $area[] = pow($shape->length, 2);
        } else if(is_a($shape, 'Circle')) {
            $area[] = pi() * pow($shape->radius, 2);
        }
    }
    return array_sum($area);
}

Nếu bạn muốn phương thức sum() có thể tính tổng diện tích của các hình khác nữa, chúng ta phải thêm một khối if/else vào và như vậy nó vi phạm nguyên lý Open-closed. Một cách để phương thức sum() có thể bỏ đi được logic trong tính toán diện tích của mỗi hình là đưa nó vào trong class định nghĩa từng hình.

class Square {
    public $length;
    public function __construct($length) {
        $this->length = $length;
    }
    public function area() {
        return pow($this->length, 2);
    }
}

Chúng ta cũng làm tương tự với lớp Circle, một phương thức area được thêm vào.

class Circle {
    public $radius;
    public function __construct($radius) {
        $this->radius = $radius;
    }
    public function area() {
        return pi() * pow($this->radius, 2);
    }
}

Như vậy việc tính toán tổng các hình sẽ được thực hiện đơn giản như sau:

public function sum() {
    foreach($this->shapes as $shape) {
        $area[] = $shape->area();
    }
    return array_sum($area);
}

Bạn thấy đấy, nếu chúng ta cần tính toán tổng diện tích có cả hình tam giác, chúng ta sẽ tạo thêm một class Triangle cho hình tam giác mà không cần thay đổi nội dung class tính toán. Tuy nhiên, một vấn đề khác nảy sinh là làm sao chúng ta biết đối tượng được truyền vào AreaCalculator là một instance của các class hình và nếu nó là một hình thì nó có phương thức area hay chưa? Đơn giản là tạo ra một interface mà các class về hình sẽ thực hiện interface này.

interface ShapeInterface {
    public function area();
}
class Circle implements ShapeInterface {
    public $radius;
    public function __construct($radius) {
        $this->radius = $radius;
    }
    public function area() {
        return pi() * pow($this->radius, 2);
    }
}

Khi đó trong phương thức sum của AreaCalculator chúng ta kiểm tra nếu các hình đưa vào không phải là một instance của ShapeInterface thì bắn ra lỗi:

public function sum() {
    foreach($this->shapes as $shape) {
        if(is_a($shape, 'ShapeInterface')) {
            $area[] = $shape->area();
            continue;
        }
        throw new AreaCalculatorInvalidShapeException;
    }
    return array_sum($area);
}

1.3 Nguyên lý thay thế – Liskov substitution principle

Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.

Giả sử q(x) là một thuộc tính của đối tượng x là thực thể của lớp T thì q(y) cũng phải là thuộc tính của đối tượng y là thực thể của lớp S với S là lớp con của lớp T. Hay nói một cách khác nguyên lý này không cho phép các lớp con phá vỡ định nghĩa lớp cha.

Nguyên lý thay thế Liskov substitution principle

Trong hình ảnh minh họa, sử dụng kem chống nắng để vẽ lên mặt một cái kính, nó cũng có hình dáng của cái kính và đạt một số tiêu chí như đeo được trên mặt, chống được nắng… nhưng nó đã phá vỡ định nghĩa của class kính. Chúng ta tiếp tục với ví dụ về tính diện tích các hình:

Để tính tổng thể tích các vật thể chúng ta tạo thêm class VolumeCalculator được mở rộng từ AreaCalculator.

class VolumeCalculator extends AreaCalulator {
    public function __construct($shapes = array()) {
        parent::__construct($shapes);
    }
    public function sum() {
        // Thực hiện tính toán thể tích và trả về một mảng kết quả
        return array($summedData);
    }
}

Và lớp SumCalculatorOutputter:

class SumCalculatorOutputter {
    protected $calculator;

    public function __constructor(AreaCalculator $calculator) {
        $this->calculator = $calculator;
    }

    public function JSON() {
        $data = array(
            'sum' => $this->calculator->sum();
        );

        return json_encode($data);
    }

    public function HTML() {
        return implode('', array(
            '',
                'Sum of the areas of provided shapes: ',
                $this->calculator->sum(),
            ''
        ));
    }
}

Tiếp theo chúng ta cùng thực hiện đoạn code sau:

$areas = new AreaCalculator($shapes);
$volumes = new AreaCalculator($solidShapes);

$output = new SumCalculatorOutputter($areas);
$output2 = new SumCalculatorOutputter($volumes);

Khi gọi đến phương thức HTML trên đối tượng $output2 chúng ta nhận được lỗi E_NOTICE thông báo rằng lỗi khi chuyển đổi mảng sang chuỗi, như vậy thiết kế class VolumeCalculator đã vi phạm nguyên lý thay thế Liskov. Để sửa lỗi này, thay vì trả về một mảng trong VolumeCalculator, chúng ta trả về một giá trị:

public function sum() {
    // Thực hiện tính toán thể tích và trả về kết quả
    return $summedData;
}

$summedData sau khi thay đổi phương thức sum() có thể là một số dạng float, double hoặc integer.

Chúng ta cùng đến với một ví dụ thứ 2, đây là một ví dụ kinh điển mà mọi người rất hay dùng mỗi khi mô tả về nguyên lý Liskov. Trong hình học, hình vuông là hình chữ nhật, nó là trường hợp đặc biệt của hình chữ nhật. Tuy nhiên, nếu class Square được mở rộng từ class Rectangle, thì class Square sẽ có những biểu hiện hơi lạ. Ví dụ các phương thức setWidth và setHeight trong Rectangle, nó đúng trong Rectangle nhưng nếu tham chiếu sang Square, hai phương thức này không có ý nghĩa bởi nó được sử dụng để thiết lập cho một đối tượng khác không phải là hình vuông. Trong trường hợp này, Square không tuân theo nguyên lý thay thế Liskov và sự trìu tượng trong kế thừa từ Rectangle là không ổn.

class Rectangle{
    public $width;
    public $height;
    public function setWidth($width) {
        $this->width = $width;
    }
    public function setHeight($height) {
        $this->height= $height;
    }
    public function area() {
        return $this->width * $this->height;
    }
}

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

$rect = new Rectangle();
$rect->setWidth(10);
$rect->setHeight(20);
echo $rect->area(); // Kết quả là 10 * 20

$square = new Square(); 
$square->setWidth(10);
$square->setHeight(20);
echo $square->area(); // Kết quả 20 * 20, như vậy class Square đã sửa định nghĩa class cha Rectangle

1.4 Nguyên lý phân tách – Interface segregation principle

A client should never be forced to implement an interface that it doesn’t use or clients shouldn’t be forced to depend on methods they do not use.

Một class không nên thực hiện một interface mà nó không dùng đến hoặc không nên phụ thuộc vào một phương thức mà nó không sử dụng. Để làm điều này, thay vì một interface lớn bao trùm chúng ta tách thành nhiều interface khác nhau.

Nguyên lý phân tách interface

Trong hình ảnh minh họa về nguyên lý phân tách interface, chúng ta thấy các ổ cắm. Nếu như phích cắm của bạn chỉ có hai chân tròn, bạn nên sử dụng ổ cắm 2 chân tròn, việc dùng ổ cắm đa năng trong trường hợp này là lãng phí.

Vẫn sử dụng ví dụ với các hình ở trên, chúng ta muốn tính toán thể tích của hình, chúng ta có thể đưa vào ShapeInterface:

interface ShapeInterface {
    public function area();
    public function volume();
}

Bất kỳ hình nào được tạo ra phải implement phương thức volume() nhưng chúng ta biết rằng các hình vuông là hình phẳng, do đó chúng không có thể tích, như vậy interface này ép buộc lớp Square phải implement một phương thức mà không sử dụng. Nguyên lý này nói không với điều đó, thay vì chúng ta tạo ra một interface khác tên là SolidShapeInterface với phương thức volume và các solid shape có thể implement interface này.

interface ShapeInterface {
    public function area();
}

interface SolidShapeInterface {
    public function volume();
}
// Lớp hình hộp chữ nhật thực hiện cả hai interface
class Cuboid implements ShapeInterface, SolidShapeInterface {
    public function area() {
        // Tính toán diện tích bề mặt hình hộp chữ nhật
    }
    public function volume() {
        // Tính toán thể tích hình hộp chữ nhật
    }
}

Đây là một giải pháp tốt, bạn có thể tạo ra một interface khác ví dụ như ManageShapeInterface và implement nó trên cả hình phẳng và hình không gian, cách này cho bạn thấy một API duy nhất để quản lý các hình:

interface ManageShapeInterface {
    public function calculate();
}

class Square implements ShapeInterface, ManageShapeInterface {
    public function area() { /*Do stuff here*/ }

    public function calculate() {
        return $this->area();
    }
}

class Cuboid implements ShapeInterface, SolidShapeInterface, ManageShapeInterface {
    public function area() { /*Do stuff here*/ }
    public function volume() { /*Do stuff here*/ }

    public function calculate() {
        return $this->area() + $this->volume();
    }
}

Như vậy lớp AreaCalculator có thể thay việc gọi phương thức area bởi phương thức calculate và nó cũng kiểm tra đối tượng có phải là một instance của ManageShapeInterface và không phải ShapeInterface.

1.5 Nguyên lý đảo ngược phụ thuộc – Dependency Inversion principle

Entities must depend on abstractions not on concretions. It states that the high level module must not depend on the low level module, but they should depend on abstractions.

Các module cấp cao không được phụ thuộc vào module cấp thấp, tất cả các module này phụ thuộc vào module trìu tượng (khung định nghĩa module).

Nguyên lý đảo ngược phụ thuộc

Đừng để các thành phần phụ thuộc vào việc triển khai cụ thể, trong hình ảnh minh họa trên, khi dây điện đèn được nối trực tiếp vào dây điện trong nhà bạn, đèn sẽ “biết” về hệ thống điện cụ thể trong nhà và mối liên hệ của nó với hệ thống điện. Thay vào đó, đèn sẽ sử dụng phích cắm và phụ thuộc vào ổ cắm mà không cần biết về ngôi nhà – một sự trừu tượng của bên cung cấp điện. Như vậy sẽ dễ dàng hơn trong duy trì, di chuyển hoặc sử dụng lại ở nơi khác.

Chúng ta cùng xem ví dụ sau:

class PasswordReminder {
    private $dbConnection;

    public function __construct(MySQLConnection $dbConnection) {
        $this->dbConnection = $dbConnection;
    }
}

Chúng ta có thể thấy PasswordReminder là một module cấp cao còn MySQLConnection là một module cấp thấp và nó vi phạm D trong SOLID vì PasswordReminder bị ép buộc phụ thuộc vào lớp MySQLConnection. Nếu sau đó bạn muốn thay đổi sử dụng CSDL khác, chúng ta phải điều chỉnh lại lớp PasswordReminder như vậy là vi phạm nguyên lý Open-closed. Lớp PasswordReminder không quan tâm CSDL là gì, để giải quyết vấn đề viết code trong một interface, module mức cao và mức thấp nên phụ thuộc vào một abstraction, chúng ta có thể tạo ra một interface:

interface DBConnectionInterface {
    public function connect();
}

Interface này có một phương thức connect và lớp MySQLConnection implement interface này thay vì sử dụng MySQLConnection trong contructor của PasswordReminder.

class MySQLConnection implements DBConnectionInterface {
    public function connect() {
        return "Database connection";
    }
}

class PasswordReminder {
    private $dbConnection;

    public function __construct(DBConnectionInterface $dbConnection) {
        $this->dbConnection = $dbConnection;
    }
}

2. Lời kết

Với một bài viết ngắn bạn đã có thể hiểu một cách sơ lược về các nguyên lý cần thiết khi thiết kế hướng đối tượng, việc áp dụng các nguyên lý này vào thực tế còn rất nhiều vấn đề nan giải, nhưng cũng đừng quá lo lắng, kinh nghiệm chỉ được tích lũy khi có thời gian làm việc dài. Dần dần, những nguyên lý này sẽ ăn sâu vào bạn lúc nào không hay, chúng ta sẽ còn quay lại chủ đề này trong những bài viết chuyên sâu hơn bạn nhé.

6 thoughts on “SOLID 5 nguyên lý vàng trong thiết kế hướng đối tượng

  1. Nguyên lý thứ 5 Dependency inversion có phải liên quan gì đến Inversion of control không? Mình thấy các khái niệm này hơi trìu tượng, bạn nào có ví dụ dễ hiểu không vậy?

  2. Cho e hỏi nếu type hinting interface vào như trên, nếu có class khác cũng implements interface này thì làm sao biết đc cần lấy function connect() từ class nào vậy ạ ?

Add Comment