trieu.dev.da
Nguyễn Thanh Triều
Phát biểu
Nguyên tắc cũng khá đơn giản về mặt mô hình hoá, bạn hãy tưởng tượng một việc trong thực tế như sau: một hệ thống máy tính sẽ có mainboard là thành phần chính, bộ phận này kết nối các thành phần khác trong hệ thống (như CPU, Ram, ổ cứng, …) lại với nhau để tạo nên một hệ thống hoạt động hoàn chỉnh và thống nhất. Như bạn đã biết, một mainboard có khả năng kết nối nhiều loại Ram, nhiều loại ổ cứng, … Dù những nhà sản xuất Ram hay mainboard là độc lập nhau hoàn toàn, và cũng có rất nhiều loại mainboard và ổ cứng khác nhau, nhưng các bộ phận này được kết nối với nhau rất dễ dàng. Làm thế nào để các nhà sản xuất có thể làm được điều này?: Mỗi thành phần hệ thống (class, module, …) chỉ nên phụ thuộc vào các abstractions, không nên phụ thuộc vào các concretions hoặc implementations cụ thể.
Câu trả lời thực ra rất đơn giản: mọi linh kiện máy tính dù cho có cấu tạo chi tiết khác nhau (implement khác nhau), nhưng luôn giao tiếp với nhau thông qua các chuẩn đã định sẵn (abstraction), cụ thể ở đây là mainboard giao tiếp với ổ đĩa cứng thông qua chuẩn kết nối chung SATA.
Figure 1 – Mainboard không cần biết loại đĩa cứng nào được dùng, miễn là chúng “tương thích” với chuẩn giao tiếp SATA là được
Figure 2 – Mặc dù có implement cụ thể khác nhau (ổ đĩa quay, ổ đĩa thể rắn), nhưng chỉ cần chúng “tương thích” với chuẩn giao tiếp SATA là có thể chạy tốt.
Đây chính là một ví dụ trong thực tế cho chúng ta hình dung được khái niệm về nguyên lí thiết kế thứ 5 trong SOLID: tính tương thích động – Dependency Inversion Priciple (DIP). Mọi thành phần hệ thống chỉ nên phụ thuộc vào abstraction, mà không hề phụ thuộc vào bất cứ một concretion cụ thể nào.
Áp dụng nguyên lí này trong lập trình thế nào?
Mình sẽ tiếp tục minh hoạ nguyên lí này bằng ví dụ về Student ở các bài trước nhé. Cụ thể ở đây mình sẽ lấy ngữ cảnh việc class Student cần thực hiện ghi log, và chúng ta vừa tách nhiệm vụ này thành một class khác đó là lớp ExportLog trong ví dụ về tính đơn nhiệm ở nguyên lí 1.
Code để xử lí cho logic đó như sau:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | class Student { // other functions and properties ... bool applyForScholarship() { // do something here // ... // Logging ExportLog log_control = new ExportLog(); log_control.exportLogToFile( target, "Error getting scholarship"); } } class ExportLog { void exportLogToFile(string filename, string error) { //write error to log file system.Out.print( filename, error ); } } |
Có thể chúng ta sẽ nghĩ ngay tới giải pháp: tạo một class cha (hoặc interface) là HandleLog chứa một phương thức chung đó là exportLog(), sau đó tạo các lớp con là ExportEmailLog và ExportFileLog kế thừa từ lớp cha này.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | interface HandleLog { void exportLog(string filenameOrEmail, string log); } class ExportEmailLog implements HandleLog { void exportLog(string email, string log) { //export log to email system.sendEmail( email, log ); } } class ExportFileLog implements HandleLog { void exportLog(string filename, string log) { //export log to file system.Out.print( filename, log ); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | class Student { // other functions and properties ... bool applyForScholarship() { // do something here // ... // Logging // Vấn đề nằm ở đây // Làm sao để biết sẽ phải new đối tượng gì??? ExportLog log_control = new ExportLog(); log_control.exportLogToFile( target, "Error getting scholarship"); } } |
Một cách đơn giản thì dependency injection là một kĩ thuật lập trình, trong đó có 2 đối tượng: đối tượng cung cấp service (dependency) và đối tượng sử dụng service, đối tượng cung cấp service sẽ được truyền vào đối tượng sử dụng từ bên ngoài (khác cách thông thường là đối tượng sử dụng sẽ phải new đối tượng cung cấp dịch vụ từ bên trong). Về khái niệm dependency injection, chúng ta phân làm 3 loại:
- Constructor injection: Truyền đối tượng cung cấp service (dependency) vào hàm khởi tạo của đối tượng sử dụng.
- Setter injection: Dependency được truyền vào thông qua hàm getter và setter của đối tượng sử dụng.
- Interface injection: Dependency được truyền vào một hàm nào đó của đối tượng sử dụng, hàm này cần phải có input param là một interface của dependency.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | class Student { //thuộc tính giữ đối tượng dependency HandleLog *log_control; void setHandleLog(HandleLog *input_log_control) { this->log_control = input_log_control; } bool applyForScholarship() { // do something here // ... //thực hiện ghi log this->log_control.exportLog( target, "Applying for scholarship successfully!"); } } |
1 2 3 4 5 6 | Student student_1 = new Student(); // Việc gọi hàm applyForScholarship() // và ghi log theo mong muốn rất dễ dàng student_1.setHandleLog(new ExportEmailLog()); //hoặc có thể dùng ExportFileLog student_1.applyForScholarship(); |
Nhận xét
Đúng với tiêu chí của SOLID – code phải rõ ràng, dễ bảo trì và mở rộng – nguyên lí này đưa chúng ta tới một cảnh giới mới của việc thiết kế phần mềm. Như bạn đã thấy ở ví dụ trên, chương trình của chúng ta bây giờ đã linh động và dễ mở rộng hơn rất nhiều.
Nguyên lí này – Dependency inversion – đi liền với kĩ thuật dependency injection, mọi người nên hiểu rõ bản chất của 2 khái niệm này để áp dụng vào code cho hợp lí. Nói rõ hơn: Dependency inversion là nguyên lý thiết kế, còn Dependency injection là một kĩ thuật giúp chúng ta triển khai được nguyên lý trên.
Nếu bạn muốn xem xét sâu hơn, hãy tìm kiếm mẫu thiết kế tương tự đó là strategy pattern, mẫu này được ứng dụng rất nhiều trong lập trình để tăng độ linh hoạt của một hàm (vd: hàm sort() để sắp xếp, nhưng có nhiều thuật toán sort khác nhau, áp dụng mẫu này để linh động chuyển qua lại giữa các thuật toán với nhau, trong lập trình game khi đối tượng lên level, ta vẫn giữ nguyên tên chiêu thức nhưng nâng cấp cách ra chiêu, khi đó mẫu này sẽ giúp ta làm điều này).
Figure 3 – Mô hình tổ chức lớp của mẫu thiết kế Strategy.