Trễ deadline là điều mà không một lập trình viên nào muốn, code tệ là một trong những yếu tố lớn nhất dẫn tới điều này. Code nhanh nhưng không theo nguyên tắc sẽ làm cho code tệ. Để giúp dự án theo hướng đối tượng dễ bảo trì, thay đổi và phát triển tôi sẽ giới thiệu về các nguyên tắc SOLID.
Lịch sử
SOLID là 5 nguyên tắc mà Robert Cecil Martin hay còn được biết đến là "Uncle Bob" đã giới thiệu trong cuốn sách Design Principles and Design Patterns. Những nguyên tắc này ra đời để giúp lập trình viên sử dụng các ngôn ngữ lập trình hướng đối tượng hiệu quả nhất.
SOLID - đối tượng hướng đến
Bài viết này sẽ giới thiệu về nguyên tắc solid cho các lập trình viên đang phát triển chương trình theo hướng đối tượng cũng như vì sao đây là các nguyên tắc quan trọng cho lập trình hướng đối tượng - OOP. Các nguyên tắc hình thành để giúp cho việc xây dựng dự án theo hướng đối tượng dễ bảo trì, phát triển và nâng cấ. Tuỳ thuộc vào mục đích và nhu cầu các giải pháp bên dưới có thể sẽ không phải là câu trả lời phù hợp nhất cho bài toán của bạn đọc.
S - SRP - The Single Responsibility Principle
Nguyên tắc nêu rằng Class chỉ nên có một và chỉ một lý do để phải thay đổi code. Nguyên tắc này được đề cập trong các dự án của Tom DeMarco và Meilir Page-Jones họ gọi đây là sự kết dính của code.
A CLASS SHOULD HAVE ONLY ONE REASON TO CHANGE
"Responsibility" là từ mà các tác giả dùng để mô tả nguyên tắc này, Trách Nhiệm nhằm nói lên mục đích và lý do Class phải thay đổi code. Theo quan điểm của tôi quy tắc này là quan trọng nhất, chúng ta phân chia Class và nội dung code chỉ nên có một và duy nhất một "trách nhiệm": lưu dữ liệu vào CSDL, lưu trữ đối tượng, vẽ đối tượng, ...
Tôi sẽ làm rõ theo sơ đồ UML bên dưới.
Ta có thể thấy Class Animal được xây dựng trên các nguyên tắc lập trình hướng đối tượng, đầu tiên là tính chất trừu tượng của code (Class Animal) và tính đóng gói khi có thuộc tính type loại dữ liệu Enum AnimalType là private phải thông qua phương thức setType để hạn chế việc thay đổi giá trị trực tiếp. Class này trở nên hoàn thiện hơn khi có các phương thức, nhìn vào ta có thể đánh giá lớp này đã được xây dựng một cách trực quan và tốt nhất. Nhưng điều này đã vi phạm nguyên tắc SRP, tôi sẽ giải thích như sau.
Class Animal đang có 2 trách nhiệm mà chúng ta phải quan tâm để thay đổi (thuộc tính, phương thức, ...):
setType()
- khai báo loại động vậtaddToZoo()
- thêm động vật vào sở thú
Đặt vấn đề như sau, khi chi nhánh sở thú bị dời đi và ta cần phải di chuyển các động vật tới sở thú mới, điều đầu tiên cần làm là thay đổi nội dung addToZoo()
để có thể dời các con vật của chúng ta tới đúng địa chỉ của sở thú mới. Điều này là bất hợp lý vì Class Animal không cần phải quan tâm việc dời tới một địa điểm mới. Trách nhiệm, chỉ nên có một để ta có thể dễ dàng phân chia các lớp giúp cho việc bảo trì dễ dàng hơn mà không cần phải để tâm tới Class Animal đang có nhiều hơn 1 trách nhiệm.
Vì thú thật mà nói tôi sẽ không bao giờ nghĩ Class Animal sẽ có code thêm động vật vào sở thú trong khi kiến trúc dự án lại có Class Zoo. Vậy ta sẽ giải quyết vấn đề trên như thế nào?
Ta có thể thấy mỗi Class đã có từng trách nhiệm đơn theo nguyên tắc SRP, điều này sẽ giảm thiểu rất nhiều thời gian debug, tìm phương thức thuộc Class nào trong quá trình bảo trì và phát triển dự án. Lúc này Animal chỉ quan tâm việc lưu dữ liệu (loại động vật), Zoo sẽ quan tâm việc thêm Animal vào trong sở thú addAnimalToZoo()
. Khi sở thú di dời tới địa chỉ mới ta lập tức biết Class Zoo chịu trách nhiệm cho việc này và nhanh chóng phát triển và thay đổi nội dung của phương thức.
O - OCP -The Open Closed Principle
Dễ dàng mở rộng để phát triển nhưng ngăn chặn việc thay đổi chỉnh sửa code. Khi xây dựng một hệ thống, phần mềm, ứng dụng, ... sự thay đổi là không thể tránh khỏi trong quá trình phát triển chúng điều này sẽ giúp cho chương trình "sống" lâu hơn phiên bản đầu tiên. Bertrand Meyer đã chứng minh điều này vào năm 1988 để giúp cho lập trình viên dễ thở hơn khi thay đổi hoặc hiện thực tính năng mới.
SOFTWARE ARE ENTITIES (CLASSES, MODULES, FUNCTIONS, ETC).
SHOULD BE OPEN FOR EXTENSION, BUT CLOSED FOR MODIFICATION.
Hiểu một góc nhìn đơn giản nhất chúng ta không thay đổi code cũ khi yêu cầu thay đổi hoặc tính năng mới sinh ra. Người lập trình viên thực hiện điều này bằng cách mở rộng (extension) thông qua code mới (kế thừa - Inheritance). Không ai muốn phải thay đổi một tính năng đang chạy một cách hoàn hảo chỉ để thêm một tính năng mới hoặc vì yêu cầu khách hàng thay đổi.
Quay lại với ví dụ bên trên ta đặt vấn đề như sau để có thể nắm rõ OCP hơn
Ta có yêu cầu phải biết mỗi loài động vật khi thêm vào sở thú có gì đặc biệt để thu hút người tới xem. Nhìn vào statement switch ở đoạn code trên ta có thể thấy, nếu là chim sẽ biết bay, là cá sẽ có thể thở dưới nước, là lớp thú sẽ có thể tạo ra sữa cho con. Thú vị đấy vậy bây giờ hãy tưởng tượng sở thú sẽ thêm các loài khác như: bò sát, côn trùng, lớp nhện, ... Là lập trình viên chúng ta phải thay đổi những gì để cho code chạy đúng?
Cần phải thêm đủ các loài động vật cho enum AnimalType
mà sở thú sẽ xử lý, và tiếp đến là.
Thêm các case mới để có thể hỗ trợ loài động vật mới, còn chưa nói tới phải thêm thông tin loài mới này có thể làm gì đặc biệt. Nhưng may mắn là trên thế giới không quá 10 loài động vật, ta chỉ cần nhớ để thêm vào enum và case mới khi sở thú hỗ trợ loài mới thôi đúng không. Lỡ như năm sau phát hiện thêm 10 loài mới, lúc này CHẮC CHẮN phải thêm cho đủ enum và case mới cho switch. OCP khuyên chúng ta là "mở rộng" chứ không được "thay đổi" điều mà ta đang làm bên trên là thay đổi enum và thay đổi số lượng case cho switch để hỗ trợ enum mới. Áp dụng lập trình hướng đối tượng về đa hình và kế thừa ta hiện thực OCP như sau.
Ta có thể thấy lúc này không còn switch, enum, và mỗi loài có trách nhiệm quan tâm thông tin đặc biệt của bản thân. Vừa theo nguyên tắc SRP vừa đáp ứng OCP, code còn linh hoạt và hỗ trợ nhiều tính năng hơn nữa. Hãy so sánh trước và sau của code bên dưới và cho tôi biết dưới phần bình luận.
L - LSP - The Liskov Substitution Principle
Lớp con có thể dễ dàng thay thế lớp cha khi sử dụng trong hàm hoặc phương thức mà không gây ảnh hưởng tới chương trình. Code dễ bảo trì và dễ sử dụng lại là code "đẹp", mọi lập trình viên đều muốn có điều này. Sử dụng lại code mình viết từ một dự án trước sẽ tiết kiệm hàng tá thời gian debug, thử sai và hoàn thiện. Nguyên tắc LSP sẽ giúp ta đạt được điều này dễ dàng nhất.
FUNCTIONS THAT USE POINTERS OR REFERENCES TO BASE CLASSESS
MUST BE ABLE TO USE OBJECT OF DERIVED CLASSES
WITHOUT KNOWING IT.
Barbara Liskov đã hoàn thiện điều này và các bạn đọc cũng thấy đấy nguyên tắc này được đặt tên theo bà. Tính Đa Hình (Polymorphism) là điều mà bà Liskov muốn mọi người sử dụng và khác với 2 nguyên tắc tôi đề cập bên trên thay vì "SHOULD" mà là "MUST BE" bởi vì nguyên tắc này là thiết yếu cho việc sử dụng lại code cũ (reuseable).
Ta cũng có thể thấy điều này trong ví dụ tôi đã đề cập từ trước.
Phương thức addAnimalToZoo
sử dụng base class Animal
và có thể dễ dàng truyền(1) Fish
, Bird
, Mammals
vào mà không ảnh hưởng tới chương trình thông qua tính đa hình của OOP.
I - ISP - The Interface Segregation Principle
Không áp đặt các phương thức cho các đối tượng phải hiện thực từ interface khi không cần thiết. "Thừa còn hơn thiếu" một châm ngôn nổi tiếng, nhưng áp dụng vào lập trình sẽ là một thảm họa khi các đối tượng có những phương thức thừa.
CLIENTS SHOULD NOT BE FORCED TO DEPEND UPON INTERFACES THAT THEY DO NOT USE
KHÔNG sử dụng interface chứa tất cả các phương thức, hàm mà các lớp hiện thực không sử dụng chúng, thay vì thế thì tách chúng ra thành nhiều interface nhỏ để ta có thể dễ dàng hiện thực và tách nhỏ tính năng một lớp có.
Vì sao một con hổ có phương thức bay?
D - DIP - The Dependency Inversion Principle
Phụ thuộc vào sự trừu tượng của đối tượng, không phải chi tiết. Nguyên tắc cuối cùng và đơn giản nhất, ít nhất là với tôi.
DEPEND ON ABSTRACTIONS, NOT ON CONCRETIONS
Khi đã hiểu được LSP thì nguyên tắc này có thể nói là bổ sung cho lập luận của bà Liskov thêm chắc chắn hơn thôi, Tính Đa Hình (Polymorphism) là chìa khóa dẫn tới "code đẹp". Nhưng thật sự mà nói nguyên tắc này còn hơn cả vậy, sự phụ thuộc và tính kết dính(không phải là điều tốt) của hệ thống phụ thuộc vào người lập trình viên có xây dựng hệ thống dựa trên nguyên tắc này không.
Ta đặt vấn đề như sau, khi KeyboardDriver là một lớp để cho Hệ Điều Hành hiểu được ta nhập gì vào bàn phím, và người dùng sử dụng rất nhiều loại bàn phím khác nhau(Logitech, Razer, Microsoft, ...) theo mô hình UML bên dưới
Có vẻ như mọi thứ hoàn hảo nếu như chương trình này chỉ hỗ trợ mỗi bàn phím Logitech, nhưng lập trình viên được biết đến là những người tinh vi, mà lại không thể hỗ trợ bàn phím Gaming RGB Razer giá 80$ thì sẽ bị chỉ trích rất nhiều. Vậy ta phải giải quyết vấn đề này tốt hơn, một hệ thống mà có thể hỗ trợ bất cứ bàn phím nào có và sẽ có trên thị trường.
Ta đã thay đổi và phát triển cho Class KeyboardDriver phụ thuộc (depended) vào một lớp trừu tượng (Class Keyboard), từ đó các loại bàn phím khác chỉ cần kế thừa lớp trừu tượng này và cung cấp cho KeyboardDriver. Hệ thống lúc này đã có thể dễ dàng mở rộng và phát triển.
Lời cuối
Đây sẽ là một bài viết rất dài và khô khan nếu phân tích từng nguyên tắc của SOLID và code mẫu, nên tôi xin phép thực hiện điều này trong các bài viết khác. Tùy theo nhu cầu và tính năng của từng hệ thống mà sẽ có nguyên tắc trong SOLID không phải là giải pháp tốt nhất cho bài toán. Dựa trên kinh nghiệm và kiến thức lập trình viên sẽ có thể vượt qua những vấn đề đó.