Trong lĩnh vực lập trình, việc xây dựng những phần mềm ổn định, dễ bảo trì và mở rộng luôn là mục tiêu mà các nhà phát triển theo đuổi. Để đạt được mục tiêu này, bộ nguyên tắc SOLID đóng vai trò vô cùng quan trọng. Bài viết dưới đây sẽ cung cấp cho bạn một cái nhìn tổng quan về SOLID là gì, giải thích cụ thể từng nguyên tắc cũng như hướng dẫn cách áp dụng chúng vào lập trình hiệu quả.
SOLID là gì?
SOLID là viết tắt của 5 nguyên tắc thiết kế hướng đối tượng do Robert C. Martin và Michael Feathers đề xuất. Nhờ áp dụng SOLID, lập trình viên có thể viết ra những đoạn code dễ đọc, dễ hiểu và dễ bảo trì.
5 nguyên tắc trong SOLID bao gồm:
- Single Responsibility Principle (SRP) – Nguyên tắc trách nhiệm đơn lẻ.
- Open/Closed Principle (OCP) – Nguyên tắc mở/đóng.
- Liskov Substitution Principle (LSP) – Nguyên tắc thay thế Liskov.
- Interface Segregation Principle (ISP) – Nguyên tắc phân tách giao diện.
- Dependency Inversion Principle (DIP) – Nguyên tắc đảo ngược phụ thuộc.
Tổng quan về SOLID
Lập trình hướng đối tượng (Object Oriented Programming – OOP) là một trong những phương pháp lập trình phổ biến nhất hiện nay. Nhờ những đặc điểm nổi bật như tính trừu tượng, đóng gói, kế thừa và đa hình, OOP giúp lập trình viên giải quyết các vấn đề thực tế một cách hiệu quả:
- Tính trừu tượng (Abstraction): Cho phép tạo ra các lớp mô phỏng các đối tượng thực tế trong thế giới thật.
- Tính đóng gói (Encapsulation): Bảo vệ dữ liệu bên trong lớp và chỉ cung cấp các phương thức truy cập an toàn.
- Tính kế thừa (Inheritance): Tái sử dụng code hiệu quả bằng cách cho phép các lớp kế thừa tính năng từ các lớp cha.
- Tính đa hình (Polymorphism): Cho phép thực hiện một hành động theo nhiều cách khác nhau tùy thuộc vào loại đối tượng cụ thể.
Tuy nhiên, việc kết hợp hiệu quả các tính chất này không phải điều dễ dàng. Nguyên tắc SOLID ra đời nhằm giải quyết vấn đề này, giúp các lập trình viên viết mã OOP một cách hiệu quả hơn.
Lợi ích của SOLID
Khi áp dụng SOLID vào viết code, lập trình viên sẽ nhận được nhiều lợi ích như:
- Giảm thiểu sự phức tạp: SOLID giúp chia nhỏ phần mềm thành các thành phần độc lập, mỗi thành phần đảm nhiệm một chức năng riêng biệt. Nhờ vậy, code trở nên dễ đọc, dễ hiểu và dễ dàng sửa đổi hơn.
- Dễ dàng bảo trì và mở rộng: Khi cần thay đổi, bạn chỉ cần tác động vào một phần nhỏ thay vì ảnh hưởng đến toàn bộ hệ thống. Nhờ vậy, việc bảo trì và mở rộng phần mềm trở nên đơn giản và hiệu quả hơn, đặc biệt là đối với các dự án lớn.
- Tăng tính linh hoạt và khả năng tái sử dụng: Các thành phần được thiết kế linh hoạt, có thể dễ dàng áp dụng vào các dự án khác nhau, nhờ đó tiết kiệm thời gian và chi phí cho việc phát triển phần mềm.
5 nguyên lý SOLID và cách sử dụng
Dù bạn đang muốn tìm hiểu SOLID trong Java hay SOLID trong C# thì đều cần nắm vững 5 nguyên lý chính sau:
Single responsibility principle
Nguyên tắc cốt lõi: Mỗi lớp chỉ nên đảm nhận một nhiệm vụ cụ thể.
Single responsibility là nguyên tắc đầu tiên, ứng với chữ “S” trong bộ nguyên tắc SOLID, nhấn mạnh rằng mỗi lớp chỉ nên đảm nhận một trách nhiệm duy nhất. Việc gán quá nhiều nhiệm vụ cho một lớp sẽ khiến lớp này trở nên phức tạp, khó hiểu và khó bảo trì. Trong ngành IT, yêu cầu thay đổi và bổ sung chức năng là điều thường xuyên xảy ra nên việc sở hữu code rõ ràng, dễ hiểu là vô cùng quan trọng.
Ví dụ:
Hãy tưởng tượng một công ty phần mềm có 3 vị trí công việc: lập trình viên (developer), kiểm thử phần mềm (tester) và nhân viên bán hàng (salesman). Mỗi nhân viên sẽ có một chức vụ và thực hiện công việc tương ứng. Vậy 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?
Câu trả lời là KHÔNG.
Hãy hình dung nếu có thêm vị trí quản lý nhân sự, bạn sẽ phải sửa đổi lớp “Employee” và thêm phương thức mới. Vậy nếu có thêm 10 vị trí khác thì sao? Khi đó, các đối tượng được tạo ra sẽ có rất nhiều phương thức dư thừa. Ví dụ, developer không cần sử dụng hàm testSoftware() và saleSoftware(), và việc sử dụng sai phương thức có thể dẫn đến hậu quả nghiêm trọng.
Áp dụng nguyên tắc Single Responsibility
Hãy tạo một lớp trừu tượng “Employee” với phương thức working(). Sau đó, kế thừa từ lớp này để tạo ra 3 lớp cụ thể: Developer, Tester và Salesman. Mỗi lớp sẽ triển khai phương thức working() riêng theo chức năng của từng vị trí. Nhờ vậy, việc nhầm lẫn phương thức sẽ không còn xảy ra.
Open/Closed principle
Nguyên tắc cốt lõi: Không sửa đổi lớp có sẵn, thay vào đó hãy mở rộng bằng kế thừa.
Open/Closed là nguyên tắc thứ hai trong SOLID, tương ứng với chữ “O” nhấn mạnh rằng khi cần bổ sung chức năng cho chương trình, ta nên tạo lớp mới kế thừa (hoặc sử dụng) lớp cũ thay vì chỉnh sửa trực tiếp lớp hiện tại. Điều này khiến chương trình có nhiều lớp hơn, nhưng bù lại ta không cần kiểm thử lại các lớp cũ mà chỉ tập trung vào các lớp mới.
Tuy nhiên, việc mở rộng chức năng thường đi kèm với việc viết thêm code. Để thiết kế module dễ dàng mở rộng mà không cần sửa đổi code nhiều, ta cần tách biệt phần dễ thay đổi khỏi phần khó thay đổi, đảm bảo không ảnh hưởng đến những phần còn lại.
Ví dụ:
Giả sử chúng ta có một lớp ConnectionManager dùng để quản lý kết nối đến cơ sở dữ liệu (CSDL). Ban đầu, lớp này chỉ hỗ trợ kết nối với SQL Server và MySQL.
class ConnectionManager
{
public function connect(Connection $connection)
{
if ($connection instanceof SqlServer) {
// Kết nối với SQL Server
} else if ($connection instanceof MySql) {
// Kết nối với MySQL
}
}
}
Sau đó, yêu cầu được đặt ra là cần hỗ trợ thêm kết nối với Oracle và các CSDL khác. Để đáp ứng yêu cầu này, chúng ta có thể sửa đổi mã nguồn của lớp ConnectionManager bằng cách thêm các khối else-if cho các hệ quản trị CSDL mới. Tuy nhiên, cách này làm cho mã nguồn trở nên cồng kềnh và khó quản lý.
Áp dụng nguyên tắc Open/Closed
Áp dụng nguyên tắc OCP, chúng ta có thể thiết kế lại như sau:
- Định nghĩa một lớp cơ sở Connection để mô tả các phương thức chung cho tất cả các lớp kết nối.
- Áp dụng Abstract để tạo các lớp SqlServer, MySql, Oracle,… kế thừa từ lớp cơ sở Connection và triển khai phương thức doConnect cho từng lớp.
- Lớp ConnectionManager chỉ cần sử dụng phương thức doConnect() của đối tượng Connection để kết nối đến cơ sở dữ liệu.
Thiết kế sau khi sửa đổi:
abstract class Connection
{
public abstract void doConnect();
}
class SqlServer extends Connection
{
public void doConnect()
{
//connect with SqlServer
}
}
class MySql extends Connection
{
public void doConnect()
{
//connect with MySql
}
}
class Oracle extends Connection
{
public function doConnection(Connection $connection)
{
//something
//.................
//connection
$connection->doConnect();
}
}
}
Với thiết kế này, lớp ConnectionManager không cần sửa đổi khi thêm các loại CSDL mới. Bạn chỉ cần tạo lớp mới kế thừa từ Connection và thực thi phương thức doConnect tương ứng cho loại CSDL đó.
Liskov substitution principle
Nguyên tắc cốt lõi: Đối tượng (instance) của lớp con có thể thay thế cho đối tượng của lớp cha mà không gây lỗi.
Liskov substitution là nguyên tắc thứ 3 trong bộ nguyên tắc SOLID, tương ứng với chữ “L”. Nguyên tắc này quy định rằng các lớp con phải kế thừa và duy trì hành vi cơ bản của lớp cha. Nếu vi phạm nguyên tắc này, chương trình có thể gặp lỗi khi sử dụng các đối tượng của lớp con thay thế cho các đối tượng của lớp cha.
Ví dụ:
Tiếp nối ví dụ lớp Employee ở nguyên tắc 1, giả sử công ty đó tiến hành chấm công nhân viên chính thức vào mỗi buổi sáng. Lúc này, ta sẽ bổ sung thêm phương thức checkAttendance() cho lớp Employee.
Tuy nhiên, công ty cũng thuê thêm nhân viên lao công làm vệ sinh văn phòng. Những nhân viên này không phải là nhân viên chính thức nên không được cấp ID cũng như không được chấm công.
Lúc này bạn có thể tạo ra lớp CleanerStaff kế thừa từ Employee và thực thi hàm working() cho lớp này. Tuy nhiên, lớp CleanerStaff cũng sẽ kế thừa phương thức checkAttendance() để điểm danh, vi phạm quy định và gây lỗi chương trình. Do đó, thiết kế lớp CleanerStaff kế thừa từ Employee là không hợp lý.
Áp dụng nguyên tắc Liskov substitution
Có thể giải quyết vấn đề này bằng cách tách hàm checkAttendance() ra một giao diện riêng và chỉ dành cho các lớp Developer, Tester và Salesman. Lớp CleanerStaff sẽ không thực thi được giao diện trên nên sẽ không thể sử dụng hàm checkAttendance() để điểm danh.
Interface segregation principle
Nguyên tắc cốt lõi: Thay vì sử dụng một giao diện (interface) lớn, hãy tách thành nhiều giao diện nhỏ hơn với các mục đích cụ thể.
Interface segregation là nguyên tắc thứ 4 trong bộ nguyên tắc SOLID, tương ứng với chữ “I”. Hãy tưởng tượng bạn đang đối mặt với một interface khổng lồ có khoảng 100 methods. Lúc này việc implements interface rất khó khăn bởi vì các lớp buộc phải thực thi tất cả các method trong interface. Điều này dẫn đến tình trạng dư thừa khi một lớp không cần sử dụng hết 100 methods đó.
Giải pháp chính là chia nhỏ interface lớn này thành các interface con, mỗi interface chỉ chứa methods liên quan chặt chẽ với nhau. Nhờ vậy, việc implements và quản lý trở nên dễ dàng, hiệu quả hơn.
Ví dụ:
Đoạn code sau thể hiện giao diện Animal với các phương thức eat(), run(), và fly():
interface Animal {
void eat();
void run();
void fly();
}
Tuy nhiên, việc sử dụng giao diện chung cho cả mèo (cat) và nhện (spider) là không hợp lý vì mèo không thể bay (fly) và nhện không thể chạy (run).
Áp dụng nguyên tắc Interface segregation
Để giải quyết vấn đề này, ta nên chia tách giao diện Animal thành 3 giao diện riêng biệt cho từng phương thức
interface Animal {
void eat();
}
interface RunnableAnimal extends Animal {
void run();
}
interface FlyableAnimal extends Animal {
void fly();
}
Dependency inversion principle
Nguyên tắc cốt lõi:
- 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 abstractions (sự trừu tượng).
- Abstractions không nên phụ thuộc vào chi tiết (implementation) mà chi tiết nên phụ thuộc vào abstractions.
Dependency inversion có thể được hiểu như sau: các thành phần trong một chương trình chỉ nên phụ thuộc vào các khái niệm trừu tượng (abstractions). Các khái niệm trừu tượng này không nên phụ thuộc vào những chi tiết cụ thể mà ngược lại, chính những chi tiết cụ thể nên phụ thuộc vào chúng.
Các khái niệm trừu tượng là những yếu tố ổn định, ít biến đổi, chúng bao gồm những đặc tính chung nhất của các yếu tố cụ thể. Dù các yếu tố cụ thể có thể rất khác nhau nhưng chúng vẫn tuân theo những nguyên tắc chung mà khái niệm trừu tượng đã định nghĩa. Sự phụ thuộc vào khái niệm trừu tượng giúp cho chương trình trở nên linh hoạt và thích ứng tốt hơn với các thay đổi liên tục.
Ví dụ:
Đối với ổ cứng máy tính, bạn có thể sử dụng ổ cứng thể rắn SSD mới nhất để tăng tốc độ truy cập dữ liệu, nhưng ổ đĩa quay HDD cũng hoàn toàn phù hợp. Nhà sản xuất mainboard không thể biết bạn sẽ chọn loại ổ nào, nhưng họ luôn đảm bảo mainboard tương thích với cả hai chuẩn giao tiếp SATA để bạn gắn vào bo mạch chủ dễ dàng. Trong trường hợp này, chuẩn giao tiếp SATA đóng vai trò là interface (giao diện), còn SSD và HDD là những implementation (trình triển khai) cụ thể.
Áp dụng nguyên tắc Dependency inversion
Tương tự, khi áp dụng nguyên lý này trong lập trình, ta thường sử dụng interface thay vì kiểu kế thừa cụ thể ở các lớp trừu tượng cấp cao. Chẳng hạn, để kết nối với cơ sở dữ liệu, ta thường thiết kế lớp trừu tượng DataAccess bao gồm các phương thức chung như save(), get(),… Sau đó, tùy theo loại DBMS nào được sử dụng (ví dụ như MySQL, MongoDB,…), ta sẽ kế thừa và triển khai phương thức cụ thể. Nguyên lý này tận dụng tối đa tính đa hình của lập trình hướng đối tượng (OOP).
Câu hỏi thường gặp
OOP là gì?
OOP là viết tắt của Object-Oriented Programming, hay Lập trình hướng đối tượng. Đây là một mô hình lập trình dựa trên khái niệm đối tượng (object). Mỗi đối tượng đại diện cho một thực thể trong thế giới thực và có hai thành phần chính:
– Thuộc tính (attribute): Miêu tả trạng thái của đối tượng, ví dụ như màu sắc, kích thước, tên,…
– Phương thức (method): Các hành động mà đối tượng có thể thực hiện, ví dụ như di chuyển, thay đổi màu sắc, phát ra âm thanh,…
OOP được thiết kế để tăng khả năng tái sử dụng code, giúp việc lập trình trở nên linh hoạt hơn và dễ quản lý hơn. Một số ngôn ngữ lập trình phổ biến hỗ trợ OOP bao gồm Java, C++, Python, và Ruby.
Design pattern là gì?
Design pattern (Mẫu thiết kế) là một giải pháp tiêu chuẩn, đã được kiểm chứng để giải quyết các vấn đề thường gặp trong lập trình. Mẫu thiết kế không phải là một đoạn code cụ thể mà là một khuôn mẫu để giải quyết vấn đề một cách hiệu quả và có thể tái sử dụng.
Lời kết
Trên đây là những thông tin mà Vietnix muốn chia sẻ tới bạn về khái niệm SOLID là gì cũng như cách sử dụng từng nguyên tắc cụ thể trong bộ nguyên tắc này. Việc học hỏi và áp dụng các nguyên tắc SOLID có thể tốn nhiều thời gian, nhưng lợi ích mà chúng mang lại là không hề nhỏ. Nếu bạn là một nhà phát triển phần mềm, hãy dành thời gian để tìm hiểu về SOLID và bắt đầu áp dụng các nguyên tắc này vào các dự án ngay từ hôm nay.