cv.cm

編碼 · 設計模式

SOLID 原則詳解與範例

閱讀約 8 分鐘

簡答

SOLID 是五項物件導向設計原則的縮寫:單一職責(Single responsibility)、開放封閉(Open/closed)、里氏替換(Liskov substitution)、介面隔離(Interface segregation)與依賴反轉(Dependency inversion)。這些原則由 Robert C. Martin 整理,縮寫則是 Michael Feathers 取的。它們的共同目標是讓程式碼更容易修改:每個類別只有一個變動的理由,新增行為時不必改動舊程式碼,子型別能安全地取代父型別,介面保持精簡,高層程式碼則依賴抽象。

SOLID 的意思

字母原則簡短規則
S單一職責原則(SRP)一個類別應該只有一個變動的理由。
O開放封閉原則(OCP)軟體應該對擴充開放,對修改封閉。
L里氏替換原則(LSP)凡是預期使用基底型別的地方,都必須能換成它的子型別。
I介面隔離原則(ISP)不該強迫使用端依賴它用不到的方法。
D依賴反轉原則(DIP)要依賴抽象,而不是依賴具體的細節。

這些觀念比縮寫本身更早出現。開放封閉原則出自 Bertrand Meyer 的著作 Object-Oriented Software Construction(1988),替換原則則來自 Barbara Liskov 在 1987 年的主題演講 Data Abstraction and Hierarchy。Robert C. Martin 在 2000 年前後的文章中描述了這五項原則,之後 Michael Feathers 把它們排成 SOLID 這個單字。

S:單一職責原則

一個類別應該只有一個變動的理由,也就是只服務一個角色或一項關注點。當類別同時混著業務規則、格式化與儲存,改動其中任何一項,都可能弄壞其他部分。

改寫前:一個類別同時負責計算、格式化並儲存報表。

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 */ }
}

改寫後:每項關注點各自放在小單元裡,新增輸出格式時就不必動到計算邏輯。

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:開放封閉原則

你應該能用新增程式碼的方式加入新行為,而不是去修改已經能運作、也測試過的程式碼。常用的工具是介面加上多型。

改寫前:每多一種形狀,就得修改 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");
}

改寫後:新形狀就是一個新類別,既有程式碼保持不變。

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:里氏替換原則

如果程式碼能搭配基底型別運作,換成任何子型別時也必須繼續運作。子型別不可以加強前置條件、削弱保證,也不可以在基底型別不會拋出例外的地方拋出例外。

改寫前:經典的正方形/長方形問題。Square 繼承 Rectangle,卻破壞了「寬與高彼此獨立」的預期。

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

改寫後:不要用繼承來建立這層關係。兩個型別都實作共同的唯讀介面,也就沒有人會期待能獨立調整正方形的邊長。

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:介面隔離原則

過大的介面會逼類別去實作它們無法支援的方法,通常只能靠拋出錯誤應付。請把介面拆成小而專注的介面,讓每個類別只實作它真正做得到的部分。

改寫前:一台單純的印表機被迫假裝自己能掃描和傳真。

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"); }
}

改寫後:拆成小的角色介面,需要時可以組合使用。

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:依賴反轉原則

高層的策略不該依賴低層的細節,例如特定的資料庫或電子郵件服務。兩者都應該依賴一個抽象,而且這個抽象通常由高層模組所擁有。實務上的做法是把依賴從外部傳入(依賴注入),而不是在內部自己建立。

改寫前:服務自己建立具體的資料庫用戶端,因此無法測試,也無法替換。

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); }
}

改寫後:服務依賴一個介面,任何實作都可以注入,包括測試中的假物件。

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

何時該套用 SOLID,何時不該

SOLID 原則是針對常常變動的程式碼所提供的指引,並非鐵律。盲目套用只會產生許多細碎的類別,以及只有單一實作的介面,反而讓程式碼更難閱讀。

  • 當你真的感受到它們所要解決的痛點時再套用:例如任何東西一變就壞的類別、需要真實資料庫才能跑的測試,或是複製到多處的 switch 敘述。
  • 不要預先替每個類別都加上介面。等第二個實作或測試替身出現時,再把介面抽出來。
  • 在小型腳本和原型中,簡單直接的程式碼通常比較好。
  • 許多設計模式其實是 SOLID 的具體應用:策略與裝飾者支持開放封閉,轉接器與外觀有助於隔離細節,抽象工廠則支持依賴反轉。

常見問題

SOLID 原則是誰提出的?

Robert C. Martin 整理並描述了這五項原則,其基礎是 Bertrand Meyer(開放封閉)與 Barbara Liskov(替換)的早期研究。SOLID 這個縮寫則是 Michael Feathers 取的。

SOLID 適用於函數式程式設計嗎?

這些觀念是可以轉換的:函式只做一件事、用組合行為取代修改行為,並以參數傳入依賴。以類別為基礎的說法則專屬於物件導向程式碼。

依賴反轉和依賴注入有什麼差別?

依賴反轉是一項原則,主張高層程式碼應該依賴抽象。依賴注入則是從外部提供這些依賴的技巧,例如透過建構函式傳入。

哪一項 SOLID 原則最重要?

並沒有官方的排名。單一職責與依賴反轉通常最快見效,因為它們能讓程式碼更容易理解,也更容易測試。

違反 SOLID 原則是不好的嗎?

不一定。每項原則都要付出額外程式碼的代價。當較簡單的設計更清楚,而它所防範的變動又不太可能發生時,可以刻意不遵守。

全部編碼教學