Trang chủHướng dẫnSOLID: 5 nguyên tắc đầu tiên của lập trình hướng đối tượng
Chuyên gia
Java

SOLID: 5 nguyên tắc đầu tiên của lập trình hướng đối tượng

CyStack blog 9 phút để đọc
CyStack blog11/06/2025
Locker Avatar

Chris Pham

Technical Writer

Locker logo social
Reading Time: 9 minutes

SOLID là từ viết tắt của năm nguyên tắc đầu tiên trong thiết kế hướng đối tượng (OOD) do Robert C. Martin (còn được gọi là Uncle Bob) đề xuất.

nguyên lý SOLID trong java

Những nguyên tắc này định hướng quá trình phát triển phần mềm, với trọng tâm là khả năng bảo trì và mở rộng hệ thống trong tương lai. Việc áp dụng các quy tắc này còn giúp tránh được code smell (các dấu hiệu cho thấy các vấn đề lớn tiềm ẩn trong code), dễ dàng refactor code, cũng như phát triển phần mềm theo phương pháp Agile hoặc Adaptive.

SOLID là viết tắt của:

  • S: Nguyên tắc trách nhiệm duy nhất (Single-responsibility)
  • O: Nguyên tắc đóng/mở (Open-closed)
  • L: Nguyên tắc thay thế Liskov (Liskov Substitution)
  • I: Nguyên tắc phân tách interface (Interface Segregation)
  • D: Nguyên tắc đảo ngược phụ thuộc (Dependency Inversion)

Trong bài viết này, chúng ta sẽ tìm hiểu từng nguyên tắc một để hiểu rõ tại sao SOLID có thể giúp bạn trở thành một lập trình viên giỏi hơn. Mặc dù những nguyên tắc này có thể áp dụng cho nhiều ngôn ngữ lập trình khác nhau, code ví dụ trong bài viết này sẽ sử dụng PHP.

Nguyên tắc trách nhiệm duy nhất

Theo nguyên tắc trách nhiệm duy nhất, một class chỉ nên có một lý do duy nhất để thay đổi (nghĩa là một class chỉ nên thực hiện một nhiệm vụ duy nhất).

Ví dụ, ta sẽ xem xét một ứng dụng nhận vào một tập hợp các hình (tròn và vuông) và tính tổng diện tích của tất cả các hình trong tập hợp đó.

Đầu tiên, ta tạo các class hình học với constructor thiết lập các tham số cần thiết.

Đối với hình vuông, ta cần biết length (độ dài) của một cạnh:

class Square
{
    public $length;

    public function construct($length)
    {
        $this->length = $length;
    }
}

Đối với hình tròn, ta cần biết radius (bán kính):

class Circle
{
    public $radius;

    public function construct($radius)
    {
        $this->radius = $radius;
    }
}

Tiếp theo, ta tạo class AreaCalculator và viết phần xử lý để tính tổng diện tích của tất cả các hình được cung cấp. Diện tích hình vuông được tính bằng bình phương độ dài cạnh, còn diện tích hình tròn được tính bằng cách lấy Pi nhân với bình phương bán kính.

class AreaCalculator
{
    protected $shapes;

    public function __construct($shapes = [])
    {
        $this->shapes = $shapes;
    }

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

        return array_sum($area);
    }

    public function output()
    {
        return implode('', [
          '',
              'Sum of the areas of provided shapes: ',
              $this->sum(),
          '',
      ]);
    }
}

Để sử dụng class AreaCalculator, ta cần khởi tạo nó, truyền vào một array chứa các hình, và hiển thị kết quả ở cuối trang.

Dưới đây là một ví dụ với một tập hợp gồm ba hình:

  • Một hình tròn có bán kính là 2
  • Một hình vuông có cạnh dài 5
  • Một hình vuông thứ hai có cạnh dài 6
$shapes = [
  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 trên là AreaCalculator đang kiêm luôn việc xử lý logic xuất dữ liệu. Hãy tưởng tượng một trường hợp mà kết quả output cần được chuyển đổi sang một định dạng khác, ví dụ như JSON. Khi đó, class AreaCalculator sẽ phải xử lý toàn bộ việc này, trái với nguyên tắc đơn trách nhiệm. Class AreaCalculator chỉ nên quan tâm đến việc tính tổng diện tích của các hình được cung cấp. Nó không cần quan tâm người dùng muốn output dưới dạng JSON hay HTML.

Để giải quyết vấn đề này, ta có thể tạo một class riêng biệt tên là SumAreaOutputter và sử dụng class mới này để xử lý việc xuất dữ liệu cho người dùng:

class SumCalculatorOutputter
{
    protected $calculator;

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

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

        return json_encode($data);
    }

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

Class SumAreaOutputter sẽ hoạt động như sau:

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

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

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

Class SumAreaOutputter chịu trách nhiệm xử lý logic cần thiết để xuất dữ liệu cho người dùng, giúp ta tuân thủ nguyên tắc trách nhiệm duy nhất.

Nguyên tắc đóng/mở

Nguyên tắc đóng/mở trong lập trình nghĩa là: code nên cho phép việc thêm tính năng mới mà không cần sửa đổi các chức năng đã có. Điều này có nghĩa là một class có thể được mở rộng mà không cần phải thay đổi chính nó.

Chúng ta hãy xem lại class AreaCalculator và tập trung vào phương thức sum:

class AreaCalculator
{
    protected $shapes;

    public function __construct($shapes = [])
    {
        $this->shapes = $shapes;
    }

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

        return array_sum($area);
    }
}

Giả sử người dùng muốn tính sum cho các hình dạng bổ sung như hình tam giác, ngũ giác, lục giác, v.v. Ta sẽ phải liên tục chỉnh sửa file này và thêm nhiều khối lệnh if/else. Điều đó sẽ vi phạm nguyên tắc đóng/mở.

Một cách để nâng cấp phương thức sum này là chuyển logic tính diện tích của mỗi hình ra khỏi phương thức của class AreaCalculator và gắn nó vào class của từng hình.

Đây là phương thức area được định nghĩa trong Square:

class Square
{
    public $length;

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

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

Và đây là phương thức area được định nghĩa trong Circle:

class Circle
{
    public $radius;

    public function construct($radius)
    {
        $this->radius = $radius;
    }

    public function area()
    {
        return pi() * pow($shape->radius, 2);
    }
}

Phương thức sum của AreaCalculator sau đó có thể được viết lại như sau:

class AreaCalculator
{
    // ...

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

        return array_sum($area);
    }
}

Giờ đây ta có thể tạo một class hình học khác và truyền nó vào khi tính tổng mà không làm hỏng code.

Tuy nhiên, một vấn đề khác giờ lại nảy sinh. Làm thế nào để ta biết rằng đối tượng được truyền vào AreaCalculator thực sự là một hình học, hoặc hình đó có phương thức tên là area hay không?

Lập trình theo interface là một phần không thể thiếu của SOLID. Ta sẽ tạo một ShapeInterface hỗ trợ phương thức area:

interface ShapeInterface
{
    public function area();
}

Sau đó sửa đổi các class hình học của bạn để triển khai ShapeInterface. Đây là code đã thay đổi của Square:

class Square implements ShapeInterface
{
    // ...
}

Và đây là code mới cho Circle:

class Circle implements ShapeInterface
{
    // ...
}

Trong phương thức sum của AreaCalculator, ta có thể kiểm tra xem các hình được cung cấp có thực sự là instance của ShapeInterface hay không. Nếu không, sẽ cho ra một exception:

class AreaCalculator
{
    // ...

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

            throw new AreaCalculatorInvalidShapeException();
        }

        return array_sum($area);
    }
}

Như vậy ví dụ ở trên đã tuân thủ nguyên tắc đóng/mở.

Nguyên tắc thay thế Liskov

Theo nguyên tắc thay thế Liskov:

Giả sử q(x) là một thuộc tính có thể chứng minh được về các đối tượng x kiểu T. Khi đó, q(y) cũng phải chứng minh được cho các đối tượng y kiểu S, trong đó S là một kiểu con của T.

Điều này có nghĩa là mọi class con hoặc class phái sinh (derived class) phải có khả năng thay thế được cho class cha hoặc class cơ sở của chúng.

Dựa trên ví dụ về class AreaCalculator, hãy xem xét một class mới tên là VolumeCalculator vốn kế thừa từ class AreaCalculator:

class VolumeCalculator extends AreaCalculator
{
    public function construct($shapes = [])
    {
        parent::construct($shapes);
    }

    public function sum()
    {
        // logic to calculate the volumes and then return an array of output
        return [$summedData];
    }
}

Nhớ rằng class SumCalculatorOutputter có dạng như sau:

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(),
            ''
        ));
    }
}

Nếu bạn thử chạy một ví dụ như này:

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

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

Khi bạn gọi phương thức sum trên đối tượng VolumeCalculator, bạn sẽ gặp lỗi E_NOTICE. Nó chỉ ra việc chuyển đổi từ array sang string.

Để khắc phục điều này, thay vì trả về một array từ phương thức sum của class VolumeCalculator, hãy trả về summedData:

class VolumeCalculator extends AreaCalculator
{
    public function construct($shapes = [])
    {
        parent::construct($shapes);
    }

    public function sum()
    {
        // logic to calculate the volumes and then return a value of output
        return $summedData;
    }
}

summedData có thể là một số float, double hoặc integer. Như vậy ví dụ ở trên đã tuân thủ nguyên tắc thay thế Liskov.

Nguyên tắc phân tách interface

Theo nguyên tắc này, một client không bao giờ nên bị buộc phải triển khai một interface mà nó không sử dụng, hoặc client không nên bị buộc phải phụ thuộc vào các phương thức mà chúng không dùng đến.

Vẫn tiếp tục với ví dụ Shapes trước đó, giả sử ta cần hỗ trợ các hình ba chiều mới là Cuboid (hình hộp) và Sphere (hình cầu), và các hình này cũng cần tính toán volume (thể tích).

Hãy xem xét điều gì sẽ xảy ra nếu ta sửa đổi ShapeInterface để thêm một contract (điều kiện ràng buộc) khác:

interface ShapeInterface
{
    public function area();

    public function volume();
}

Bây giờ, bất kỳ hình nào bạn tạo ra cũng phải triển khai phương thức volume. Nhưng ta biết rằng hình vuông là hình phẳng và không có thể tích, vì vậy interface này sẽ buộc class Square phải triển khai một phương thức mà nó không hề sử dụng.

Điều này sẽ vi phạm nguyên tắc phân tách interface. Thay vào đó, ta có thể tạo một interface khác gọi là ThreeDimensionalShapeInterface chứa contract volume, và các hình ba chiều có thể triển khai interface này:

interface ShapeInterface
{
    public function area();
}

interface ThreeDimensionalShapeInterface
{
    public function volume();
}

class Cuboid implements ShapeInterface, ThreeDimensionalShapeInterface
{
    public function area()
    {
        // calculate the surface area of the cuboid
    }

    public function volume()
    {
        // calculate the volume of the cuboid
    }
}

Đây là một cách tiếp cận tốt hơn nhiều. Tuy nhiên phải chú ý trường hợp khi thực hiện type-hinting (gợi ý type) các interface này. Thay vì sử dụng ShapeInterface hoặc ThreeDimensionalShapeInterface, bạn có thể tạo một interface khác, ví dụ ManageShapeInterface, và để cả hình phẳng lẫn hình ba chiều triển khai nó.

Bằng cách này, bạn có thể có 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()
    {
        // calculate the area of the square
    }

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

class Cuboid implements ShapeInterface, ThreeDimensionalShapeInterface, ManageShapeInterface
{
    public function area()
    {
        // calculate the surface area of the cuboid
    }

    public function volume()
    {
        // calculate the volume of the cuboid
    }

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

Giờ đây, trong class AreaCalculator, bạn có thể thay thế các lần gọi phương thức area. Đồng thời, cũng cần kiểm tra xem đối tượng có phải là instance của SolidShapeInterface, chứ không phải là của ManageShapeInterface hay không. Điều này giúp ta tuân thủ nguyên tắc phân tách interface.

Nguyên tắc đảo ngược dependency

Theo nguyên tắc đảo ngược dependency:

Các thực thể phải phụ thuộc vào abstraction (trừu tượng), không phải vào concretion (cụ thể hóa). Nghĩa là, module cấp cao không nên phụ thuộc vào module cấp thấp, mà cả hai nên phụ thuộc vào abstraction.

Nguyên tắc này cho phép việc decoupling (giảm sự phụ thuộc dễ dàng hơn).

Dưới đây là một ví dụ về PasswordReminder kết nối tới database MySQL:

class MySQLConnection
{
    public function connect()
    {
        // handle the database connection
        return 'Database connection';
    }
}

class PasswordReminder
{
    private $dbConnection;

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

Đầu tiên, MySQLConnection là module cấp thấp trong khi PasswordReminder là module cấp cao. Tuy nhiên, đoạn code trên đã vi phạm nguyên tắc đảo ngược dependency vì class PasswordReminder đang bị buộc phải phụ thuộc vào class MySQLConnection.

Sau này, nếu bạn muốn thay đổi database engine, bạn cũng sẽ phải chỉnh sửa class PasswordReminder. Điều đó sẽ vi phạm nguyên tắc đóng/mở.

Class PasswordReminder không nên quan tâm ứng dụng của bạn sử dụng database nào. Để giải quyết những vấn đề này, bạn có thể lập trình dựa trên interface, vì cả module cấp cao và module cấp thấp đều nên phụ thuộc vào abstraction:

interface DBConnectionInterface
{
    public function connect();
}

Interface này có một phương thức connect và class MySQLConnection triển khai interface này. Đồng thời, thay vì trực tiếp type-hint cho class MySQLConnection trong constructor của PasswordReminder, bạn sẽ type-hint DBConnectionInterface. Bất kể ứng dụng của bạn sử dụng loại database nào, class PasswordReminder đều có thể kết nối tới nó mà không gặp vấn đề gì (không vi phạm nguyên tắc đóng/mở).

class MySQLConnection implements DBConnectionInterface
{
    public function connect()
    {
        // handle the database connection
        return 'Database connection';
    }
}

class PasswordReminder
{
    private $dbConnection;

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

Với đoạn code này, cả module cấp cao và module cấp thấp giờ đây đều phụ thuộc vào abstraction.

Tổng kết

Bài viết này đã cung cấp một cái nhìn tổng quan về 5 nguyên tắc SOLID. Các dự án tuân thủ những nguyên tắc này sẽ dễ dàng chia sẻ giữa các thành viên, đồng thời đơn giản hơn trong việc mở rộng, sửa đổi, kiểm thử và refactor mà không gặp nhiều trở ngại.

Đừng quên tham khảo các bài viết khác của chúng tôi để tìm hiểu sâu hơn về những phương pháp hay khác trong phát triển phần mềm Agile và Adaptive.

0 Bình luận

Đăng nhập để thảo luận

Chuyên mục Hướng dẫn

Tổng hợp các bài viết hướng dẫn, nghiên cứu và phân tích chi tiết về kỹ thuật, các xu hướng công nghệ mới nhất dành cho lập trình viên.

Đăng ký nhận bản tin của chúng tôi

Hãy trở thành người nhận được các nội dung hữu ích của CyStack sớm nhất

Xem chính sách của chúng tôi Chính sách bảo mật.