Nội dung chính
- Nguồn gốc ra đời của SOLID
- Vì sao SOLID giúp lập trình viên viết code tốt hơn?
- 5 nguyên tắc SOLID là gì?
- Single responsibility Principle – Nguyên tắc Đơn nhiệm
- Open/closed principle – Nguyên tắc Mở/đóng
- Liskov Substitution principle – Nguyên tắc Thay thế Liskov
- Interface Segregation principle – Nguyên tắc Phân tách Interface
- Dependency Inversion principle – Nguyên tắc Đảo ngược Phụ thuộc
- Tổng kết
SOLID là gì và vì sao việc tuân thủ SOLID sẽ giúp bạn trở thành lập trình viên giỏi? 5 SOLID principles của lập trình hướng đối tượng giúp các thiết kế hướng đối tượng trở nên dễ hiểu, linh hoạt và dễ bảo trì hơn – điều mà mọi developer đều muốn khi viết code.
Đọc bài viết này để hiểu rõ:
- SOLID là gì?
- 5 SOLID principles (nguyên tắc SOLID) phải thuộc nằm lòng
- Cách áp dụng đúng và sai của từng nguyên tắc SOLID với ví dụ code dễ hiểu
Nguồn gốc ra đời của SOLID
Các SOLID principles được Robert C. Martin giới thiệu trong cuốn sách “Design Principles and Design Patterns” xuất bản năm 2000. Bộ nguyên tắc nguyên bản trong cuốn sách sau đó được Michael Feathers tóm lược lại bằng cụm từ viết tắt SOLID – cụm từ mà chúng ta thường dùng ngày nay.
Và trong hơn 20 năm qua kể từ khi ra mắt, những SOLID principles này đã cách mạng hóa thế giới lập trình hướng đối tượng (OOP) và thay đổi cách chúng ta viết phần mềm.
Vì sao SOLID giúp lập trình viên viết code tốt hơn?
SOLID phục vụ cho mục đích:
“Tạo ra những dòng code giúp các developer dễ đọc, dễ hiểu và dễ kiểm tra khi hợp tác cùng nhau.”
Các SOLID principles có thể được sử dụng để phát triển phần mềm với mục đích dễ duy trì và mở rộng khi dự án phát triển. Việc áp dụng các phương pháp này cũng sẽ góp phần tái cấu trúc codebase sang trạng thái dễ bảo trì hơn hoặc thậm chí áp dụng các phương pháp Agile!
Nói một cách đơn giản, các nguyên tắc thiết kế của Martin và Feathers khuyến khích lập trình viên tạo ra những phần mềm dễ bảo trì, dễ hiểu và linh hoạt hơn. Tất cả những ai đã từng lập trình cũng đều biết rằng bảo trì code (như là thêm chức năng, sửa lỗi,…) mới là phần tốn nhiều thời gian nhất trong quá trình phát triển.
Nhờ có SOLID, khi các ứng dụng phát triển quy mô, chúng ta có thể giảm độ phức tạp và hạn chế các vấn đề rủi ro trong tương lai.
5 nguyên tắc SOLID là gì?
Vậy thì SOLID principles là gì? SOLID principles là năm nguyên tắc thiết kế hướng đối tượng. Chúng là một bộ quy tắc và các phương pháp đã được kiểm chứng và đúc kết bởi nhiều lập trình viên cần tuân theo khi thiết kế cấu trúc class.
Ngoài ra, bạn có thể ghi nhớ thêm một vài Design Pattern phổ biến trong lập trình hướng đối tượng OOP do ITviec tổng hợp.
SOLID là viết tắt từ năm chữ cái đầu của năm nguyên tắc sau:
- Single Responsibility
- Open/Closed
- Liskov Substitution
- Interface Segregation
- Dependency Inversion
Mặc dù những khái niệm này thoạt nghe có vẻ khó hiểu và khó áp dụng, nhưng sau bài viết này kèm theo ví dụ code đơn giản, bạn có thể hiểu SOLID là gì và áp dụng được ngay. Trong phần tiếp theo của bài viết, chúng ta sẽ đi sâu vào 5 SOLID principles này, kèm ví dụ minh họa cực kỳ dễ hiểu cho từng nguyên tắc.
Single responsibility Principle – Nguyên tắc Đơn nhiệm
Nội dung Nguyên tắc Đơn nhiệm như sau:
Một class chỉ nên có một nhiệm vụ. Hơn nữa, mỗi class chỉ nên có một lý do để thay đổi.
Vì sao nguyên tắc Đơn nhiệm lại quan trọng như vậy?
Trước hết, vì nhiều team khác nhau có thể làm việc trong cùng một dự án nên nếu một class có nhiều hơn một trách nhiệm, thì các team đó phải làm việc trên cùng một class và chỉnh sửa cùng một class vì những lý do khác nhau, điều này có thể dẫn đến các module không tương thích.
Lý do thứ hai, nguyên tắc Đơn nhiệm giúp cho việc kiểm soát phiên bản trở nên dễ dàng hơn. Ví dụ: giả sử ta có một persistence class dùng để xử lý các hoạt động của cơ sở dữ liệu và chúng ta nhận thấy có một thay đổi trong tệp đó ở GitHub commit. Bằng cách làm theo nguyên tắc Đơn nhiệm, chúng ta sẽ dễ dàng biết rằng thay đổi đó liên quan đến lưu trữ hay liên quan đến cơ sở dữ liệu.
Ví dụ: Ta hãy nhìn vào một class đại diện cho một cuốn sách đơn giản:
public class Book { private String name; private String author; private String text; //constructor, getters và setters }
Trong đoạn code này, chúng ta lưu trữ tên, tác giả và văn bản được liên kết với một phiên bản của Book.
Bây giờ chúng ta hãy thêm một vài phương thức để truy vấn văn bản:
public class Book { private String name; private String author; private String text; //constructor, getters và setters //các phương thức liên quan trực tiếp đến thuộc tính Book public String replaceWordInText(String word, String replacementWord){ return text.replaceAll(word, replacementWord); } public boolean isWordInText(String word){ return text.contains(word); } }
Để khắc phục tình trạng “thiếu trật tự” trên, chúng ta nên triển khai một class riêng chỉ xử lý việc in văn bản:
public class BookPrinter { //phương thức cho văn bản đầu ra void printTextToConsole(String text){ //code dùng để định dạng và in văn bản } void printTextToAnotherMedium(String text){ //code dùng để viết cho các phương tiện khác } }
Chúng ta không chỉ phát triển một class giúp giảm bớt nhiệm vụ in Book mà chúng ta còn có thể tận dụng class BookPrinter để gửi văn bản tới các phương tiện khác. Dù đó là email, nhật ký hoạt động, hay bất kỳ phương tiện gì, chúng ta đang có một class riêng dành riêng cho nhiệm vụ này.
Open/closed principle – Nguyên tắc Mở/đóng
Nội dung Nguyên tắc Mở/đóng như sau:
Các đối tượng hoặc thực thể nên “mở cửa” với mở rộng nhưng “đóng cửa” với sửa đổi.
Trong đó:
- Mở rộng có nghĩa là thêm chức năng mới vào class hiện có.
- Sửa đổi có nghĩa là thay đổi code của một class hiện có.
Vì vậy, nội dung nguyên tắc Mở/đóng có nghĩa là: Chúng ta có thể thêm chức năng mới mà không cần đụng vào code hiện có của class.
Nguyên nhân là do bất cứ khi nào ta sửa đổi code hiện có, luôn sẽ có nguy cơ tạo ra các lỗi tiềm ẩn. Vì vậy, nếu có thể, chúng ta nên tránh chạm vào code đã được kiểm tra và đang chạy ổn.
Vậy thì làm thế nào để chúng ta thêm chức năng mới mà không phải đụng vào class đó? Chúng ta chỉ cần kế thừa hoặc sở hữu class đó. Hãy đến với ví dụ sau:
Hãy thử tưởng tượng chúng ta đang triển khai một class Guitar. Đây là một chiếc guitar hoàn chỉnh và có cả nút chỉnh âm lượng:
public class Guitar { private String make; private String model; private int volume; //constructors, getters và setters }
Sau một vài tháng, chúng ta thấy rằng Guitar nhìn hơi chán và ta muốn thêm họa tiết hình ngọn lửa để làm cho chiếc guitar này trông “chất” hơn.
Lúc này đây, sẽ có một vài bạn muốn mở class Guitar ra và thêm ngay họa tiết ngọn lửa vào đó nhưng ai biết được những lỗi nào có thể xuất hiện trong ứng dụng nếu chúng ta làm như vậy.
Thay vào đó, hãy tuân theo nguyên tắc Mở/đóng và chỉ cần mở rộng class Guitar:
public class SuperCoolGuitarWithFlames extends Guitar { private String flameColor; //constructor, getters và setters }
Bằng cách mở rộng class Guitar, ta có thể chắc chắn rằng ứng dụng hiện có sẽ không bị ảnh hưởng gì cả.
Liskov Substitution principle – Nguyên tắc Thay thế Liskov
Nội dung Nguyên tắc Thay thế Liskov như sau:
Giả sử Φ(x) là một thuộc tính có thể chứng minh được đối với các đối tượng x thuộc loại T. Khi đó, Φ(y) phải có thể chứng minh được đối với các đối tượng y thuộc loại S mà trong đó S là một class con của T.
Nguyên tắc này có nghĩa là mọi class con (subclass) hoặc class dẫn xuất phải có khả năng thay thế cho class cơ sở hoặc class cha (superclass) của chúng mà không làm ảnh hưởng đến ứng dụng.
Trong 5 SOLID principles, nguyên tắc Thay thế Liskov là nguyên tắc trừu tượng và khó hiểu nhất. Chính vì thế, thay vì giải thích thêm, bạn có thể hiểu nhanh bằng ví dụ sau:
Giả sử bạn có một class hình chữ nhật để tính diện tích của một hình chữ nhật và thực hiện các hoạt động khác như xác định màu sắc:
class Rectangle { setWidth(width) { this.width = width; } setHeight(height) { this.height = height; } setColor(color) { // ... } getArea() { return this.width * this.height; } }
Chúng ta đều biết rằng tất cả các hình vuông là hình chữ nhật nên bạn có thể kế thừa các thuộc tính của hình chữ nhật. Vì chiều rộng và chiều cao phải giống nhau, nên bạn có thể điều chỉnh code như sau:
class Square extends Rectangle { setWidth(width) { this.width = width; this.height = width; } setHeight(height) { this.width = height; this.height = height; } }
Nhìn vào ví dụ này, code nên hoạt động bình thường:
let rectangle = new Rectangle(); rectangle.setWidth(10); rectangle.setHeight(5); console.log(rectangle.getArea()); // 50
Ở đoạn code trên, bạn sẽ nhận thấy rằng một hình chữ nhật đã được tạo, và chiều rộng và chiều cao đã được xác định. Sau đó, bạn có thể tính toán diện tích chính xác.
Tuy nhiên, theo nguyên tắc Thay thế Liskov, các đối tượng của class con cần hoạt động giống như các đối tượng của class cha. Có nghĩa là nếu bạn thay thế hình chữ nhật bằng hình vuông, code vẫn nên hoạt động bình thường:
let square = new Square(); square.setWidth(10); square.setHeight(5);
Kết quả đáng lẽ ra nên là 100, bởi vì setWidth(10) nên cả chiều cao và chiều rộng đều là 10. Tuy nhiên, bởi vì setHeight(5), nên kết quả sẽ là 25.
let square = new Square(); square.setWidth(10); square.setHeight(5); console.log(square.getArea()); // 25
Tuy nhiên, như vậy sẽ phá vỡ nguyên tắc Thay thế Liskov. Để khắc phục lỗi này, chúng ta cần có một class chung cho tất cả các hình dạng mà sẽ chứa tất cả các phương thức chung mà bạn muốn các đối tượng thuộc các class con truy cập được. Sau đó, đối với các phương thức riêng lẻ, bạn tạo một class riêng cho hình chữ nhật và hình vuông.
class Shape { setColor(color) { this.color = color; } getColor() { return this.color; } } class Rectangle extends Shape { setWidth(width) { this.width = width; } setHeight(height) { this.height = height; } getArea() { return this.width * this.height; } } class Square extends Shape { setSide(side) { this.side = side; } getArea() { return this.side * this.side; } }
Bằng cách này, bạn có thể đặt màu và lấy màu bằng các class cha hoặc class con:
// superclass let shape = new Shape(); shape.setColor('red'); console.log(shape.getColor()); // đỏ // subclass let rectangle = new Rectangle(); rectangle.setColor('red'); console.log(rectangle.getColor()); // đỏ // subclass let square = new Square(); square.setColor('red'); console.log(square.getColor()); // đỏ
Interface Segregation principle – Nguyên tắc Phân tách Interface
Nội dung Nguyên tắc Phân tách Interface như sau:
Một khách hàng không nên bị buộc phải triển khai một giao diện mà họ không sử dụng, hoặc phụ thuộc vào các phương pháp họ không sử dụng.
Trong ví dụ này, chúng ta sẽ thử “hóa thân” thành những người làm việc trong sở thú, cụ thể là vị trí chăm sóc hổ.
Trước hết, hãy bắt đầu với một interface dùng để liệt kê những đầu việc của một người chăm sóc hổ.
public interface TigerKeeper { void washTheTiger(); void feedTheTiger(); void petTheTiger(); }
Là một người chăm sóc thú năng nổ, chúng ta sẵn sàng tắm rửa và cho hổ ăn. Tuy nhiên, không phải ai cũng có đủ kỹ năng và lòng can đảm để “nựng” một chú hổ to con cả. Nhưng do interface của chúng ta quá rộng, nên ta bắt buộc phải triển khai code để “nựng” chú hổ ấy.
Chúng ta có thể sửa lỗi này bằng cách chia interface lớn thành ba interface riêng biệt:
public interface TigerCleaner { void washTheTiger(); } public interface TigerFeeder { void feedTheTiger(); } public interface TigerPetter { void petTheTiger(); }
Giờ đây, nhờ có sự phân tách interface, ta có thể tự do triển khai chỉ những phương thức cần thiết đối với chúng ta:
public class TigerCarer implements TigerCleaner, TigerFeeder { public void washTheTiger() { //Tắm rửa mà còn dơ! } public void feedTheTiger() { //Thứ 3 ăn cá ngừ } }
Và cuối cùng, bạn có thể nhường nhiệm vụ “nựng” cho những ai đủ kỹ năng:
public class SkilledPerson implements TigerPetter { public void petTheTiger() { //Cẩn thận bạn nhé! } }
Dependency Inversion principle – Nguyên tắc Đảo ngược Phụ thuộc
Nội dung Nguyên tắc Đảo ngược Phụ thuộc như sau:
Các thực thể phải phụ thuộc vào abstraction, không phải class cụ thể hay chức năng cụ thể.
Module cấp cao không được phụ thuộc vào module cấp thấp, nhưng chúng đều phải phụ thuộc vào abstraction.
Nói một cách đơn giản, nguyên tắc Đảo ngược Phụ thuộc nói rằng các class nên phụ thuộc vào interface hoặc abstraction thay vì các class và chức năng cụ thể. Điều này làm cho các class mở rộng, tuân theo nguyên tắc Mở/đóng.
Để ví dụ cách áp dụng của nguyên tắc này, hãy cùng “hoài niệm” với Windows 98:
public class Windows98Machine {}
Nhưng làm sao một chiếc máy tính lại có thể không có màn hình và bàn phím được? Hãy thêm chúng vào constructor để mọi Windows98Machine mà chúng ta khởi tạo đều có sẵn Monitor và một StandardKeyboard:
public class Windows98Machine { private final StandardKeyboard keyboard; private final Monitor monitor; public Windows98Machine() { monitor = new Monitor(); keyboard = new StandardKeyboard(); } }
Với code này, chúng ta có thể sử dụng StandardKeyboard và Monitor thoải mái trong class Windows98Machine.
Tuy nhiên, bằng cách khai báo StandardKeyboard và Monitor với từ khóa mới, chúng ta đã liên kết chặt chẽ ba class này lại với nhau.
Điều này không chỉ làm cho Windows98Machine khó kiểm tra mà chúng ta còn mất khả năng chuyển đổi class StandardKeyboard bằng một lớp khác nếu có nhu cầu. Và chúng ta cũng không thể chuyển đổi class Monitor.
Hãy tách Windows98Machine ra khỏi StandardKeyboard bằng cách thêm một interface Keyboard tổng quát hơn và sử dụng interface này trong class:
public interface Keyboard { }
public class Windows98Machine{ private final Keyboard keyboard; private final Monitor monitor; public Windows98Machine(Keyboard keyboard, Monitor monitor) { this.keyboard = keyboard; this.monitor = monitor; } }
Ở đây, chúng ta đang sử dụng dependency injection pattern để thêm Keyboard dependency vào class Windows98Machine.
Chúng ta cũng hãy sửa đổi class StandardKeyboard để triển khai giao diện Keyboard sao cho phù hợp để đưa vào class Windows98Machine:
public class StandardKeyboard implements Keyboard { }
Bây giờ, các class đã được tách rời và giao tiếp thông qua Keyboard abstraction. Nếu muốn, chúng ta có thể dễ dàng chuyển đổi loại bàn phím trong máy bằng cách triển khai interface khác. Chúng ta có thể làm theo nguyên tắc tương tự cho class Monitor.
Giờ chúng ta đã tách rời các thành phần phụ thuộc và có thể tự do kiểm Windows98Machine với bất kỳ framework kiểm tra nào.
Tổng kết
Trong bài viết này, chúng ta đã tìm hiểu sâu về SOLID là gì và SOLID principles là gì. Bài viết bắt đầu bằng lịch sử hình thành của SOLID và lý do vì sao những nguyên tắc này tồn tại và cần thiết trong lập trình.
Bằng cách phân tích từng chữ cái một trong SOLID, ITviec đã đi sâu vào ý nghĩa của từng nguyên tắc cũng như những cách áp dụng phù hợp được minh họa qua ví dụ dễ hiểu.
Bạn thấy bài viết hay và hữu ích? Đừng ngại Share với bạn bè và đồng nghiệp nhé.
Và nhanh tay tham khảo việc làm IT “chất” trên ITviec!