根據 《Clean Architecture》這本書裡所書寫的定義為 :
一個軟體應該對於擴展是開放的,但對於修改是封閉的。
如果比較白話文的話來說,我覺得是 :
> 這個原則就是叫我們少改 code,而是用擴展或 plugin 的方式。
有工作過的人大部份都知道,開發新東西簡單,而修改舊東西很難。這個原則基本上就是希望我們處理新需求時就是用擴展,而不是修改的方法來改,最常見的範例就是我們下面這段支付的情境。
// 抽象接口 PaymentProcessor
interface PaymentProcessor {
processPayment(amount: number): void;
}
class CreditCardPaymentProcessor implements PaymentProcessor {
processPayment(amount: number): void {
console.log(`Processing credit card payment of ${amount}`);
// 信用卡付款邏輯
}
}
然後這時如果需求來時,需要增加另一個支付,我們就只要新增一個類別就好如下 :
class PayPalPaymentProcessor implements PaymentProcessor {
processPayment(amount: number): void {
console.log(`Processing PayPal payment of ${amount}`);
// PayPal 付款邏輯
}
}
上面這裡我是覺得合理,沒什麼問題,大部份有聽過 OCP 的人事實上也都知道。
然後接下來我們來品品其中幾本書對 OCP 的總結。
《Clean Architecture》: OCP 的目標是使系統易於擴展,而不會因為修改而產生較大的影響。這個目標透過將系統劃分為元件,並且這些元件安排到依賴階層中而實現的,這種階層結構能保護較高層級的元件免受較低層級的元件的變更所影響。
《Clean Architecture》的總結比較偏架構層面,它的概念我覺得比較接近 :
以架構層級來思考,如何不讓一個簡單的擴展需求,炸掉整個世界。
所以我覺得關鍵點是將系統分層,保護核心業務邏輯(高層級元件)不受外部細節(低層級元件)變更的影響。這意味著,我們應該使用抽象來定義核心邏輯,具體的實現細節應放在外層,並透過依賴反轉(Dependency Inversion)來保護高層元件。這個事實上就是我們上一篇說的 DIP 的概念。
《無瑕的程式碼-敏捷完整篇》: 物件導向設計的核心之一就是 OCP,尊循這個原則可以帶來靈活性、再使用性及可維護性。然後,對於每個部份都肆意地進行抽象也不是好主意。正確的做法是,開發人員應該僅僅對於程式碼呈現頻繁變化的那些部份做出抽象。
拒絕不成熟的抽象
和抽象本身一樣重要。
《無瑕的程式碼-敏捷完整篇》的總結我可以理解,因為抽象本身就是達到 OCP 的一種方式,但是書中總結也有提到,不要什麼東西都抽象,應該只對頻繁變化的地方進行抽象。
它的核心概念是 :
- 子型態 (subtype) 必須能夠替換掉它們的基底型態 ( base type )
- IS-A 是關於行為的
這個說的比較白話一點就是 :
子類別必須能夠替換基本類別,而不會影響系統的正確性
// 基類 Product,代表所有產品的通用屬性和行為
class Product {
name: string;
price: number;
constructor(name: string, price: number) {
this.name = name;
this.price = price;
}
purchase(): void {
console.log(`${this.name} purchased for ${this.price}.`);
}
}
// 實體產品,繼承自 Product
class PhysicalProduct extends Product {
shippingWeight: number;
constructor(name: string, price: number, shippingWeight: number) {
super(name, price);
this.shippingWeight = shippingWeight;
}
ship(): void {
console.log(`Shipping ${this.name} with weight ${this.shippingWeight}kg.`);
}
}
// 課程產品,繼承自 Product
class DigitalCourse extends Product {
accessLink: string;
constructor(name: string, price: number, accessLink: string) {
super(name, price);
this.accessLink = accessLink;
}
provideAccess(): void {
console.log(`Providing access to ${this.name} via ${this.accessLink}.`);
}
}
然後我們在外面使用的大概如下 ,作為 product 的子類,它應該可以在任何期望使用 product 的地方進行替換,而不影響功能。
function checkout(product: Product): void {
product.purchase();
}
const book = new PhysicalProduct("Clean Code", 50, 1.2);
const course = new DigitalCourse("TypeScript Masterclass", 100, "http://course-link.com");
checkout(book); // Output: Clean Code purchased for 50.
checkout(course); // Output: TypeScript Masterclass purchased for 100.
IS-A 是關於行為的,所以如果一個繼承了母類別,但行為不一致的,就是違反 LSP
如下範例,我們新增了一個免費的文章產品,但是我們母類別的行為是要可以 purchase 的,所以這個時後就代表這違反 LSP,雖然可能每個都覺得 FreeArticle 也是種產品,但是母類別就定義好,產品一定要可以購買,這個接下來會導致什麼問題呢 ?
class FreeArticleProduct extends Product {
isSubscribed: boolean;
constructor(name: string, price: number, isSubscribed: boolean) {
super(name, price);
}
// 覆寫purchase方法
purchase(): void {
throw new Error(`Cannot purchase ${this.name}, because it is free product`);
}
}
答案就是外面使用時,每個地方都有要多考慮這個類別如果不能 purchase 的情況。
const products: Product[] = [
new Product("Paid Article", 10),
new FreeArticleProduct("Free Article"),
];
products.forEach((product) => {
// 假設所有產品都可以購買
product.purchase();
});
然後這時有人或需會說,那這樣不能就 FreeArticleProduct 中的 purchase 不要 throw 就好了,直接不做任何事。這會導致的問題為隱藏錯誤行為
。
如果 FreeArticleProduct 的 purchase() 方法什麼都不做,那麼外部系統期望商品被成功購買的地方將無法發現該商品實際上並未被購買。這種靜默失敗
會讓系統的行為變得不可預測且難以調試,因為看起來操作已成功,但事實上沒有任何效果 ~ 他事實上就是留坑給後人跳。
在這裡總結一下我在開發時的一些心得 :
IS-A 是關於行為的
這句話就是精華 。IS-A 是關於行為的
事實上也很適合用當成一個高內聚的思考方向。然後順到回憶一下以前寫的繼承的問題, :