介面隔離原則 ISP 深度指南 SOLID 實踐 應用介面拆分技巧降低耦合與提高內聚優化擴展性

me
林彥成
2023-10-05 | 7 min.
文章目錄
  1. 1. 什麼是介面隔離原則 (ISP)?
  2. 2. 介面隔離原則 (ISP):縮小介面以減少依賴
    1. 2.1. 為什麼我們需要介面隔離?
    2. 2.2. 介面定義:分離基本與進階角色邏輯
    3. 2.3. 通用處理函數
  3. 3. ISP vs SRP:職責與介面的微妙平衡
  4. 4. 通用函數設計:基於介面的處理與重構
  5. 5. 為什麼 ISP 對大型專案至關重要?
  6. 6. FAQ:介面隔離原則常見問題
    1. 6.1. Q1:如果介面拆得太細,會不會導致類別爆炸?
    2. 6.2. Q2:ISP 只能應用在有 Interface 關鍵字的語言(如 Java/TS)嗎?
    3. 6.3. Q3:ISP 如何協助前端元件開發?

什麼是介面隔離原則 (ISP)?

介面隔離原則 (Interface Segregation Principle, ISP) 是物件導向設計 SOLID 原則中的第四項,其核心理念是:客戶端不應該被迫依賴於它們不使用的方法。在實務開發中,這意謂著我們應該將龐大、臃腫的介面拆分為多個更小、更具體且專注於特定職責的小介面。這樣做的優點在於能顯著降低模組間的「非必要耦合」,當小介面發生變動時,只有真正使用到該介面的模組才需要重新編譯或修改。ISP 能確保系統具備高品質的內聚力,使程式碼更易於理解、測試與擴充,是構建強韌軟體架構的重要基石。


在之前的文章中有提出了當 重複的東西一再出現的問題,當時並沒有特別的去談一些理論和解決方法,但在後面幾天小編開始慢慢的置入 SOLID 原則 中的:

今天想來開箱另外一個設計程式時的 介面隔離原則 (Interface Segregation Principle, ISP),談談透過介面的隔離來確保高內聚性 (Cohesion) 和低耦合性 (Coupling),使程式碼易於理解、擴充和維護。這份 ISP 教學 將幫助您優化系統的 軟體架構設計

介面隔離原則 (ISP):縮小介面以減少依賴

讓我們複習一下之前提到的情境描述。假設我們正在開發一個角色扮演遊戲 (RPG) 的程式,其中有不同類型的玩家,包括基本玩家和進階玩家。每個玩家類型都有自己的檢查條件,程式碼重構 的目標是確保系統易於擴充。

為什麼我們需要介面隔離?

在沒有應用 ISP 之前,我們可能會試圖建立一個「萬能玩家介面」,強迫所有角色實作所有的屬性與方法。例如,如果一個「平民」角色被強迫實作 attack() 方法,即使他根本不會攻擊,這就違反了介面隔離原則。當一個類別被迫依賴它不使用的方法時,系統就會變得脆弱且難以維護。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 初始版本只有基本角色屬性
const player1 = {
name: "玩家1",
level: 1,
health: 100,
damage: 10,
};

// 一段時間後,遊戲需要更多的角色屬性
const player2 = {
name: "玩家2",
level: 5,
health: 150,
damage: 15,
agility: 20, // 新需求:敏捷度
inventory: ["劍", "盾"], // 新需求:背包
};

// 合起來
function processPlayerData(
name,
level,
health,
damage,
agility,
inventory,
statusCode
) {
if (
health > 10 &&
(name === "foo" || damage < 5) &&
(name !== "bar" || agility > 20)
) {
// ...
}
// 非常長的函數內容...
if (statusCode === 20100) {
// ...
}

if (statusCode === 20101) {
// ...
}
}

介面定義:分離基本與進階角色邏輯

首先定義了 Player 的介面,包含了所有玩家類型都需要具備的通用屬性。透過 介面隔離,我們可以確保每個類別都只依賴於它實際需要的方法。

但由於角色的不同,改善的方向應該是把角色分開,這樣每個角色都只會用到自己需要的屬性和方法。

  • 基本玩家: 透過 createPlayer 函數實現了 Player 介面,確保了基本玩家包含了通用的屬性遵循了 Player 介面。
  • 進階玩家: 透過 createAdvancedPlayer 函數擴充 Player 介面,確保了進階玩家不僅遵循了 Player 介面也包含了進階玩家特有的屬性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 定義一個函數,用於建立玩家物件
function createPlayer(playerData) {
return {
...playerData,
};
}

// 建立玩家物件
const player1 = createPlayer({
name: "玩家1",
level: 1,
health: 100,
damage: 10,
});

// 新需求:更多的角色屬性
function createAdvancedPlayer(advancedPlayerData) {
return {
...createPlayer(advancedPlayerData),
};
}

// 建立進階玩家物件
const player2 = createAdvancedPlayer({
name: "玩家2",
level: 5,
health: 150,
damage: 15,
agility: 20,
inventory: ["劍", "盾"],
});

// 重新設計函數,只接受角色物件作為參數
function processPlayerData(player) {
const { name, health, damage, agility } = player;
if (
health > 10 &&
(name === "foo" || damage < 5) &&
(name !== "bar" || agility > 20)
) {
// ...
}
// 非常長的函數內容...
if (statusCode === 20100) {
// ...
}

if (statusCode === 20101) {
// ...
}
}

通用處理函數

處理通用處理函數的部分,這裡建立了 processPlayerData 函數,可以接受任何類型的玩家物件作為參數,目標是不直接依賴於特定的玩家類型讓函數更具通用性,方便未來新增更多的角色。

判斷的部份選擇把不同功能的判斷用 function 拆開,將這些條件拆分成獨立的函數可以提高程式碼的可讀性和維護性,這樣做讓每個檢查條件都有自己的名稱,更容易理解和測試。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 定義一個函數,用於檢查玩家的健康狀態
function isHealthy(player) {
return player.health > 10;
}

// 定義一個函數,用於檢查玩家的名稱和傷害值
function hasDesiredNameOrLowDamage(player) {
return player.name === "foo" || player.damage < 5;
}

// 定義一個函數,用於檢查玩家的名稱和敏捷度
function hasDesiredNameOrHighAgility(player) {
return player.name !== "bar" || player.agility > 20;
}

// 重新設計函數,只接受角色物件作為參數
function processPlayerData(player) {
if (
isHealthy(player) &&
hasDesiredNameOrLowDamage(player) &&
hasDesiredNameOrHighAgility(player)
) {
// ...
// 這裡執行根據條件的操作
}
// 非常長的函數內容...
if (statusCode === 20100) {
// ...
}

if (statusCode === 20101) {
// ...
}
}

重構完之後發現,只有 AdvancedPlayer 才需要有 hasDesiredNameOrHighAgility,所以再把腳色拆分進行重構,讓每個玩家類型都分別具有自己的檢查邏輯,基本玩家和進階玩家分別知道如何檢查自己的條件,這就是介面隔離。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 定義一個函數,用於檢查玩家的健康狀態
function isHealthy(player) {
return player.health > 10;
}

// 定義一個函數,用於檢查玩家的名稱和傷害值
function hasDesiredNameOrLowDamage(player) {
return player.name === "foo" || player.damage < 5;
}

// 定義一個函數,用於檢查玩家的名稱和敏捷度
function hasDesiredNameOrHighAgility(player) {
return player.name !== "bar" || player.agility > 20;
}

// 定義一個基本玩家函數,接受包含玩家屬性的物件作為參數
function createPlayer(playerData) {
const { name, health, damage } = playerData;
return {
name,
health,
damage,

// 通用的檢查函數,每個玩家都可以使用
checkPlayerCondition() {
return isHealthy({ health, name, damage }) && hasDesiredNameOrLowDamage({ name, damage });
},
};
}

// 定義進階玩家函數,接受包含進階玩家屬性的物件作為參數
function createAdvancedPlayer(advancedPlayerData) {
const { name, health, damage, agility } = advancedPlayerData;
const player = createPlayer({ name, health, damage });
return {
...player,
agility,

// 進階玩家特有的檢查函數
checkAdvancedPlayerCondition() {
return hasDesiredNameOrHighAgility({ name: player.name, agility });
},
};
}

ISP vs SRP:職責與介面的微妙平衡

很多人會混淆 單一職責原則 (SRP)介面隔離原則 (ISP)。雖然兩者都強調「精簡」,但關注點不同:

  • SRP 關注的是類別的「內部實現」:一個類別應該只有一個引起它變化的原因。
  • ISP 關注的是類別的「外部暴露」:客戶端不應該被迫依賴於它們不使用的方法。

在我們的 RPG 案例中,即使一個 AdvancedPlayer 類別符合 SRP,如果我們強迫一個只需要讀取 name 的顯示元件去依賴整個包含 checkAdvancedPlayerCondition 的大介面,那就是違反了 ISP。這就是為什麼進行 軟體架構設計 時,必須精確拆分介面。

通用函數設計:基於介面的處理與重構

這裡建立了 processPlayerData 函數,目標是不直接依賴於特定的玩家類型。這種 軟體架構設計 讓函數更具通用性,方便未來新增更多的角色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 通用的處理玩家資料函數,接受任何類型的玩家物件作為參數
function processPlayerData(player) {
if (player.checkPlayerCondition()) {
// 做一些基本玩家的操作
console.log(`${player.name} 符合基本條件`);
}

if (
player?.checkAdvancedPlayerCondition &&
player?.checkAdvancedPlayerCondition()
) {
// 做一些進階玩家的操作
console.log(`${player.name} 符合進階條件`);
}

// 非常長的函數內容...
if (statusCode === 20100) {
// ...
}

if (statusCode === 20101) {
// ...
}
}

// 建立玩家物件,使用包含屬性的物件作為參數
const player1 = createPlayer({
name: "玩家1",
health: 100,
damage: 10,
});
const player2 = createAdvancedPlayer({
name: "玩家2",
health: 150,
damage: 15,
agility: 20,
});

// 處理玩家資料
processPlayerData(player1);
// 輸出:玩家1 符合基本條件
processPlayerData(player2);
// 輸出:玩家2 符合基本條件
// 輸出:玩家2 符合進階條件

為什麼 ISP 對大型專案至關重要?

在大型專案中,介面隔離原則 (ISP) 能顯著減少「編譯依賴性」。當你修改一個龐大介面中的其中一個方法時,所有依賴該介面的模組可能都需要重新編譯或重新部署。透過將大介面拆分為多個小介面,你可以:

  1. 降低耦合性:模組之間的連動減少,修改程式碼的風險降低。
  2. 提高程式碼重用性:小介面更容易被不同的類別實作。
  3. 增強可測試性:在撰寫單元測試時,Mock 物件會變得更加簡單,因為你只需要實作測試所需的少量方法。

processPlayerData 函數最後不需要知道你是哪種特定的玩家類型,只關心玩家是否符合介面定義。這就是 介面隔離原則 (ISP) 的具體應用,有助於確保程式碼的結構清晰,並簡化依賴關係,同時支持未來的靈活擴充。不論是在簡單的腳本還是複雜的 SOLID 原則 實作中,ISP 都是不可或缺的基石。


FAQ:介面隔離原則常見問題

Q1:如果介面拆得太細,會不會導致類別爆炸?

A:這是一個權衡。過度拆分(介面碎片化)確實會增加系統的認知負擔。建議的標準是:觀察是否有客戶端因為介面變動而頻繁進行「不必要」的重新測試或部署。如果有,那就該拆;如果各方法高度相關且幾乎總是被同時使用,則應保留在同一個介面中。

Q2:ISP 只能應用在有 Interface 關鍵字的語言(如 Java/TS)嗎?

A:並非如此。 在 JavaScript 這類動態語言中,ISP 體現於「參數的預期」。例如:一個函數如果接收一個龐大的 options 物件,但只讀取其中的一個屬性,這在廣義上也是違反 ISP 的。改為只傳入所需的屬性,就是一種介面隔離的實踐。

Q3:ISP 如何協助前端元件開發?

A:在 React 中,這體現於 Props 的設計。避免傳遞整個龐大的 Data Object 給子元件,而是只傳遞元件所需的最小屬性。這能避免子元件因為無關資料的變動而產生不必要的重新渲染,提升效能與可測試性。


掌握了 介面隔離原則,您就能為軟體架構注入更高品質的內聚力。持續在重構中實踐「最小依賴」,讓您的程式碼更優雅、更具靈活性!


喜歡這篇文章,請幫忙拍拍手喔 🤣