Đối với nguyên tắc cuối này, nếu các bạn đều đã hiểu các nguyên tắc trước đó thì cũng không còn gì nhiều để nói về nó. Trong Liskov Substitution đã có một phần của Dependency Inversion. Trong Open/Close cũng thế. Có cả trong Interface Segregation. Tất cả những nguyên tắc kể trên đều có mối liên kết là Dependency Inversion. Đối với Liskov Substitution, bạn tạo sự phụ thuộc qua các lớp cha, và các lớp con được gọi qua sự phụ thuộc ấy. Open/Close cũng cần có Dependency Inversion. Kể cả khi bạn dùng composition thay cho inheritance, các phụ thuộc trong một composite cũng vẫn tuân theo Dependency Inversion. Adapter là một thiết kế mẫu để gom những class không tương thích lại với nhau và bạn thấy đấy, lớp trừu tượng hóa của nó vẫn rất quen thuộc, chỉ riêng phần không tương thích được đóng gói và che đậy đi mà thôi. Còn Interface Segregation, bạn chia tách làm gì khi không muốn liên kết các class qua các interface đó?
Như tôi đã nói thứ tự trong SOLID vốn không phải để đánh giá nguyên tắc nào quan trọng hơn. Bạn sẽ đạt được cả 5 nguyên tắc hoặc không gì cả. Thói quen của dân lập trình là sẽ lao ngay vào code. Có hai cách để code ở đây:
- Bắt đầu trừu tượng hóa.
- Bắt đầu viết một hàm chạy được.
Không có ưu tiên nào trong hai cách trên giữa người mới và người có kinh nghiệm. Nhưng chắc chắn người mới thường không để ý đến trừu tượng hóa còn người có kinh nghiệm sẽ thực hiện cả hai bước và bỏ nhiều thời gian vào trừu tượng hóa. Trừu tượng hóa là bước bạn biến đổi các đối tượng trên thực tế thành các class và interface. Chúng ta đều biết khái niệm Abstraction Level. Có thể nói người mới sẽ dừng lại ở level 1 (hay không làm gì). Có kinh nghiệm sẽ lên được level 2. Nhiều kinh nghiệm thì có thể lên level 5, 6. Các lão làng sẽ kiềm chế ở level 3 hoặc 4 không hơn. Abstraction Level không chỉ là số cấp thừa kế mà còn là số thành phần phụ thuộc để gọi đến concrete class (bao hàm cả inheritance và composition). Mọi mô hình lập trình và ngôn ngữ lập trình đều cố gắng cung cấp các công cụ để thực hiện quá trình trừu tượng hóa này, dù là OOP, procedural hay functional. Nói chung, trừu tượng hóa là quá trình tìm ra nguyên lý của hệ thống. Các nguyên lý này giúp việc phát triển, mở rộng và chuyển giao hệ thống dễ dàng hơn. Ví dụ là về phép cộng, một phép tính cơ bản. Hãy nhớ về cách chúng ta học phép cộng từ khi còn nhỏ. Cộng các số có 1 chữ số trước. Sau đó cộng các số có 2 chữ số. Không có nhiều nguyên lý trong cộng số 2 chữ số, bạn chỉ tách số thành các số nhỏ và chẵn cho dễ cộng, và có thể vẫn phải đếm dần trong một số phép tính. Với nguyên lý này, cộng các số 3 chữ số trở nên lâu hơn và dễ nhầm hơn do phải nhớ nhiều số hơn. Cho đến khi bạn được học nguyên lý cộng từ cô giáo mình và bạn có thể cộng các số với độ dài tùy ý bằng cách viết hai số trên 1 cột, cộng theo từng hàng đơn vị và ghi số nhớ bên phải. Cùng với nguyên lý đó áp dụng lên bàn tính soroban bạn có thể vật lý hóa các phép tính và làm tính nhanh hơn nữa. Quá trình tìm ra nguyên lý chung cho phép cộng để có thể áp dụng cho mọi trường hợp cũng như để sáng tạo ra công cụ tốt hơn là quá trình trừu tượng hóa. Khi thiết kế phần mềm, việc trừu tượng hóa cũng có ý nghĩa tương tự. Quá trình này cũng có những cấp độ khác nhau như phép cộng, tìm ra nguyên lý cộng số 2 chữ số, 3 chữ số và cho mọi số. Việc tìm ra nguyên lý cộng cho mọi số không phải bài toán ban đầu bạn nhận được, nếu bạn không nghĩ tới làm sao để cộng số có độ dài tùy ý thì bạn sẽ không tìm ra nó. Sẽ không có phần mềm thiết kế tốt nếu bạn không tưởng tượng. Tưởng tượng là một chuỗi giả định và suy diễn. Quay lại với Dependency Inversion, nguyên lý này khuyến khích bạn dùng các số với độ dài bất kì chứ không phải các dạng số cố định 2 chữ số, 3 chữ số. Nhưng để làm được việc đó, bạn cần phải nắm bắt được nguyên lý của hệ thống rồi. Ở góc độ khác thì nó có thể là một best practice khi bạn tham gia vào một dự án dựng sẵn, và điều này phụ thuộc vào Abstraction Level của code base, để đến cuối cùng, bạn vẫn phải đọc qua toàn bộ thiết kế và cố gắng hiểu nguyên lý của nó.
Quay lại một câu hỏi cơ bản: Vì sao chúng ta dùng thừa kế. Một câu trả lời chúng ta đều được dạy và ghi nhớ là để tái sử dụng. Khi mới học OOP hay lập trình, chúng ta hiểu rằng tái sử dụng là việc sửa dụng lại các thuộc tính và hành vị của lớp cha. Như thế là chưa hết nghĩa. Tái sử dụng còn là sử dụng lại code và dependency liên quan đến lớp cha. Khi khai báo thêm một lớp mới thừa kế từ một lớp cha, bạn không những không phải khai báo lại các thuộc tính hay hành vi của lớp cha, mà con không phải viết lại các đoạn mã sử dụng lớp cha (theo như Liskov Substitution). Đoạn này hơi phức tạp chút vì chúng ta có thể nhầm lẫn khi lớp cha là một concrete class, hay một class thực thi chứ không phải một class trừu tượng. Hãy chú ý thế này: các dependency thường là các lớp hay interface trừu tượng chứ không phải concrete class (theo khuyến nghị của Dependency Inversion); trong trường hợp một concrete class được dùng trong dependency, để mở rộng nó chúng ta sẽ lựa chọn Composition hơn là Inheritance. Composition mặc dù có những ưu điểm nhưng việc cài đặt và kích hoạt nó cần một số điều kiện nhất định trong khi Inheritance có thể hoạt động ngay lập tức và cung cấp khả năng can thiệp sâu hơn.
Composition | Inheritance | |
---|---|---|
Abstraction Level | Có thể tránh việc tăng level | Tăng |
Can thiệp | Hạn chế | Đầy đủ |
Kích hoạt | Nhiều bước, cần kích hoạt thêm cả các lớp chứa | Có thể hoạt động ngay lập tức |
Khả năng áp dụng | Khi Inheritance không hiệu quả hoặc không thực hiện được | Hầu hết trường hợp nhưng kèm theo hiệu ứng phụ nếu Abstraction Level cao |
Khuyến nghị của Dependency Inversion sẽ giúp tối ưu giá trị của các lớp Abstract, hạn chế tăng Abstraction Level, giảm thiểu xung đột khi bạn mở rộng. Số lần sử dụng Composition và Inheritance là hữu hạn và bạn cần phải tiết chế cũng như tiết kiệm chúng.
Một trong những điều hay ho nhất trong 10 năm trở lại đây của coding là DI (Dependency Injection) và IoC container. Hãy chú ý rằng các thư viện nhận mình là DI library còn dân lập trình thì gọi DI library là IoC container. Về mặt kĩ thuật, các DI library là một factory chung kèm thêm thức năng quản lý vòng đời. Cách dùng của lập trình viên biến chúng trở thành IoC (Inversion of Call/Control). Với DI, bạn có thể đăng kí một class rồi inject nó đâu đó, nhưng cách chúng ta được khuyến nghị là viết một interface hoặc abstract class, đăng kí một implementation cho interface đó, inject interface. Đối với library bình thường, bạn phải gọi nó, còn trong IoC, library phải implement lại thứ bạn định nghĩa, tức là đảo ngược vai trò. Như tôi nói ở trên, đây là công việc trừu tượng hóa vấn đề. Khi so sánh IoC với Dependency Inversion, có thể nói như thế này: DI đối với IoC mang tính kĩ thuật hơn. IoC so với Dependency Inversion lại cũng mang tính kĩ thuật hơn, hoặc thực hành hơn. Không có DI/IoC bạn vẫn áp dụng Dependency Inversion. DI/IoC là một thể hiện tuyệt vời của Dependency Inversion.
Cuối cùng, hi vọng những tham luận trên có thể giúp các bạn hiểu rõ hơn về Dependency Inversion.