跳至主要内容

[GoF] 物件導向程式設計邏輯

此篇為各筆記之整理,非原創內容,資料來源主要為《JavaScript 設計模式與開發實踐》

TL;DR

  • 單一職責原則(SRP):一個方法只做一件事
  • 最少知識原則(LKP):只暴露出必要的介面,盡可能減少軟體實體間的關聯性
  • 開放封閉原則:如果使用擴展的方式就能夠簡單的解決問題,根本沒有必要耗時耗力的改變原本即有的程式。

單一職責原則(Single Responsibility Principle, SRP)

概念

「就一個類別而言,應該僅有一個引起它變化的原因《JavaScript 設計模式與開發實踐》」。

在單一職責原則中的職責,指的就是「引起變化的原因」。因為,簡單來說,單一職責原則指的就是「一個方法只做一件事」

實務考量

「並不是所有的職責都該一一分離,如果有兩個職責總是同時變化,那就不必分離他們」,在單一職責原則中,困難的會是「可是該分離職責」。

另外,違反原則的情況並不少見,有時候我們會為了提供使用者更好的 DX 而違反原則,讓同一個方法帶有更多的功能。重要的不是一層不變的遵循原則,而是要知道「自己為什麼這樣做?以及這樣做是的取捨是什麼?

優缺點

遵守 SRP 的原則有助於測試的撰寫,並降低單一方法、物件的複雜度,並且減少改變一個方法時,需要連動修改很多不同的地方。

雖然我們減少了單一方法或物件的複雜度,但過度或過多的切分卻有可能增加程的維護的複雜度,除了可能會增加 trace 程式的時間成本外,各個方法之間的聯繫和關聯也容易讓開發者迷失其中。

相關的設計模式

  • 代理模式:不直接改變本體,而是在 proxy 中額外增添本體的功能
  • 迭代器模式:不直接在函式內中物件或陣列的操作,而是透過迭代器來對物件或陣列進行操作
  • 單例模式:把多個具有不同職責的單例拼裝起來使用
  • 裝飾者模式:把多個具有不同職責的函式拼裝起來使用

最少知識原則(Least Knowledge Principle, LKP)

又被稱作迪米特法則(Law of Demeter, LoD),指的是「一個『軟體實體』應當盡可能少地與其他實體發生『交互作用』」。這裡的軟體實體,泛指「系統、類別、模組、函式、變數、...等」都是。

實作的方式

只暴露特定的介面/功能給使用者,讓物件之間的聯繫限制在最小範圍。

更簡單來說,不要把所有的變數都定義在全域(global),而是在需要用到的 function scope 中定義各自使用到的變數,廣義來說也符合最少知識原則的概念。

相關的設計模式

  • 中介者模式:所有相關的功能都去找同一個軟體實體負責
  • 外觀模式:在子系統外再包一層介面讓客戶端更容易使用子系統提供的功能,但當客戶端有需求時,依然可以獨立使用子系統中提供的功能。

開發-封閉原則(Open-Closed Principle)

軟體實體(類別、模組、函式)等應該是可以擴展,但是不可修改的JavaScript 設計模式與開發實踐》」。

簡單來說,是在不修改原有程式碼的前提下來新增所需的功能。

舉例來說,當專案中已經很多地方使用某個方法,現在要為這個方法添加一些功能時,如果直接修改此方法原本寫好的邏輯,甚至改變它的介面,將有很高的機會使得原本沒有壞掉的地方壞掉,進而導致改 A 壞 B 的情況,因此,比較好的作法是去擴展它,而不去動到原本已經運作良好的部分。

「當需要改變一個程式的功能或者可以這個程式增加新功能的時候, 可以使用增加程式碼的方式,但是不允許改變程式的原始碼。《JavaScript 設計模式與開發實踐》」

開放-封閉原則的重點在於,如果使用擴展的方式就能夠簡單的解決問題,根本沒有必要耗時耗力的改變原本即有的程式

用物件的多態性消除條件判斷

實際上,單純在原本的程式中使用 ifswitch 仍然時違反開放-封閉原則的,因為本質上它還是修改了原本的程式碼。常見的作法是利用物件的多態性來讓程式遵守開放-封閉原則。

舉例來說,下面的作法並不符合開放-封閉原則,因為每次添加動物時,都需要改動這個程式碼:

// 不符合開放-封閉原則
class Animal {
private kind: string;

constructor(kind: string) {
this.kind = kind;
}

// 每次添加動物時,都需要改動這個程式碼,這樣並不符合開放-封閉原則
sound() {
if (this.kind === 'cat') {
console.log('meow');
} else if (this.kind === 'dog') {
console.log('bark');
}
}
}

const dog = new Animal('dog');
const cat = new Animal('cat');
dog.sound(); // 'bark'
cat.sound(); // 'meow'

比較好的作法會是這樣:

// 符合開放-封閉原則:分離出不易改變的部分和容易改變的部分
interface Animal {
sound(): void;
}

class Cat implements Animal {
sound() {
console.log('meow');
}
}

class Dog implements Animal {
sound() {
console.log('bark');
}
}

const dog = new Dog();
const cat = new Cat();
dog.sound(); // 'bark'
cat.sound(); // 'meow'

找出容易發生改變的地方進行封裝它

要找到開放-封閉原則能夠著力的地方,可以觀察程式中「不太會改變」和「容易發生改變」的部分,然後將這兩個部分拆開來,針對容易改變的部分進行封裝

以上各段落的範例來說,每個動物都會叫,就是屬於不太會改變的部分,但每個動物會怎麼叫,就是屬於容易發生改變的部分,一起可以把它從原本的程式中抽離出來。

處理使用多態性之外,常見的方式還包括:

放置鈎子(hook)

前端框架的生命週期中就包含各種鉤子,讓開發者能夠自行決定在不同的時間點要做什麼,例如,rendercomponentDidMountcomponentDidUpdatecomponentWillUnmount 等。

在一般 CI/CD 的設定檔中,也常包括各種不同鉤子的運用,讓開發者能夠決定在不同的時間點要做那些操作。

回呼函式(callback function)

在 JavaScript 中,經常會使用 callback function 讓開發者決定「當...發生時,要...」,例如 onScuessonError 等參數,經常就是讓開發者能夠帶入 callback function 的地方,同時,在這些 callback function 的參數中,還能取得特定的資料,例如 onSuccess(data) 時可以在 callback function 的參數中取得對應的資料;在 onError(error) callback function 的參數中者可以取得錯誤的原因。

另外像是,``Array.prototype.mapArray.prototype.filter` 等等這些方法中,也都可以透過 callback function 的方式,來針對陣列進行不同的操作。

實務考量

要讓程式完全保持封閉是不容易的,如果一昧的為了維持開發—封閉原則,可能會引入過多的抽象層次,增加程式碼的複雜度而導致維護難度增加。如果前面段落所述,開發者能做的是:

  • 找出容易發生變化的地方,構建抽象來進行封裝
  • 善用最少知識原則,開放容易被修改的部分供使用者修改(例如,設定檔),而不需要深入修改到原始碼

避免過度設計

要能夠在第一時間就找出「容易改變」和「不常改變」的部分,並不是容易的事,因為在第一次撰寫功能時,我們可以先假設「變化永遠不會發生」,加速並減少完成需求所需的時間,避免過度設計(over design)。

當我們未來碰到需要改變,或者產生實際影響時,再來回過頭進行優化。

相關的設計模式

一般來說,幾乎所有設計模式都遵守開發-封閉原則。

  • 裝飾著模式
  • 發佈—訂閱模式:降低多個物件間的依賴關係,新的訂閱者出現時,不需要改變發佈者的程式;發佈者的程式改變時,也不會影響到訂閱者
  • 範本方法模式
  • 策略模式
  • 代理模式
  • 職責鏈模式

Giscus