本章核心#
前幾章透過引入 class 將功能與資料拉近,已大幅改善架構。本章聚焦於封裝 (encapsulation)——限制對資料與功能的存取範圍,使不變量 (invariant) 只能在局部被破壞,進而更容易預防 bug。核心規則是 DO NOT USE GETTERS OR SETTERS 與 NEVER HAVE COMMON AFFIXES,搭配 ELIMINATE GETTER OR SETTER 與 ENCAPSULATE DATA 兩個重構模式。
Rule: DO NOT USE GETTERS OR SETTERS#
- 不使用非布林欄位的 getter 或 setter(包含 C# 的 property)
- 定義上,getter / setter 是直接回傳或直接賦值某個欄位的方法,與方法名稱無關
Getter 如何破壞封裝#
- 回傳物件後,接收方可以任意轉傳,我們無法控制誰拿到了物件的參照
- 任何拿到參照的人都能呼叫其公開方法,可能以非預期的方式修改狀態
- Setter 理論上提供間接層以便切換內部資料結構,但實務上人們只是同步修改 getter 的回傳型別,造成與呼叫端的緊耦合
Pull-based vs. Push-based 架構#
| Pull-based | Push-based | |
|---|---|---|
| 做法 | 透過 getter 取得資料,在中央點運算 | 把資料當參數傳入,運算靠近資料 |
| 結果 | 一堆「笨」data class + 少數巨大 manager class | 每個 class 都有功能,程式碼依用途分布 |
| 耦合 | data class 與 manager 之間緊耦合 | 各 class 透過方法簽名鬆耦合 |
Push-based 架構中,我們不「取得」資料,而是「傳入」資料。方法像服務一樣被暴露,呼叫者不需要知道內部結構。
此規則源自 Law of Demeter(「不要跟陌生人說話」)。Getter 正是取得「陌生人」參照的最常見途徑。
Refactoring Pattern: ELIMINATE GETTER OR SETTER#
步驟#
- 將 getter / setter 改為
private,讓編譯器在所有使用處報錯 - 用 PUSH CODE INTO CLASSES 修復錯誤——把使用 getter 的邏輯推入擁有該資料的 class
- 此時 getter / setter 已在 PUSH CODE INTO CLASSES 過程中被內聯,成為未使用的方法——刪除它
在遊戲程式碼中的應用#
KeyConfiguration 有 getColor() 和 getRemoveStrategy() 兩個 getter:
getRemoveStrategy()→ 推入KeyConfiguration成為removeLock()方法getColor()→ 推入KeyConfiguration成為setColor(g)方法(此處setColor不是我們定義中的 setter,因為它不直接賦值欄位)
FallStrategy.getFalling() 也以同樣方式消除——邏輯推入 FallStrategy 成為 moveHorizontal(tile, dx) 方法。
每消除一個 getter,通常會在擁有資料的 class 中產生多個以呼叫情境命名的方法(而非以資料命名)。例如
TrafficLight上的drive()可重新命名為notifyGreenLight()。
Rule: NEVER HAVE COMMON AFFIXES#
- 若多個方法或變數共享相同的前綴或後綴(如
playerx、playery、drawPlayer),它們應被放入同一個 class - 共同詞綴暗示著共同職責 (shared responsibility),class 是表達這種結構的更好方式
好處#
- 可以隱藏輔助方法,避免汙染全域作用域(在 FIVE LINES 規則下會產生大量小方法)
- 限制資料存取範圍,使不變量成為局部不變量,更容易維護
- 某些方法需要特定前置條件才能安全呼叫(如
updateMap必須在transformMap之後),class 讓我們能透過private隱藏這些方法
此規則衍生自單一職責原則 (Single Responsibility Principle)——class 應只有一個變更的理由。共同詞綴正是辨識「子職責」的訊號。
Refactoring Pattern: ENCAPSULATE DATA#
步驟#
- 建立新 class
- 把相關變數移入 class,
let改為private,簡化名稱;同時建立暫時的 getter / setter - 利用編譯器找出所有原本存取這些變數的地方,逐步修復:
- 選定新 class 的實例名稱(如
player) - 存取改用 getter、賦值改用 setter
- 若多個方法都有錯誤,將新 class 的實例加為第一個參數並逐層傳遞
- 重複到只剩一處有錯
- 在原變數宣告的位置實例化新 class
- 選定新 class 的實例名稱(如
- 把帶有相同詞綴的方法也推入新 class(用 PUSH CODE INTO CLASSES)
- 用 ELIMINATE GETTER OR SETTER 消除剛才建立的暫時 getter / setter
flowchart TD
A["建立新 class"] --> B["移入相關變數\nlet → private\n建立暫時 getter/setter"]
B --> C["用編譯器找出所有存取處"]
C --> D{"還有編譯錯誤?"}
D -->|是| E["存取改用 getter\n賦值改用 setter\n逐層傳遞實例"]
E --> D
D -->|否| F["PUSH CODE INTO CLASSES\n推入相關方法"]
F --> G["ELIMINATE GETTER OR SETTER\n消除暫時 getter/setter"]在遊戲程式碼中的應用:Player class#
playerx、playery、drawPlayer 共享 player 前綴:
- 建立
Playerclass,將playerx、playery移入並改名為x、y - 建立暫時的
getX()、getY()、setX()、setY() - 修復所有編譯錯誤,將
player參數向上傳遞 - 把
drawPlayer推入Player成為draw(g) - 消除 getter / setter——最終
getX、getY、setX、setY全部消失,取而代之的是moveHorizontal(dx)、move(dx, dy)、pushHorizontal(tile, dx)、moveToTile(newx, newy)等方法
在遊戲程式碼中的應用:Map class#
map、transformMap、updateMap、drawMap 共享 map 詞綴:
- 建立
Mapclass,將map陣列移入 - 三個方法推入後分別簡化為
transform()、update()、draw(g) - 消除
getMap()getter——邏輯推入Map,產生drop()、getBlockOnTopState()、isAir()、movePlayer()、moveHorizontal()、moveVertical()、remove()等方法 - 暫時引入的
setTile()最終只在Map內部使用,可改為private或直接刪除
消除序列不變量:Enforce Sequence#
map 的初始化需呼叫 map.transform(),必須在其他方法之前執行——這是一個序列不變量 (sequence invariant)。
解法#
把 transform() 改為 constructor。物件的 constructor 必然在任何方法之前被呼叫,因此序列不變量被消除,而非僅僅被維護。
步驟#
- 用 ENCAPSULATE DATA 封裝最後該執行的方法
- 讓 constructor 呼叫應先執行的方法
- 若兩個方法的參數有關聯,將參數改為欄位
sequenceDiagram
participant C as 呼叫端
participant M as Map
Note over C,M: 重構前:序列不變量
C->>M: transform()
C->>M: update()
C->>M: draw(g)
Note right of C: 呼叫端必須記住順序
Note over C,M: 重構後:constructor 保證
C->>M: new Map(rawMap)
Note right of M: constructor 自動<br/>呼叫 transform()
C->>M: update()
C->>M: draw(g)物件實例本身就是「constructor 已被呼叫」的證明 (proof)——我們無法在不執行 constructor 的情況下取得實例。這個技巧可以擴展到多步驟的序列,每一步引入一個 class。
用 Private Constructor 消除 enum#
在某些語言中 enum 不能有方法(如 TypeScript 的 enum 本質上是數字)。此時可用私有建構子 (private constructor) 技巧:
- 建立 class,以
static readonly欄位模擬 enum 各值 - 建構子設為
private,確保外部無法建立新實例 - 現在 class 可以有方法,可以用 PUSH CODE INTO CLASSES
為了進一步消除 class 內部的 if-else,對每個值建立獨立的 class(搭配 interface),再把行為推入各 class。
數字對應到 class#
若原始資料以數字(enum index)儲存,可建立一個陣列,按 enum 順序排列新 class 的實例,用陣列索引完成對應:
const RAW_TILES = [
RawTile.AIR, RawTile.FLUX, RawTile.UNBREAKABLE, ...
];
// 使用:RAW_TILES[rawMap[y][x]].transform()
最終,原本的 switch 消失,assertExhausted 不再需要,transformTile 被內聯。
本章小結#
- DO NOT USE GETTERS OR SETTERS:不透過 getter / setter 間接暴露私有欄位。用 ELIMINATE GETTER OR SETTER 把邏輯推入擁有資料的 class,促進 push-based 架構
- NEVER HAVE COMMON AFFIXES:共同前後綴暗示共同職責,用 ENCAPSULATE DATA 將相關變數與方法收入 class,使不變量局部化
- Enforce Sequence:利用 constructor 消除序列不變量,讓編譯器保證執行順序
- Private Constructor 技巧可在 enum 不支援方法的語言中模擬 enum class,進一步消除 switch
本章結束了 Part 1 的遊戲重構之旅。重構並未結束——inputs、handleInputs 仍可繼續封裝,player 與 map 也可進一步封裝為 Game class——但 Part 1 已為程式碼帶來三項顯著改善:擴展新 Tile 類型更快更安全、程式碼更易推理、資料作用域更精細使非局部不變量更難被破壞。