cv.cm

Lập trình · Mẫu thiết kế

Nguyên lý SOLID giải thích kèm ví dụ

8 phút đọc

Trả lời nhanh

SOLID là từ viết tắt của năm nguyên lý thiết kế hướng đối tượng: Single responsibility (đơn trách nhiệm), Open/closed (đóng/mở), Liskov substitution (thay thế Liskov), Interface segregation (tách interface) và Dependency inversion (đảo ngược phụ thuộc). Chúng được Robert C. Martin tổng hợp, còn từ viết tắt do Michael Feathers đặt ra. Cùng nhau, chúng nhằm làm mã dễ thay đổi hơn: mỗi lớp chỉ có một lý do để thay đổi, hành vi mới được thêm mà không sửa mã cũ, kiểu con là sự thay thế an toàn, interface luôn nhỏ gọn, và mã cấp cao phụ thuộc vào abstraction.

SOLID nghĩa là gì

Chữ cáiNguyên lýQuy tắc ngắn gọn
SNguyên lý đơn trách nhiệm (Single responsibility principle, SRP)Một lớp chỉ nên có một lý do để thay đổi.
ONguyên lý đóng/mở (Open/closed principle, OCP)Phần mềm nên mở để mở rộng nhưng đóng đối với sửa đổi.
LNguyên lý thay thế Liskov (Liskov substitution principle, LSP)Kiểu con phải dùng được ở bất cứ nơi nào mong đợi kiểu cha.
INguyên lý tách interface (Interface segregation principle, ISP)Không nên ép client phụ thuộc vào những phương thức mà nó không dùng.
DNguyên lý đảo ngược phụ thuộc (Dependency inversion principle, DIP)Hãy phụ thuộc vào abstraction, không phụ thuộc vào chi tiết cụ thể.

Các ý tưởng này có trước từ viết tắt. Nguyên lý đóng/mở xuất phát từ cuốn Object-Oriented Software Construction (1988) của Bertrand Meyer, còn nguyên lý thay thế từ bài diễn văn năm 1987 Data Abstraction and Hierarchy của Barbara Liskov. Robert C. Martin mô tả cả năm nguyên lý trong các bài viết quanh năm 2000, và Michael Feathers sau đó sắp xếp chúng thành từ SOLID.

S: Nguyên lý đơn trách nhiệm

Một lớp nên có một lý do để thay đổi, nghĩa là nó phục vụ một tác nhân hoặc một mối quan tâm. Khi một lớp trộn lẫn quy tắc nghiệp vụ, định dạng và lưu trữ, thay đổi ở bất kỳ phần nào cũng có nguy cơ làm hỏng các phần còn lại.

Trước: một lớp vừa tính toán, vừa định dạng, vừa lưu báo cáo.

class Report {
  constructor(private sales: number[]) {}
  total(): number { return this.sales.reduce((a, b) => a + b, 0); }
  toHtml(): string { return `<p>Total: ${this.total()}</p>`; }
  saveToFile(path: string): void { /* write this.toHtml() to disk */ }
}

Sau: mỗi mối quan tâm nằm trong một đơn vị nhỏ riêng, nên một định dạng đầu ra mới không đụng tới phần tính toán.

class SalesReport {
  constructor(private sales: number[]) {}
  total(): number { return this.sales.reduce((a, b) => a + b, 0); }
}
class HtmlFormatter {
  format(r: SalesReport): string { return `<p>Total: ${r.total()}</p>`; }
}
class ReportWriter {
  save(content: string, path: string): void { /* write to disk */ }
}
const html = new HtmlFormatter().format(new SalesReport([10, 20, 5]));
console.log(html); // <p>Total: 35</p>

O: Nguyên lý đóng/mở

Bạn nên thêm được hành vi mới bằng cách thêm mã mới, chứ không phải sửa mã đã chạy tốt và đã được kiểm thử. Công cụ thường dùng là interface cộng đa hình.

Trước: mỗi hình mới đồng nghĩa với việc sửa hàm area.

type Shape = { kind: "circle"; r: number } | { kind: "square"; side: number };
function area(s: Shape): number {
  if (s.kind === "circle") return Math.PI * s.r ** 2;
  if (s.kind === "square") return s.side ** 2;
  throw new Error("unknown shape");
}

Sau: một hình mới là một lớp mới; mã hiện có giữ nguyên.

interface Shape { area(): number; }
class Circle implements Shape {
  constructor(private r: number) {}
  area(): number { return Math.PI * this.r ** 2; }
}
class Square implements Shape {
  constructor(private side: number) {}
  area(): number { return this.side ** 2; }
}
const totalArea = (shapes: Shape[]) => shapes.reduce((sum, s) => sum + s.area(), 0);
console.log(totalArea([new Square(2), new Square(3)])); // 13

L: Nguyên lý thay thế Liskov

Nếu mã chạy được với kiểu cha, nó phải tiếp tục chạy khi nhận bất kỳ kiểu con nào. Kiểu con không được siết chặt điều kiện tiên quyết, làm yếu các đảm bảo, hay ném lỗi ở chỗ kiểu cha không ném.

Trước: bài toán hình vuông/hình chữ nhật kinh điển. Square kế thừa Rectangle nhưng phá vỡ kỳ vọng rằng chiều rộng và chiều cao độc lập với nhau.

class Rectangle {
  constructor(public width: number, public height: number) {}
  setWidth(w: number): void { this.width = w; }
  setHeight(h: number): void { this.height = h; }
  area(): number { return this.width * this.height; }
}
class Square extends Rectangle {
  setWidth(w: number): void { this.width = w; this.height = w; }
  setHeight(h: number): void { this.width = h; this.height = h; }
}
function stretch(r: Rectangle): number {
  r.setWidth(5);
  r.setHeight(4);
  return r.area(); // caller expects 20
}
console.log(stretch(new Rectangle(1, 1))); // 20
console.log(stretch(new Square(1, 1)));    // 16, surprise

Sau: đừng mô hình hóa quan hệ này bằng kế thừa. Cả hai kiểu cùng triển khai một interface chỉ đọc chung, và không ai mong đổi kích thước các cạnh của hình vuông một cách độc lập.

interface Shape { area(): number; }
class Rectangle implements Shape {
  constructor(readonly width: number, readonly height: number) {}
  area(): number { return this.width * this.height; }
}
class Square implements Shape {
  constructor(readonly side: number) {}
  area(): number { return this.side ** 2; }
}
console.log([new Rectangle(5, 4), new Square(4)].map((s) => s.area())); // [ 20, 16 ]

I: Nguyên lý tách interface

Interface lớn ép các lớp phải triển khai những phương thức chúng không hỗ trợ được, thường bằng cách ném lỗi. Hãy chia chúng thành các interface nhỏ, tập trung, và để mỗi lớp chỉ triển khai những gì nó thực sự làm.

Trước: một máy in đơn giản buộc phải giả vờ có thể quét và gửi fax.

interface Machine {
  print(doc: string): void;
  scan(): string;
  fax(doc: string): void;
}
class BasicPrinter implements Machine {
  print(doc: string): void { console.log("print", doc); }
  scan(): string { throw new Error("not supported"); }
  fax(doc: string): void { throw new Error("not supported"); }
}

Sau: các interface vai trò nhỏ có thể kết hợp với nhau.

interface Printer { print(doc: string): void; }
interface Scanner { scan(): string; }
class BasicPrinter implements Printer {
  print(doc: string): void { console.log("print", doc); }
}
class OfficeMachine implements Printer, Scanner {
  print(doc: string): void { console.log("print", doc); }
  scan(): string { return "scanned page"; }
}
function printAll(p: Printer, docs: string[]): void { docs.forEach((d) => p.print(d)); }
printAll(new BasicPrinter(), ["a.pdf"]); // print a.pdf

D: Nguyên lý đảo ngược phụ thuộc

Chính sách cấp cao không nên phụ thuộc vào chi tiết cấp thấp như một cơ sở dữ liệu hay dịch vụ email cụ thể. Cả hai nên phụ thuộc vào một abstraction, và module cấp cao thường sở hữu abstraction đó. Trong thực tế, bạn truyền các phụ thuộc vào (dependency injection) thay vì tạo chúng bên trong.

Trước: service tự tạo một client cơ sở dữ liệu cụ thể, nên không thể kiểm thử hay đổi sang loại khác.

class MySqlUserStore {
  find(id: string): string { return "user " + id; }
}
class UserService {
  private store = new MySqlUserStore(); // hard-wired detail
  greet(id: string): string { return "Hello, " + this.store.find(id); }
}

Sau: service phụ thuộc vào một interface; có thể tiêm vào bất kỳ cách triển khai nào, kể cả bản giả trong kiểm thử.

interface UserStore { find(id: string): string; }
class UserService {
  constructor(private store: UserStore) {}
  greet(id: string): string { return "Hello, " + this.store.find(id); }
}
class InMemoryUserStore implements UserStore {
  constructor(private users: Record<string, string>) {}
  find(id: string): string { return this.users[id] ?? "unknown"; }
}
const service = new UserService(new InMemoryUserStore({ u1: "Ada" }));
console.log(service.greet("u1")); // Hello, Ada

Khi nào nên áp dụng SOLID, và khi nào không

Các nguyên lý SOLID là hướng dẫn cho mã thay đổi thường xuyên, không phải luật. Áp dụng máy móc, chúng tạo ra rất nhiều lớp và interface nhỏ xíu chỉ có một cách triển khai, khiến mã khó theo dõi hơn.

  • Hãy áp dụng khi bạn thấy đúng nỗi đau mà chúng giải quyết: một lớp hỏng mỗi khi có gì thay đổi, một bài kiểm thử cần cơ sở dữ liệu thật, hoặc một câu lệnh switch bị sao chép ra nhiều nơi.
  • Đừng thêm interface cho mọi lớp từ trước. Hãy tách ra khi xuất hiện cách triển khai thứ hai hoặc một đối tượng giả cho kiểm thử.
  • Trong script nhỏ và bản mẫu, mã đơn giản và trực tiếp thường tốt hơn.
  • Nhiều mẫu thiết kế là ứng dụng cụ thể của SOLID: mẫu Strategy và mẫu Decorator hỗ trợ nguyên lý đóng/mở, mẫu Adapter và mẫu Facade giúp cô lập chi tiết, còn mẫu Abstract Factory hỗ trợ đảo ngược phụ thuộc.

Câu hỏi thường gặp

Ai tạo ra các nguyên lý SOLID?

Robert C. Martin tổng hợp và mô tả năm nguyên lý, dựa trên công trình trước đó của Bertrand Meyer (đóng/mở) và Barbara Liskov (thay thế). Michael Feathers đặt ra từ viết tắt SOLID.

SOLID có áp dụng cho lập trình hàm không?

Các ý tưởng chuyển đổi được: hàm nhỏ với một mục đích, kết hợp hành vi thay vì sửa nó, và truyền phụ thuộc làm tham số. Cách diễn đạt dựa trên lớp là riêng của mã hướng đối tượng.

Khác nhau giữa đảo ngược phụ thuộc và tiêm phụ thuộc là gì?

Đảo ngược phụ thuộc là nguyên lý rằng mã cấp cao nên phụ thuộc vào abstraction. Tiêm phụ thuộc là kỹ thuật cung cấp các phụ thuộc đó từ bên ngoài, ví dụ qua hàm khởi tạo.

Nguyên lý SOLID nào quan trọng nhất?

Không có xếp hạng chính thức. Đơn trách nhiệm và đảo ngược phụ thuộc thường mang lại lợi ích nhanh nhất vì chúng làm mã dễ hiểu và dễ kiểm thử hơn.

Vi phạm một nguyên lý SOLID có tệ không?

Không phải lúc nào cũng vậy. Mỗi nguyên lý đều có cái giá là mã nhiều hơn. Hãy cố ý vi phạm khi thiết kế đơn giản hơn rõ ràng hơn và thay đổi mà nó phòng ngừa ít có khả năng xảy ra.

Tất cả hướng dẫn lập trình