上一章:程式碼壞味道偵測

DRY (Don’t Repeat Yourself) 是軟體開發的核心原則之一。本章基於 Error Pattern IMP-001,學習如何識別重複程式碼並建立共用模組。後半部分以 v0.31.0 的模組演進和遷移實戰為例,示範共用庫如何隨系統成長持續演進。

問題背景

症狀

相同功能在多個檔案中重複實作:

1# hooks/pre_commit.py
2def run_git_command(cmd):
3    result = subprocess.run(cmd, capture_output=True, text=True)
4    return result.stdout.strip()
5
6# hooks/post_merge.py  -- 完全相同
7# hooks/branch_check.py  -- 完全相同
8# hooks/worktree_guardian.py  -- 完全相同

四個檔案中存在完全相同的函式定義。

5 Why 分析

  1. Why 1: 相同的 run_git_command 函式在 4 個檔案中重複
  2. Why 2: 每個 Hook 獨立開發,沒有共用模組
  3. Why 3: 缺乏 Hook 系統的架構設計和共用程式庫規劃
  4. Why 4: 快速開發時複製貼上最快
  5. Why 5: 缺乏 DRY 原則的強制檢查機制

DRY 原則核心

重複程式碼的四大壞處:修改需改多處容易不一致增加維護成本測試困難

DRY 的完整含義不只是「不要複製貼上」:

Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.

– Andy Hunt & Dave Thomas, The Pragmatic Programmer

這意味著不只是程式碼,還包括業務邏輯、資料定義、設定內容。

識別重複程式碼

1# 找出重複的函式定義
2grep -rh "^def " .claude/hooks/*.py | sort | uniq -c | sort -rn | head -20
3
4# 範例輸出:
5#    4 def run_git_command(cmd):
6#    3 def get_current_branch():
7#    2 def parse_worktree_line(line):
重複類型範例處理方式
完全相同複製貼上的程式碼抽取到共用模組
結構相同相似但參數不同抽取並參數化
概念相同做同樣的事但實作不同統一介面

建立共用程式庫

模組結構

1.claude/lib/
2├── __init__.py           # 公開介面
3├── git_utils.py          # Git 操作
4├── config_loader.py      # 配置載入
5├── hook_io.py            # 輸入輸出
6└── hook_logging.py       # 日誌系統

抽取共用函式

從重複程式碼中抽取,加上完整的型別標註和 docstring:

 1# lib/git_utils.py
 2"""Git 操作工具模組。"""
 3
 4import subprocess
 5from pathlib import Path
 6from typing import List, Optional
 7
 8def run_git_command(
 9    cmd: List[str],
10    cwd: Optional[Path] = None,
11    check: bool = False
12) -> str:
13    """執行 Git 命令並回傳輸出。
14
15    Args:
16        cmd: Git 命令列表,例如 ["git", "status"]
17        cwd: 工作目錄,預設為當前目錄
18        check: 是否在命令失敗時拋出異常
19    """
20    result = subprocess.run(
21        cmd, capture_output=True, text=True, cwd=cwd, check=check
22    )
23    return result.stdout.strip()
24
25def get_current_branch(cwd: Optional[Path] = None) -> str:
26    """取得當前分支名稱。"""
27    return run_git_command(["git", "branch", "--show-current"], cwd=cwd)

更新使用處

1# hooks/pre_commit.py(重構後)
2from lib.git_utils import run_git_command, get_current_branch
3
4def check_branch():
5    current_branch = get_current_branch()
6    # 使用共用函式,不再重複定義

抽取技巧

處理微小差異

當重複程式碼有微小差異時,使用參數化:

 1# 重構前:三個檔案各自的版本
 2# hooks/file_a.py
 3def parse_worktree_line(line):
 4    return line[9:]                        # 不 strip
 5
 6# hooks/file_b.py
 7def parse_worktree_line(line):
 8    return line[9:].strip()                # 有 strip
 9
10# hooks/file_c.py
11def parse_worktree_line(line):
12    return line.removeprefix("worktree ")  # 用 Python 3.9+ API
13
14# 重構後:統一實作,支援選項
15WORKTREE_PREFIX = "worktree "
16
17def parse_worktree_line(line: str, strip: bool = True) -> str:
18    """解析 worktree 輸出行。"""
19    result = line.removeprefix(WORKTREE_PREFIX)
20    return result.strip() if strip else result

使用高階函式

當邏輯結構相同但操作不同時:

 1from pathlib import Path
 2from typing import Callable
 3
 4# 重構前
 5def check_all_python_files():
 6    for file in Path(".").glob("**/*.py"):
 7        if validate_python(file): print(f"OK: {file}")
 8
 9def check_all_yaml_files():
10    for file in Path(".").glob("**/*.yaml"):
11        if validate_yaml(file): print(f"OK: {file}")
12
13# 重構後
14def check_files(pattern: str, validator: Callable[[Path], bool]) -> None:
15    for file in Path(".").glob(pattern):
16        if validator(file): print(f"OK: {file}")
17
18check_files("**/*.py", validate_python)
19check_files("**/*.yaml", validate_yaml)

共用模組設計原則

原則做法反面教材
單一職責git_utils.py(Git 操作)、config_loader.py(配置載入)。模組名稱即可看出職責utils.py(什麼都放,職責不明確)
穩定的介面透過 __init__.py 定義公開 API,內部可自由重構讓使用者直接 import 內部實作細節
完整的 docstring每個公開函式都要有 docstring(Args/Returns/Raises)只有程式碼,沒有使用說明
充分的測試每個共用函式都要有對應的單元測試重構後不跑測試就上線

模組演進:從 4 個到 7+ 個

共用程式庫隨著系統成長持續演進。

模組演進表

版本模組職責說明
v0.28.0git_utils.pyGit 命令執行、分支管理消除 4 處 run_git_command 重複
v0.28.0hook_io.pyHook JSON 輸入讀取、輸出生成統一 stdin/stdout 處理
v0.28.0config_loader.pyYAML 配置檔案載入支援 PyYAML fallback JSON
v0.28.0hook_logging.py日誌設定統一日誌格式
v0.31.0hook_utils.py統一日誌 + 頂層例外處理取代分散的兩套日誌系統
v0.31.0hook_messages.py訊息常數集中管理消除 19 個 Hook 的硬編碼訊息
v0.31.0hook_validator.pyHook 健康檢查驗證 import 和執行狀態

演進的驅動力

每次新增模組都有明確的驅動力,而非預先設計:

v0.28.0(初建期):四個函式重複 → 建立四個共用模組。

v0.31.0(成熟期):Hook 數量從 7 個成長到 40+ 個,新的重複模式浮現:

  1. 日誌系統分裂hook_logging.pycommon_functions.setup_hook_logging 兩套實作並存,40+ 個 Hook 各自選用。最終建立 hook_utils.py 統一取代
  2. 訊息散落各處:19 個 Hook 各自硬編碼使用者訊息 → 建立 hook_messages.py 集中管理

這驗證了「至少重複兩次再抽取」的 Rule of Three 原則:模組是在真實需求驅動下自然長出來的。

漸進遷移策略

共用庫建立後,需要將現有使用者逐步遷移。「一次全改」風險太高,以下是 W22 遷移 40+ 個 Hook 到新日誌系統的實戰策略。

分批遷移計畫

批次範圍檔案數策略
W22-001.2主力遷移14 個按 Hook 事件類型分組遷移
W22-001.3補漏3 個掃描殘留的舊 import

每個 Hook 的遷移步驟

 1# === 步驟 1:替換 import ===
 2# 遷移前
 3from lib.common_functions import setup_hook_logging
 4# 遷移後
 5from hook_utils import setup_hook_logging
 6
 7# === 步驟 2:包裹主函式 ===
 8# 遷移前
 9if __name__ == "__main__":
10    try:
11        main()
12    except Exception as e:
13        logger.error(f"執行失敗: {e}")
14        sys.exit(1)
15# 遷移後
16from hook_utils import run_hook_safely
17if __name__ == "__main__":
18    sys.exit(run_hook_safely(main, "my-hook"))
19
20# === 步驟 3:驗證 ===
21uv run python hook-name.py < /dev/null

為什麼分批而非一次全改

一次全改分批遷移
改動 40+ 個檔案,review 困難每批 14-3 個,可仔細確認
一個錯誤影響所有 Hook錯誤影響範圍有限
無法中途暫停每批獨立可交付
回滾等於全部回滾只回滾出問題的批次

遷移陷阱:IMP-005

模組遷移最常見的陷阱是 import 路徑未同步更新。這個問題在系統中發生過兩次,我們將其記錄為 Error Pattern IMP-005。

症狀

模組從目錄 A 移到目錄 B 後,部分使用者的 import 忘記更新:

1# 遷移前(同目錄)
2from common_functions import hook_output  # OK
3
4# 遷移後(模組移到 lib/,但 import 未更新)
5from common_functions import hook_output  # ModuleNotFoundError!
6
7# 正確的遷移後 import
8from lib.common_functions import hook_output  # OK

為什麼容易遺漏

  1. py_compile 不偵測 import 問題:只檢查語法,不解析模組路徑
  2. 部分 Hook 不常觸發:SessionStart Hook 只在啟動時執行,測試不容易覆蓋
  3. 多源錯誤疊加:多個 Hook 同時報錯,修完幾個就以為全部修好

遷移前強制檢查清單

1# 1. 列出所有引用舊路徑的檔案
2grep -r "from common_functions import" .claude/hooks/*.py
3
4# 2. 逐一更新每個引用者的 import 路徑
5
6# 3. 逐一驗證(不能只跑其中幾個!)
7for f in .claude/hooks/*.py; do
8    uv run python "$f" < /dev/null 2>&1 | grep -q "Error" && echo "FAIL: $f"
9done

Import 防護機制

在 Hook 入口加 try-except,讓 import 失敗時顯示具體原因:

1try:
2    from hook_utils import setup_hook_logging
3except ImportError as e:
4    print(f"[Hook Import Error] {Path(__file__).name}: {e}", file=sys.stderr)
5    sys.exit(1)

實際案例統計

v0.28.0 初建共用庫:

函式重複次數重構後
run_git_command41 (git_utils.py)
get_current_branch31 (git_utils.py)
parse_worktree_line21 (git_utils.py)
load_json21 (hook_io.py)

總計消除數百行重複程式碼。

v0.31.0 持續演進:

項目重複次數重構後
setup_hook_logging2 套系統1 (hook_utils.py)
run_hook_safely40+ 處 try-except1 (hook_utils.py)
使用者訊息字串19 個 Hook 散落1 (hook_messages.py)

常見錯誤

錯誤 1:過早抽象

只用一次就抽出去是過度抽象。原則:至少重複兩次再抽取(Rule of Three)。

錯誤 2:強行統一

不同概念硬塞進同一個函式(靠 mode 參數切換)。解決:不同概念應該是不同的函式。

錯誤 3:忽略測試

重構時沒有先寫測試,導致引入新 bug。原則:先寫測試,確保重構不改變行為。

錯誤 4:遷移不徹底

模組搬家後只更新「自己知道的」使用處。原則:用 grep 列出所有引用,逐一更新並驗證(詳見 IMP-005)。

實作練習

練習 1:識別重複

找出以下程式碼的可抽取重複:

 1# file1.py
 2def process_user_data(user):
 3    if not user.get("name"):
 4        return {"error": "缺少姓名"}
 5    if not user.get("email"):
 6        return {"error": "缺少信箱"}
 7    return {"success": True, "data": user}
 8
 9# file2.py
10def process_order_data(order):
11    if not order.get("product"):
12        return {"error": "缺少商品"}
13    if not order.get("quantity"):
14        return {"error": "缺少數量"}
15    return {"success": True, "data": order}
參考答案
 1def validate_required_fields(data: dict, required_fields: list) -> dict:
 2    """驗證必填欄位。"""
 3    for field in required_fields:
 4        if not data.get(field):
 5            return {"error": f"缺少{field}"}
 6    return {"success": True, "data": data}
 7
 8def process_user_data(user: dict) -> dict:
 9    return validate_required_fields(user, ["name", "email"])
10
11def process_order_data(order: dict) -> dict:
12    return validate_required_fields(order, ["product", "quantity"])

練習 2:規劃遷移策略

20 個 Hook 要從 from common_functions import setup_logging 遷移到 from hook_utils import setup_hook_logging,請規劃遷移策略。

參考答案
 1# 1. 盤點
 2grep -rl "from common_functions import" .claude/hooks/*.py | wc -l
 3
 4# 2. 分批(按事件類型)
 5# 第一批:SessionStart hooks(啟動就能看到)
 6# 第二批:UserPromptSubmit hooks
 7# 第三批:PreToolUse / PostToolUse hooks
 8
 9# 3. 逐批執行,每批完成後 commit
10
11# 4. 全量掃描(不可省略!防止 IMP-005)
12grep -r "from common_functions import" .claude/hooks/*.py
13# 預期輸出:空

小結

  • DRY 原則要求每個知識只有單一權威來源,用 grep 識別重複的函式定義
  • 不要過早抽象,至少重複兩次再抽取(Rule of Three)
  • 建立結構清晰的共用程式庫,重構前先寫測試確保行為不變
  • 共用庫隨系統成長持續演進,大規模遷移採用分批策略
  • 模組搬家後必須全量 grep 引用並逐一驗證,防止 IMP-005 陷阱

下一章:配置分離與常數管理


文件版本:v0.31.1 建立日期:2026-03-04