2024-06-14 09:43 — 12 phút đọc

Nguyên Tắc SOLID Trong Phát Triển Phần Mềm

#solid#design-patterns#software-development


Trong lĩnh vực phát triển phần mềm, nguyên tắc SOLID là một tập hợp các nguyên tắc thiết kế giúp cải thiện chất lượng mã, tăng tính linh hoạt và bảo trì của hệ thống. SOLID là viết tắt của năm nguyên tắc: Single Responsibility Principle (SRP), Open/Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), và Dependency Inversion Principle (DIP). Những nguyên tắc này được giới thiệu bởi Robert C. Martin, hay còn được gọi là Uncle Bob, và đã trở thành tiêu chuẩn vàng trong thiết kế phần mềm.

SOLID Principles

Như với mọi thứ trong cuộc sống, sử dụng những nguyên tắc này một cách vô thức sẽ làm mọi thứ tồi tệ hơn là có lợi. Chi phí để áp dụng các nguyên tắc này vào trong dự án cụ thể hoặc lớn có thể làm cho nó phức tạp hơn mức cần thiết. Cố gắng để sử dụng những nguyên tắc này là tốt nhưng đừng lạm dụng quá bởi vì chúng có thể là con dao 2 lưỡi.

Single Responsibility Principle (SRP)

Nguyên tắc trách nhiệm đơn lẻ: Một class chỉ nên có một lý do duy nhất để thay đổi, nghĩa là mỗi class chỉ nên đảm nhiệm một chức năng cụ thể.

Lợi ích:

  • Dễ Dàng Bảo Trì: Khi mỗi class chỉ có một trách nhiệm, việc cập nhật hoặc sửa lỗi sẽ dễ dàng hơn.
  • Tái Sử Dụng: Các class có thể được tái sử dụng ở các phần khác nhau của hệ thống mà không gây ra sự phụ thuộc không cần thiết.

Mục tiêu chính của nguyên tắc này đó là làm giảm độ phức tạp. Bạn không phải làm tăng độ phức tạp cho một đoạn code chỉ khoảng 200 dòng.

Các vấn đề thực sự xuất hiện khi và chỉ khi chương trình của bạn cần sự thay đổi và phát triển liên tục. Đến một thời điểm nào đó class của bạn trở nên quá lớn, phức tạp đến mức bạn không còn nhớ chi tiết của nó nữa.

Nếu một class làm quá nhiều việc thì mỗi khi có một thay đổi nhỏ, bạn có thể sẽ phá vỡ các phần khác của class mặc dù bạn không có ý định làm điều đó.

Nếu bạn cảm thấy không thể tập trung vào một class, nghĩa là nó làm quá nhiều việc, hãy chia nó thành các class nhỏ hơn với chức năng cụ thể.

Ví Dụ:

class Employee {
    public void calculatePay() {
        // Tính lương
    }
    public void save() {
        // Lưu thông tin nhân viên
    }
}

Tách thành:

class Employee {
    public void calculatePay() {
        // Tính lương
    }
}

class EmployeeRepository {
    public void save(Employee employee) {
        // Lưu thông tin nhân viên
    }
}

Open/Closed Principle (OCP)

Nguyên tắc mở/đóng: Các thực thể phần mềm (class, module, hàm, v.v.) nên được mở để mở rộng nhưng đóng để sửa đổi.

Lợi ích:

  • Dễ Dàng Mở Rộng: Hệ thống có thể mở rộng mà không cần thay đổi mã hiện tại.
  • Bảo Vệ Mã Hiện Tại: Giảm nguy cơ phá vỡ mã hiện có khi thêm tính năng mới.

Ý tưởng chính của nguyên tắc này là giữ cho code hiện tại của bạn không bị lỗi khi thêm một tính năng mới.

Một class mở nếu bạn có thể mở rộng nó, tạo ra một lớp con và làm bất cứ điều gì với nó (Thêm các phương thức, trường mới, ghi đè lên hành vi hiện tại). Một số ngôn ngữ hạn chế việc mở của các class với các từ khoá đặc biệt. Sau này khi các lớp không còn được mở rộng nữa (Có thể coi là đã hoàn thành) nếu nó đã sẵn sàng 100% để được sử dụng bởi các lớp khác. Các Interface của nó được định nghĩa rõ ràng và không thay đổi trong tương lai.

Khi lần đầu tiên tôi nghe về phương thức này (Open/Close), tôi có một chút bối rối vì không hiểu và nghe tên nó có vẻ mâu thuẫn. Nhưng theo nguyên tắc này thì, một lớp có thể vừa mở (Open để mở rộng) và vừa có thể đóng (Close để sửa đổi).

Nếu một Class được phát triển, kiểm thử, và reviewed hoặc được đưa vào sử dụng trong một số framework, hoặc đưa vào trong một số dự án khác, việc thay đổi các class này là rất rủi ro. Thay vì thay đổi hoặc sửa Class đó bạn hãy tạo một class mới và kế thừa từ class cũ sau đó ghi đè lên các phương thức cần thay đổi. Bạn sẽ đạt được mục tiêu mà không cần phải thay đổi class cũ.

Nguyên tắc này không có nghĩa là được áp dụng cho tất cả các class. Nếu có một lỗi trong class thì hãy tiếp tục sửa nó, đừng tạo một class con cho nó. Một class con không nên chịu trách nhiệm cho class cha.

Ví Dụ:

class Rectangle {
    public double width;
    public double height;
}

class AreaCalculator {
    public double calculateRectangleArea(Rectangle rectangle) {
        return rectangle.width * rectangle.height;
    }
}

Sửa đổi để tuân thủ OCP:

interface Shape {
    double calculateArea();
}

class Rectangle implements Shape {
    public double width;
    public double height;
    public double calculateArea() {
        return width * height;
    }
}

class Circle implements Shape {
    public double radius;
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

class AreaCalculator {
    public double calculateArea(Shape shape) {
        return shape.calculateArea();
    }
}

Liskov Substitution Principle (LSP)

Nguyên tắc thay thế Liskov: Các đối tượng của một class con nên có thể thay thế các đối tượng của class cha mà không làm thay đổi tính đúng đắn của chương trình.

Lợi ích:

  • Tăng Tính Tin Cậy: Đảm bảo rằng các class con có thể sử dụng thay thế cho các class cha mà không gây ra lỗi.
  • Tăng Tính Linh Hoạt: Các class con có thể được sử dụng một cách linh hoạt và nhất quán.

Điều này có nghĩa lớp con phải tương thích với hành vi cũ của lớp cha. Khi ghi đè một phương thức, hãy mở rộng hành vi mới thay vì thay thế nó bằng một phương thức hoàn toàn khác.

Nguyên tắc này là một tập hợp các kiểm tra giúp dự đoán liệu có một lớp con nào còn tương thích với code đang hoạt động với các lớp cha hay không. Khái niệm này rất quan trọng khi phát triển các thư viện, framework, bởi vì các class của bạn sẽ được truy cập và sử dụng bởi những người khác và họ không có quyền thay đổi mã nguồn của bạn.

Không giống với các nguyên tắc khác, nguyên tắc thay thế có một tập hợp các yêu cầu chính thức cho các lớp con và đặc biệt cho các phương thức của chúng. Dưới đây là danh sách kiểm tra này:

  1. Các loại tham số trong một phương thức của lớp con phải khớp hoặc trừu tượng hơn so với tham số của phương thức của lớp cha. Nghe có vẻ khó hiểu? hãy xem ví dụ sau.
  • Giả sử có một class với phương thức cho mèo ăn: feed(Cat c).

  • GOOD: Giả sử bạn tạo ra một phương thức của class con ghi đè lên phương thức trên và nó có thể cho mọi loại động vật ăn: feed(Animal a). Bây giờ bạn truyền một class của một động vật bất kỳ vào phương thức này và nó sẽ hoạt động tốt. Method này có thể nuôi bất kỳ loại động vật nào, vì vậy nó vẫn có thể nuôi được mèo như phương thức cha.

  • BAD: Nếu bạn tạo một phương thức feed(BengalCat c) thì nó không thể nuôi được mèo bất kỳ ngoài BengalCat. Điều gì sẽ xảy ra nếu bạn truyền một class của mèo khác vào phương thức này? Nó sẽ không hoạt động, phá vỡ tất cả những chức năng có liên quan.

  1. Kiểu trả về của phương thức của một class con phải phù hợp hoặc trùng khớp với kiểu trả về của class cha Như bạn có thể thấy, các yêu cầu thay đổi với loại trả về ngược lại với loại tham số.
  • Giả sử bạn có một class với một phương thức buyCat(): Cat.

  • GOOD: class con ghi đè phương thức cha buyCat(): BengalCat. Và BelganCat là một lớp con của Cat. Bây giờ bạn có thể gọi phương thức này và nó sẽ trả về một BengalCat, nhưng bạn có thể xử lý nó như một Cat, và tất cả mọi thứ vẫn hoạt động bình thường.

  • BAD: Nếu bạn ghi đè phương thức cha buyCat(): Animal thì bạn không thể chắc chắn rằng phương thức này sẽ trả về một Cat. Nếu bạn gọi phương thức này và nó trả về một Dog thì bạn sẽ gặp lỗi.

  • Một ví dụ khác với ngôn ngữ lập trình động: Phương thức của class cha trả về kiểu String nhưng class con trả về kiểu Integer. Điều này sẽ gây ra lỗi khi bạn sử dụng phương thức của class cha.

  1. Một phương thức của lớp con không nên trả ra các exceptions mà lớp cha không xử lý. Nói cách khác, các exceptions của lớp con trả ra phải trùng hoặc là một loại của các exceptions của lớp cha xử lý. Quy tắc này nằm trong phương thức try-catch nhằm mục tiêu loại các exceptions không mong muốn. Do đó có thể một ngoại lệ không mong muốn có thể bị bỏ qua và gây ra lỗi cho ứng dụng.
Trong hầu hết các ngôn ngữ lập trình hiện đại (Java, C#, v.v.), các quy tắc này được tích hợp vào ngôn ngữ. Bạn sẽ không thể biên dịch được chương trình vi phạm quy tắc này.
  1. Một lớp con không nên thêm các điều kiện bắt buộc Ví dụ: Phương thức của lớp cha có một dữ liệu kiểu int. Nếu lớp con ghi đè phương thức này và yêu cầu giá trị truyền vào phương thức đó là số dương (bằng cách ném một ngoại lệ nếu giá trị truyền vào là số âm). Điều này sẽ gây ra lỗi nếu bạn truyền một số âm vào phương thức của lớp cha.

  2. Một lớp con không nên làm suy yếu các điều kiện trước đó Tưởng tượng rằng bạn có một lớp với một phương thức đang làm việc với cơ sở dữ liệu. Các kết nối đến cơ sở dữ liệu sẽ được đóng ngay sau khi phương thức kết thúc.

Bạn tạo ra một lớp con và thay đổi nó để kết nối tới các cơ sở dữ liệu và có thể sử dụng lại chúng. Nhưng khách hàng lại không biết gì về các ý định của bạn. Bởi vì họ mong đợi các phương thức sẽ hoạt động như cũ, tức là các kết nối sẽ luôn đóng sau khi phương thức kết thúc, gây rác cho chương trình với các kết nối tới cơ sở dữ liệu mà không được sử dụng.

  1. Lớp con không nên thay đổi các biến private của lớp cha. Làm sao có thể? Hoá ra một số ngôn ngữ lập trình cho phép bạn truy cập các biến private của lớp cha thông qua các phương thức getter và setter. Nếu bạn thay đổi giá trị của biến private của lớp cha trong lớp con, nó có thể sẽ gây ra lỗi cho chương trình.

Ví Dụ:

class Bird {
    public void fly() {}
}

class Ostrich extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException();
    }
}

Sửa đổi để tuân thủ LSP:

abstract class Bird {
    abstract void move();
}

class Sparrow extends Bird {
    @Override
    public void move() {
        fly();
    }

    public void fly() {
        // Logic bay
    }
}

class Ostrich extends Bird {
    @Override
    public void move() {
        run();
    }

    public void run() {
        // Logic chạy
    }
}

Interface Segregation Principle (ISP)

Nguyên tắc phân tách giao diện: Các khách hàng không nên bị buộc phải phụ thuộc vào các giao diện mà họ không sử dụng.

Lợi ích:

  • Giảm Sự Phụ Thuộc: Các class chỉ cần biết đến những phương thức mà chúng thực sự sử dụng.
  • Tăng Tính Linh Hoạt: Dễ dàng thay đổi hoặc thay thế các phần của hệ thống mà không ảnh hưởng đến các phần khác.

Ví Dụ:

interface Worker {
    void work();
    void eat();
}

Sửa đổi để tuân thủ ISP:

interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

class Worker implements Workable, Eatable {
    public void work() {
        // Logic làm việc
    }

    public void eat() {
        // Logic ăn uống
    }
}

class Robot implements Workable {
    public void work() {
        // Logic làm việc
    }
}

Dependency Inversion Principle (DIP)

Nguyên tắc đảo ngược sự phụ thuộc: Các module cấp cao không nên phụ thuộc vào các module cấp thấp. Cả hai nên phụ thuộc vào các abstraction. Các abstraction không nên phụ thuộc vào chi tiết. Các chi tiết nên phụ thuộc vào abstraction.

Lợi ích:

  • Tăng Tính Linh Hoạt: Cho phép thay thế dễ dàng các module mà không ảnh hưởng đến các module khác.
  • Giảm Sự Phụ Thuộc: Đảm bảo rằng các module chỉ biết đến những abstraction cần thiết.

Ví Dụ:

class LightBulb {
    public void turnOn() {}
    public void turnOff() {}
}

class Switch {
    private LightBulb bulb;

    public Switch(LightBulb bulb) {
        this.bulb = bulb;
    }

    public void operate() {
        bulb.turnOn();
    }
}

Sửa đổi để tuân thủ DIP:

interface Switchable {
    void turnOn();
    void turnOff();
}

class LightBulb implements Switchable {
    public void turnOn() {
        // Logic bật đèn
    }

    public void turnOff() {
        // Logic tắt đèn
    }
}

class Switch {
    private Switchable device;

    public Switch(Switchable device) {
        this.device = device;
    }

    public void operate() {
        device.turnOn();
    }
}

Kết Luận

Áp dụng các nguyên tắc SOLID trong phát triển phần mềm giúp tạo ra các hệ thống dễ bảo trì, dễ mở rộng và linh hoạt. Những nguyên tắc này không chỉ giúp cải thiện chất lượng mã mà còn nâng cao hiệu quả làm việc của đội ngũ phát triển. Dù có thể đòi hỏi thêm nỗ lực ban đầu, nhưng Lợi ích dài hạn mà SOLID mang lại sẽ làm cho quá trình phát triển phần mềm trở nên mượt mà và hiệu quả hơn.


aitu avatar

Hi! Tôi là Tuyên — Hiện tại tôi đang làm Software Architect, Senior developer tại một công ty nhỏ ở Hà Nội. Tôi cảm thấy thích thú, đam mê, yêu thích với việc viết lách và chia sẻ những kiến thức mà tôi biết. Hãy đọc nhiều hơn tại blogs và tới about để biết thêm về tôi nhé.