物件導向程式設計基本原則 - SOLID
SOLID — OOP 五大原則
TL;DRSOLID 是 OOP 設計的 5 條心法,讓程式碼好維護、易擴充。
- SRP: 一個類別只做一件事
- OCP: 開放擴充,封閉修改
- LSP: 子類能無痛取代父類
- ISP: 介面細分,別塞太多
- DIP: 依賴抽象,不依賴實作
範例靈感借用 Skyyen999 的設計模式書 的「阿文跟車子」比喻,加上 TypeScript 程式碼範例。
為什麼要 SOLID
軟體開發過程中,程式設計師最常抱怨的話:
「什麼,又要改,是要改幾次啊!?」
不論事前設計多周到,需求變更、需求理解錯誤、bug 修改總會逼你不斷調整 code。SOLID 是前人累積的經驗心法,讓你下次改 code 時,工作順利一點、痛苦少一點。
S: Single Responsibility Principle(單一職責)
一個類別只負責一件事。
反例:阿文的超級汽車
阿文 18 歲生日,爸爸送一台 又能飛、又能上路、又能潛水 的車。聽起來很酷?
問題:
- 阿文要 機師駕照 + 汽車駕照 + 潛艇證照 才能開
- 故障時要修飛機 + 修汽車 + 修潛艇 三種技師 同時來
這就是把太多職責塞給一個類別 — 使用、維護都痛苦。
// ❌ 違反 SRP
class SuperCar {
drive() { /* 路上跑 */ }
fly() { /* 天空飛 */ }
dive() { /* 水下游 */ }
repairEngine() { /* 修引擎 */ }
refuel() { /* 加油 */ }
charge() { /* 充電 */ }
getCustomerSupport() { /* 客服 */ }
// ...
}
正例:職責拆開
// ✅ 一個類別管一件事
class Car {
drive() { /* 路上跑 */ }
}
class Plane {
fly() { /* 天空飛 */ }
}
class Submarine {
dive() { /* 水下游 */ }
}
class CarMaintenance {
repair(car: Car) { /* ... */ }
}
不要過度細分(over-design)SRP ≠ 一個類別只能有一個方法。 一台車的「行駛」是 前進 / 後退 / 左轉 / 右轉 / 剎車 組合而成,這些都該在 Car 內。 關鍵是「對使用者來說是一致的責任」,不是看方法數量。
O: Open/Closed Principle(開放/封閉原則)
對擴充開放、對修改封閉。這是 SOLID 最重要的一條。
比喻:換輪胎不該動引擎
阿文要 把大燈改亮一點,技師只換燈泡 → 不能也不需要動引擎。 要 上山賞雪,直接 綁雪鏈 → 不必整個輪胎換掉。
反例:每次新增功能都改舊 code
// ❌ 加新付款方式 = 改舊 if-else
class Checkout {
pay(method: string, amount: number) {
if (method === 'credit_card') {
// Stripe 邏輯
} else if (method === 'paypal') {
// PayPal 邏輯
} else if (method === 'linepay') {
// 新增就要改這裡 → 容易破壞其他分支
}
}
}
正例:新增功能 = 新加一個類別
// ✅ 抽象介面 + 多個實作
interface PaymentMethod {
process(amount: number): Promise<Result>;
}
class StripePayment implements PaymentMethod { /* ... */ }
class PayPalPayment implements PaymentMethod { /* ... */ }
class LinePayPayment implements PaymentMethod { /* ... */ } // ⭐ 新增不動舊 code
class Checkout {
constructor(private method: PaymentMethod) {}
pay(amount: number) { return this.method.process(amount); }
}
新增功能只是 add new file,不用改 Checkout class。零風險動到既有功能。
L: Liskov Substitution Principle(Liskov 替換原則)
子類別應該能無痛取代父類別,使用者完全察覺不到差異。
反例:阿文的樂高車
阿文要開車去外婆家,從車庫挑了樂高車。結果發現是模型,不能跑 — 違反父類 Car 的「能上路」假設。
// ❌ 違反 LSP
class Car {
drive() { /* 路上跑 */ }
}
class LegoCar extends Car {
drive() {
throw new Error('我是模型啦,不能跑!'); // ⭐ 子類不能滿足父類契約
}
}
// 使用者拿到 Car 物件,理應能呼叫 drive()
function goHome(car: Car) {
car.drive(); // 拿到 LegoCar 就炸
}
正例:不該繼承就不繼承
// ✅ LegoCar 不是 Car,應該分開
class Car {
drive() { /* ... */ }
}
class LegoModel { // ⭐ 純粹的展示模型,不繼承 Car
display() { /* ... */ }
}
LSP 判斷準則「if it walks like a duck, swims like a duck, quacks like a duck — then it's a duck」 子類應該滿足父類「所有可被觀察的行為契約」。 不能滿足 → 不該用繼承,改用 組合(composition)。
I: Interface Segregation Principle(介面隔離)
介面要細,不要塞太多無關功能。使用者不該被迫依賴他不用的方法。
反例:車子介面太胖
阿文家車庫有 一般車 + 樂高車。Car 介面定義了「路上跑」,但樂高車根本不能跑 → 違反 LSP。
根本問題:Car 介面把太多東西混在一起。
正例:把介面拆細
// ❌ 一個大介面
interface Car {
display(): void; // 展示用
drive(): void; // 行駛
refuel(): void; // 加油
changeOil(): void; // 換機油
}
// ✅ 拆成多個小介面
interface Displayable {
display(): void;
}
interface Drivable {
drive(): void;
}
interface Maintainable {
refuel(): void;
changeOil(): void;
}
// 真車實作多個
class Sedan implements Displayable, Drivable, Maintainable { /* ... */ }
// 樂高車只實作展示
class LegoCar implements Displayable { /* ... */ } // ⭐ 不用假裝能跑
每個 client 只依賴自己需要的介面,bundle 也更乾淨。
D: Dependency Inversion Principle(依賴反轉)
高階模組不依賴低階模組,兩者都依賴抽象。抽象不依賴細節,細節依賴抽象。
比喻:邀請函上不要寫死車型
❌ 邀請函:「歡迎來欣賞 Ferrari Fx2020 超跑」
→ 當天爸爸開走了 → 你超糗
✅ 邀請函:「歡迎來欣賞超級跑車」
→ 當天即使換車也 OK,反正都是「超跑」這個抽象
反例:直接依賴 concrete class
// ❌ Checkout 直接依賴 Stripe
import { Stripe } from 'stripe';
class Checkout {
private stripe = new Stripe(process.env.STRIPE_KEY!);
pay(amount: number) {
return this.stripe.charges.create({ amount });
}
}
問題:
- 想換 PayPal? 整個 Checkout 都要改
- 寫測試? 還要 mock 整個 Stripe
- 多個付款方式? if-else 大爆炸
正例:依賴介面(抽象)
// ✅ 高階模組(Checkout)依賴抽象
interface PaymentGateway {
charge(amount: number): Promise<Result>;
}
class Checkout {
constructor(private payment: PaymentGateway) {} // ⭐ 注入抽象
pay(amount: number) {
return this.payment.charge(amount);
}
}
// 細節層(實作)
class StripeGateway implements PaymentGateway {
charge(amount: number) { /* Stripe API */ }
}
class PayPalGateway implements PaymentGateway {
charge(amount: number) { /* PayPal API */ }
}
// 使用時注入
const checkout = new Checkout(new StripeGateway());
依賴方向反轉了:
- 之前
Checkout → Stripe(高階依賴低階) - 之後
Checkout → PaymentGateway ← Stripe(都依賴抽象)
SOLID 在 React 的應用
很多人覺得「SOLID 是後端 / Java 的事」,其實 React 也適用:
SRP — 元件職責單一
// ❌ 一個 component 做太多
function UserDashboard() {
// ... 200 行: 抓資料 + 表單 + 圖表 + 通知
}
// ✅ 拆成多個小 component
function UserDashboard() {
return (
<>
<UserProfile />
<UserActivityChart />
<UserNotifications />
</>
);
}
OCP — 用 props 擴充行為
// ✅ 擴充新樣式不用改 Button
<Button variant="primary">...</Button>
<Button variant="danger">...</Button>
<Button variant="ghost">...</Button> // 新增 variant 只加 CSS
DIP — Hook 抽象資料層
// ❌ component 直接呼叫 fetch
function PostList() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/api/posts').then((r) => r.json()).then(setPosts);
}, []);
}
// ✅ component 依賴 hook 抽象
function PostList() {
const { data: posts } = usePosts(); // ⭐ 不知道資料從哪來
}
// hook 實作可換 fetch / TanStack Query / GraphQL / mock
function usePosts() {
return useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
}
這就是 DIP — Component 依賴 hook 介面,不依賴 fetch 細節。
SOLID 的爭議與限制
不要 cargo cultSOLID 是指導原則,不是律法。死板套用會 過度抽象,讓簡單問題變複雜。例如:你寫一個 hello world script,還搞 5 層介面 + DI container,完全沒必要。
判斷準則:這段 code 預期會變嗎? 會 → 用 SOLID 防止以後痛。不會 → 直接寫,以後要改再 refactor。
總結
| 原則 | 一句話 | 關鍵字 |
|---|---|---|
| SRP | 一個類別一個職責 | 拆 |
| OCP | 擴充開放、修改封閉 | 插件式 |
| LSP | 子類能完美取代父類 | 契約 |
| ISP | 介面要小,不要強迫依賴 | 細分 |
| DIP | 依賴抽象不依賴實作 | 倒過來 |
學會 SOLID 之後接下來進階:設計模式(GoF) + Clean Architecture / DDD + 領域語言(Ubiquitous Language)。SOLID 是這些東西的內功心法。