本章核心#

前幾章透過引入 class 將功能與資料拉近,已大幅改善架構。本章聚焦於封裝 (encapsulation)——限制對資料與功能的存取範圍,使不變量 (invariant) 只能在局部被破壞,進而更容易預防 bug。核心規則是 DO NOT USE GETTERS OR SETTERSNEVER HAVE COMMON AFFIXES,搭配 ELIMINATE GETTER OR SETTERENCAPSULATE DATA 兩個重構模式。


Rule: DO NOT USE GETTERS OR SETTERS#

  • 不使用非布林欄位的 getter 或 setter(包含 C# 的 property)
  • 定義上,getter / setter 是直接回傳或直接賦值某個欄位的方法,與方法名稱無關

Getter 如何破壞封裝#

  • 回傳物件後,接收方可以任意轉傳,我們無法控制誰拿到了物件的參照
  • 任何拿到參照的人都能呼叫其公開方法,可能以非預期的方式修改狀態
  • Setter 理論上提供間接層以便切換內部資料結構,但實務上人們只是同步修改 getter 的回傳型別,造成與呼叫端的緊耦合

Pull-based vs. Push-based 架構#

Pull-basedPush-based
做法透過 getter 取得資料,在中央點運算把資料當參數傳入,運算靠近資料
結果一堆「笨」data class + 少數巨大 manager class每個 class 都有功能,程式碼依用途分布
耦合data class 與 manager 之間緊耦合各 class 透過方法簽名鬆耦合

Push-based 架構中,我們不「取得」資料,而是「傳入」資料。方法像服務一樣被暴露,呼叫者不需要知道內部結構。

此規則源自 Law of Demeter(「不要跟陌生人說話」)。Getter 正是取得「陌生人」參照的最常見途徑。


Refactoring Pattern: ELIMINATE GETTER OR SETTER#

步驟#

  1. 將 getter / setter 改為 private,讓編譯器在所有使用處報錯
  2. PUSH CODE INTO CLASSES 修復錯誤——把使用 getter 的邏輯推入擁有該資料的 class
  3. 此時 getter / setter 已在 PUSH CODE INTO CLASSES 過程中被內聯,成為未使用的方法——刪除它

在遊戲程式碼中的應用#

KeyConfigurationgetColor()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#

  • 若多個方法或變數共享相同的前綴或後綴(如 playerxplayerydrawPlayer),它們應被放入同一個 class
  • 共同詞綴暗示著共同職責 (shared responsibility),class 是表達這種結構的更好方式

好處#

  • 可以隱藏輔助方法,避免汙染全域作用域(在 FIVE LINES 規則下會產生大量小方法)
  • 限制資料存取範圍,使不變量成為局部不變量,更容易維護
  • 某些方法需要特定前置條件才能安全呼叫(如 updateMap 必須在 transformMap 之後),class 讓我們能透過 private 隱藏這些方法

此規則衍生自單一職責原則 (Single Responsibility Principle)——class 應只有一個變更的理由。共同詞綴正是辨識「子職責」的訊號。


Refactoring Pattern: ENCAPSULATE DATA#

步驟#

  1. 建立新 class
  2. 把相關變數移入 class,let 改為 private,簡化名稱;同時建立暫時的 getter / setter
  3. 利用編譯器找出所有原本存取這些變數的地方,逐步修復:
    • 選定新 class 的實例名稱(如 player
    • 存取改用 getter、賦值改用 setter
    • 若多個方法都有錯誤,將新 class 的實例加為第一個參數並逐層傳遞
    • 重複到只剩一處有錯
    • 在原變數宣告的位置實例化新 class
  4. 把帶有相同詞綴的方法也推入新 class(用 PUSH CODE INTO CLASSES)
  5. 用 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#

playerxplayerydrawPlayer 共享 player 前綴:

  • 建立 Player class,將 playerxplayery 移入並改名為 xy
  • 建立暫時的 getX()getY()setX()setY()
  • 修復所有編譯錯誤,將 player 參數向上傳遞
  • drawPlayer 推入 Player 成為 draw(g)
  • 消除 getter / setter——最終 getXgetYsetXsetY 全部消失,取而代之的是 moveHorizontal(dx)move(dx, dy)pushHorizontal(tile, dx)moveToTile(newx, newy) 等方法

在遊戲程式碼中的應用:Map class#

maptransformMapupdateMapdrawMap 共享 map 詞綴:

  • 建立 Map class,將 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 必然在任何方法之前被呼叫,因此序列不變量被消除,而非僅僅被維護。

步驟#

  1. 用 ENCAPSULATE DATA 封裝最後該執行的方法
  2. 讓 constructor 呼叫應先執行的方法
  3. 若兩個方法的參數有關聯,將參數改為欄位
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) 技巧:

  1. 建立 class,以 static readonly 欄位模擬 enum 各值
  2. 建構子設為 private,確保外部無法建立新實例
  3. 現在 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 類型更快更安全、程式碼更易推理、資料作用域更精細使非局部不變量更難被破壞。