上一章:DRY 原則與共用程式庫

硬編碼問題不只是魔法數字。當專案成長到數十個模組時,三種不同形態的硬編碼會同時出現:看不懂的數字、混在邏輯裡的配置資料、散落各處的使用者訊息。本章整合 Error Pattern IMP-002(魔法數字)和 ARCH-001(配置與邏輯混合)的實戰經驗,並加入 W23 訊息集中化的完整案例。


三種硬編碼問題

在維護 19 個 Hook 模組的過程中,我們遇到了三種不同但相關的硬編碼問題:

類型Error Pattern典型症狀危害
魔法數字IMP-002line[9:]sleep(3)range(5)無法理解數字含義,修改時容易遺漏
配置混合ARCH-001800 行檔案中 400 行是配置資料配置散落各處,同一資料有多個版本
散落訊息W23 發現57+ 個硬編碼中文字串散落在 19 個檔案中訊息不一致,無法統一維護

三種問題的共同根因:開發時為求快速,把應該集中管理的資料直接寫在邏輯程式碼裡。


一、消除魔法數字 (IMP-002)

魔法數字是程式碼中無法理解含義的字面值:

1def parse_worktree_line(line: str) -> str:
2    if line.startswith("worktree "):
3        return line[9:]  # 為什麼是 9?
4    return line
5
6if len(branch) > 50:    # 為什麼是 50?
7    raise Error("分支名稱過長")
8
9time.sleep(3)           # 為什麼等 3 秒?

問題不只是可讀性。當前綴改成 "work tree " 時,line[9:] 不會自動更新,產生隱蔽的 bug。

三種消除方法

方法 1:len() 動態計算(最安全)

1WORKTREE_PREFIX = "worktree "
2
3def parse_worktree_line(line: str) -> str:
4    if line.startswith(WORKTREE_PREFIX):
5        return line[len(WORKTREE_PREFIX):]
6    return line

前綴改變時切片自動正確,不需要同步更新數字。

方法 2:removeprefix(最簡潔,Python 3.9+)

1WORKTREE_PREFIX = "worktree "
2
3def parse_worktree_line(line: str) -> str:
4    return line.removeprefix(WORKTREE_PREFIX)

不需要先檢查 startswith,沒有前綴時安全返回原字串。

方法 3:IntEnum 管理相關常數群組

 1from enum import IntEnum
 2
 3class Limits(IntEnum):
 4    MAX_BRANCH_LENGTH = 50
 5    MAX_COMMIT_MSG_LENGTH = 72
 6    MAX_RETRIES = 3
 7    TIMEOUT_SECONDS = 30
 8
 9if len(branch) > Limits.MAX_BRANCH_LENGTH:
10    raise ValueError("分支名稱過長")

常見處理對照

場景
字串切片line[7:]line.removeprefix(PREFIX)
時間限制sleep(3)sleep(RETRY_DELAY_SECONDS)
大小限制len(x) > 50len(x) > MAX_BRANCH_LENGTH
重試次數range(5)range(MAX_RETRIES)

可接受的例外

不是所有數字都需要命名:

1if count == 0:               # 可接受:0 在布林邏輯中
2if text.find("key") == -1:   # 可接受:-1 作為找不到的標記
3half = total / 2              # 可接受:明顯的數學常數

判斷標準:如果閱讀者需要思考「這個數字為什麼是這個值」,就應該命名。


二、YAML 配置分離 (ARCH-001)

問題識別

單一 Hook 檔案超過 800 行,其中約一半是硬編碼的配置資料:

 1# user_prompt_submit.py (847 行,配置佔 400+)
 2PROTECTED_BRANCHES = ["main", "master", "develop"]
 3ALLOWED_PATTERNS = ["feat/*", "fix/*", "chore/*"]
 4ERROR_MESSAGES = {
 5    "branch_not_allowed": "分支名稱不符合規範",
 6    "missing_ticket": "缺少 Ticket 引用",
 7    # ... 數百行配置
 8}
 9
10def main():
11    # 實際邏輯只有 200 行
12    pass

更嚴重的是,同一份配置在多個檔案中各自定義,彼此不一致:

1# file1.py
2PROTECTED_BRANCHES = ["main", "master"]
3# file2.py
4PROTECTED_BRANCHES = ["main", "master", "develop"]  # 多了 develop!

判斷標準

問題若答「是」放置位置
會隨環境改變?YAML 配置檔
非工程師可能修改?YAML 配置檔
是業務規則?程式碼常數檔(附註解)
與程式邏輯緊密耦合?程式碼內常數

簡單記憶:資料放配置,邏輯留程式碼。

實作:config_loader 模式

步驟 1:抽離配置到 YAML

 1# config/branch_rules.yaml
 2protected_branches:
 3  - main
 4  - master
 5  - develop
 6
 7allowed_patterns:
 8  - "feat/*"
 9  - "fix/*"
10  - "chore/*"
11
12error_messages:
13  branch_not_allowed: "分支名稱不符合規範"
14  missing_ticket: "缺少 Ticket 引用"

步驟 2:建立載入器(含快取)

 1# lib/config_loader.py
 2from pathlib import Path
 3from typing import Any, Dict
 4import yaml
 5
 6_config_cache: Dict[str, Any] = {}
 7
 8def load_config(filename: str) -> Dict[str, Any]:
 9    """載入 YAML 配置檔案(含快取)。"""
10    if filename in _config_cache:
11        return _config_cache[filename]
12
13    config_path = Path(__file__).parent.parent / "config" / filename
14    if not config_path.exists():
15        raise FileNotFoundError(f"配置檔案不存在: {config_path}")
16
17    with open(config_path, "r", encoding="utf-8") as f:
18        config = yaml.safe_load(f)
19
20    _config_cache[filename] = config
21    return config

步驟 3:在 Hook 中使用

1from lib.config_loader import load_config
2
3def check_branch():
4    config = load_config("branch_rules.yaml")
5    if current_branch in config["protected_branches"]:
6        print(f"錯誤: {config['error_messages']['branch_not_allowed']}")
7        return False
8    return True

重構後結構:847 行的單一檔案拆成約 200 行純邏輯 + config/ 目錄的 YAML 檔 + 共用的 config_loader.py

常見錯誤

過度配置化 – 把程式邏輯也放進配置檔:

1# 錯誤:這是邏輯,不是資料
2process_steps:
3  - name: "validate"
4    function: "validate_input"

缺乏預設值 – 沒有處理配置缺失:

1timeout = config["timeout"]        # KeyError!
2timeout = config.get("timeout", 30)  # 正確

三、訊息集中化 (W23)

消除魔法數字和分離配置後,還有一種硬編碼藏在邏輯裡:使用者訊息字串。

W23 審計發現 19 個 Hook 中散落了 57+ 個硬編碼中文字串:

1# hook_a.py
2print("錯誤:未找到待處理的 Ticket")
3print("建議執行 /ticket create 建立新 Ticket")
4
5# hook_b.py
6print("錯誤:未找到待處理的 Ticket")  # 同一訊息,略有不同
7print("請先建立 Ticket 再執行")

同一個錯誤概念有 2-3 種不同措辭,修改一則訊息需要搜尋所有檔案。

Messages 類別模式

解決方案:建立 hook_messages.py,用類別分組管理所有訊息常數。

 1# lib/hook_messages.py
 2class CoreMessages:
 3    """Hook 執行通用訊息 - 所有 Hook 共用"""
 4    HOOK_START = "{hook_name} 啟動"
 5    INPUT_EMPTY = "輸入為空,預設允許"
 6    JSON_PARSE_ERROR = "JSON 解析錯誤,預設允許: {error}"
 7
 8class GateMessages:
 9    """Gate Hook 阻擋訊息 - 5 個 gate hooks 使用"""
10    TICKET_NOT_FOUND_ERROR = """錯誤:未找到待處理的 Ticket
11建議: 執行 /ticket create 建立新 Ticket"""
12
13    TICKET_NOT_CLAIMED_ERROR = """錯誤:Ticket {ticket_id} 尚未認領
14建議: 執行 /ticket track claim {ticket_id} 認領"""
15
16class WorkflowMessages:
17    """工作流指導訊息 - 5 個工作流 hooks 使用"""
18    PRE_FIX_EVAL_REQUIRED = """[強制] 修復前評估
19  1. 執行 /pre-fix-eval
20  2. 派發 incident-responder 分析"""

最終產出 7 個 Messages 類別,管理約 45 個訊息常數。

使用方式

Hook 中引用常數,使用 .format() 填入動態值:

1from lib.hook_messages import GateMessages
2
3def validate_ticket(ticket_id: str):
4    if not is_claimed(ticket_id):
5        print(GateMessages.TICKET_NOT_CLAIMED_ERROR.format(
6            ticket_id=ticket_id
7        ))
8        return False
9    return True

組織原則

分類依據類別名稱涵蓋範圍
核心通用CoreMessages所有 Hook 共用的啟動、錯誤訊息
阻擋訊息GateMessages5 個 Gate Hook 的阻止原因和建議
工作流指導WorkflowMessages5 個工作流 Hook 的流程提示
品質檢查QualityMessages5 個品質 Hook 的檢查結果
驗證相關ValidationMessages驗證 Hook 的成功/失敗訊息

分類原則:按使用者角色和觸發情境分組,而不是按技術功能。

命名規範

常數類型命名規則範例
訊息常數大寫蛇形TICKET_NOT_FOUND_ERROR
Messages 類別PascalCase + MessagesGateMessages
格式化佔位符{variable_name}"Ticket {ticket_id} 尚未認領"

W23 實際數據

指標重構前重構後
硬編碼訊息位置散落 19 個檔案集中 1 個檔案
訊息總數57+ 個(含重複)45 個(去重後)
修改訊息需搜尋所有 Hook 檔案只需 hook_messages.py
訊息一致性同概念 2-3 種措辭每個概念一個定義

決策框架

遇到硬編碼時,用這張表判斷該怎麼處理:

硬編碼類型識別特徵處理方式存放位置
魔法數字裸露的數字或字串切片具名常數、len()removeprefix()同檔案頂部或常數模組
配置資料清單、規則表、業務參數抽離到 YAML 配置檔config/ 目錄
使用者訊息字串直接嵌入邏輯提取到 Messages 類別lib/*_messages.py
程式邏輯常數與邏輯緊密耦合的值具名常數,保留在程式碼檔案頂部

決策流程

 1發現硬編碼
 2    |
 3    v
 4會隨環境改變? ─是→ YAML 配置檔
 5    |
 6 7    v
 8是使用者看到的文字? ─是→ Messages 類別
 9    |
1011    v
12是無法理解的數字? ─是→ 具名常數 / len() / removeprefix()
13    |
1415    v
16保留原樣(程式邏輯的一部分)

完整重構範例

重構前

 1def validate_branch(branch):
 2    if len(branch) > 50:
 3        return False
 4    if branch.startswith("refs/heads/"):
 5        branch = branch[11:]
 6    for i in range(3):
 7        if check_remote(branch):
 8            return True
 9        time.sleep(2)
10    return False

重構後

 1MAX_BRANCH_LENGTH = 50
 2REFS_HEADS_PREFIX = "refs/heads/"
 3MAX_RETRIES = 3
 4RETRY_DELAY_SECONDS = 2
 5
 6def validate_branch(branch: str) -> bool:
 7    """驗證分支名稱。"""
 8    if len(branch) > MAX_BRANCH_LENGTH:
 9        return False
10    branch = branch.removeprefix(REFS_HEADS_PREFIX)
11    for attempt in range(MAX_RETRIES):
12        if check_remote(branch):
13            return True
14        time.sleep(RETRY_DELAY_SECONDS)
15    return False

四個魔法數字全部消除,每個值的含義一目了然。


檢測方法

1# 找出數字切片(潛在魔法數字)
2grep -rn "\[[0-9]*:\]" hooks/*.py
3
4# 找出 sleep 和 range 中的硬編碼
5grep -rn "sleep([0-9]" hooks/*.py
6grep -rn "range([0-9]" hooks/*.py
7
8# 找出硬編碼中文字串(潛在散落訊息)
9grep -rn '[一-龥]' hooks/*.py

實作練習

找出以下程式碼中的三種硬編碼問題,並提出修正方案:

 1def process_hook_result(result_line):
 2    if result_line.startswith("status: "):
 3        status = result_line[8:]
 4    else:
 5        status = "unknown"
 6
 7    if len(status) > 100:
 8        print("狀態文字過長,已截斷")
 9        status = status[:97] + "..."
10
11    VALID_STATUSES = ["pass", "fail", "skip", "error"]
12    if status not in VALID_STATUSES:
13        print("無效的狀態值: " + status)
14        return None
15    return status
參考答案

三種硬編碼問題:

  1. 魔法數字result_line[8:]10097
  2. 配置資料VALID_STATUSES 清單應該可配置
  3. 散落訊息"狀態文字過長,已截斷""無效的狀態值: "
 1from lib.config_loader import load_config
 2
 3STATUS_PREFIX = "status: "
 4MAX_STATUS_LENGTH = 100
 5ELLIPSIS = "..."
 6
 7class HookResultMessages:
 8    STATUS_TRUNCATED = "狀態文字過長,已截斷"
 9    INVALID_STATUS = "無效的狀態值: {status}"
10
11def process_hook_result(result_line: str) -> str | None:
12    status = result_line.removeprefix(STATUS_PREFIX)
13    if status == result_line:
14        status = "unknown"
15
16    if len(status) > MAX_STATUS_LENGTH:
17        print(HookResultMessages.STATUS_TRUNCATED)
18        truncate_at = MAX_STATUS_LENGTH - len(ELLIPSIS)
19        status = status[:truncate_at] + ELLIPSIS
20
21    config = load_config("hook_rules.yaml")
22    if status not in config["valid_statuses"]:
23        print(HookResultMessages.INVALID_STATUS.format(status=status))
24        return None
25    return status

小結

  • 硬編碼問題有三種形態:魔法數字、配置混合、散落訊息
  • 魔法數字用 len()removeprefix()IntEnum 消除
  • 配置資料用 YAML 檔案集中管理,透過 config_loader 載入
  • 使用者訊息用 Messages 類別集中化,按角色和情境分組
  • 決策關鍵:會隨環境改變 → 配置檔;是使用者文字 → Messages;是裸露數字 → 常數

下一章:大規模統一化重構


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