在入門系列中,我們學習了效能優化的原則和工具。本章將這些知識應用於 .claude/lib 的實際程式碼,展示如何從「發現問題」到「驗證效果」的完整流程。

學習目標

完成本章後,你將能夠:

  1. 使用 cProfile 分析真實程式碼的效能瓶頸
  2. 判斷哪些程式碼值得優化
  3. 應用正則表達式預編譯提升效能
  4. 使用 functools.lru_cache 實現有效的快取策略
  5. 根據查詢模式選擇適當的資料結構

先備知識

本章假設你已經閱讀:

如果你還不熟悉 cProfiletimeit 或「過早優化是萬惡之源」這句話的含義,請先閱讀入門系列。

效能分析流程

優化的正確流程是:

11. 測量基準效能
232. 找出瓶頸(cProfile)
453. 針對瓶頸優化
674. 驗證優化效果
895. 評估維護成本

最重要的原則:先測量,後優化。沒有測量數據的優化是盲目的。

真實案例:Hook 驗證器

我們以 .claude/lib/hook_validator.py 為例。這個工具用來驗證 Hook 腳本是否符合專案規範,核心功能是透過正則表達式檢查程式碼內容。

 1class HookValidator:
 2    """Hook 合規性驗證器"""
 3
 4    # 共用模組導入模式
 5    HOOK_IO_PATTERNS = [
 6        r"from\s+hook_io\s+import",
 7        r"from\s+lib\.hook_io\s+import",
 8    ]
 9
10    HOOK_LOGGING_PATTERNS = [
11        r"from\s+hook_logging\s+import",
12        r"from\s+lib\.hook_logging\s+import",
13    ]
14
15    # ... 更多模式 ...
16
17    def _has_import(self, content: str, patterns: list[str]) -> bool:
18        """檢查是否有符合任一模式的導入"""
19        return any(
20            re.search(pattern, content)
21            for pattern in patterns
22        )

這段程式碼有什麼效能問題?讓我們用 cProfile 來找出答案。

步驟 1:測量基準效能

首先,建立測試環境並測量原始版本的效能:

 1import cProfile
 2import pstats
 3import re
 4from pstats import SortKey
 5
 6def generate_test_content(num_lines: int = 500) -> str:
 7    """生成測試用的 Hook 腳本內容"""
 8    lines = [
 9        '#!/usr/bin/env python3',
10        '"""Test hook script"""',
11        'import os',
12        'import sys',
13        'from hook_io import read_hook_input, write_hook_output',
14        'from hook_logging import setup_hook_logging',
15        '',
16    ]
17
18    # 加入更多程式碼行
19    for i in range(num_lines):
20        if i % 10 == 0:
21            lines.append(f'def function_{i}():')
22            lines.append(f'    """Function {i}"""')
23        lines.append(f'    x_{i} = {i}')
24
25    return '\n'.join(lines)
26
27def benchmark_original(content: str, iterations: int = 100):
28    """測量原始版本的效能"""
29
30    # 原始實作:每次呼叫都重新編譯正則表達式
31    patterns = [
32        r"from\s+hook_io\s+import",
33        r"from\s+lib\.hook_io\s+import",
34    ]
35
36    def has_import_original(content: str, patterns: list[str]) -> bool:
37        return any(
38            re.search(pattern, content)
39            for pattern in patterns
40        )
41
42    # 執行效能分析
43    profiler = cProfile.Profile()
44    profiler.enable()
45
46    for _ in range(iterations):
47        has_import_original(content, patterns)
48
49    profiler.disable()
50
51    stats = pstats.Stats(profiler)
52    stats.sort_stats(SortKey.CUMULATIVE)
53    stats.print_stats(10)
54
55    return stats
56
57# 執行測試
58content = generate_test_content(1000)
59print("=== 原始版本效能 ===")
60benchmark_original(content)

執行後的輸出類似:

1=== 原始版本效能 ===
2         501 function calls in 0.0234 seconds
3
4   Ordered by: cumulative time
5
6   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
7      100    0.001    0.000    0.023    0.000 test.py:30(has_import_original)
8      200    0.022    0.000    0.022    0.000 {method 'search' of 're.Pattern'}
9      100    0.000    0.000    0.000    0.000 {built-in method builtins.any}

觀察結果:re.search 佔用了大部分時間。

步驟 2:找出瓶頸

從 cProfile 結果可以看到,re.search 被呼叫了 200 次(100 次迭代 x 2 個 pattern)。

問題在於:re.search(pattern, content) 每次呼叫時都會重新編譯正則表達式

雖然 Python 的 re 模組有內部快取(最近使用的 pattern 會被快取),但:

  1. 快取有大小限制(預設 512 個)
  2. 查詢快取本身也有開銷
  3. 在 Hook 驗證器中有多達 20+ 個不同的 pattern

正則表達式預編譯

問題:重複編譯的開銷

來看 hook_validator.py 中的實際程式碼:

 1class HookValidator:
 2    """Hook 合規性驗證器"""
 3
 4    # 模式定義為字串列表
 5    HOOK_IO_PATTERNS = [
 6        r"from\s+hook_io\s+import",
 7        r"from\s+lib\.hook_io\s+import",
 8    ]
 9
10    HOOK_LOGGING_PATTERNS = [
11        r"from\s+hook_logging\s+import",
12        r"from\s+lib\.hook_logging\s+import",
13    ]
14
15    CONFIG_LOADER_PATTERNS = [
16        r"from\s+config_loader\s+import",
17        r"from\s+lib\.config_loader\s+import",
18    ]
19
20    GIT_UTILS_PATTERNS = [
21        r"from\s+git_utils\s+import",
22        r"from\s+lib\.git_utils\s+import",
23    ]
24
25    # 輸出函式使用模式
26    OUTPUT_PATTERNS = [
27        r"write_hook_output\s*\(",
28        r"create_pretooluse_output\s*\(",
29        r"create_posttooluse_output\s*\(",
30    ]
31
32    # 不推薦的輸出模式
33    BAD_OUTPUT_PATTERNS = [
34        r'print\s*\(\s*json\.dumps\s*\(',
35        r'sys\.stdout\.write\s*\(\s*json\.dumps\s*\(',
36    ]
37
38    def _has_import(self, content: str, patterns: list[str]) -> bool:
39        """檢查是否有符合任一模式的導入"""
40        return any(
41            re.search(pattern, content)  # 每次都要編譯!
42            for pattern in patterns
43        )

每次呼叫 _has_import 時,所有 pattern 都會被重新處理。

解決方案:預編譯

將字串 pattern 改為預編譯的 re.Pattern 物件:

 1import re
 2from typing import Pattern
 3
 4class HookValidatorOptimized:
 5    """優化版 Hook 驗證器"""
 6
 7    # 預編譯的正則表達式模式
 8    HOOK_IO_PATTERNS: list[Pattern] = [
 9        re.compile(r"from\s+hook_io\s+import"),
10        re.compile(r"from\s+lib\.hook_io\s+import"),
11    ]
12
13    HOOK_LOGGING_PATTERNS: list[Pattern] = [
14        re.compile(r"from\s+hook_logging\s+import"),
15        re.compile(r"from\s+lib\.hook_logging\s+import"),
16    ]
17
18    CONFIG_LOADER_PATTERNS: list[Pattern] = [
19        re.compile(r"from\s+config_loader\s+import"),
20        re.compile(r"from\s+lib\.config_loader\s+import"),
21    ]
22
23    GIT_UTILS_PATTERNS: list[Pattern] = [
24        re.compile(r"from\s+git_utils\s+import"),
25        re.compile(r"from\s+lib\.git_utils\s+import"),
26    ]
27
28    OUTPUT_PATTERNS: list[Pattern] = [
29        re.compile(r"write_hook_output\s*\("),
30        re.compile(r"create_pretooluse_output\s*\("),
31        re.compile(r"create_posttooluse_output\s*\("),
32    ]
33
34    BAD_OUTPUT_PATTERNS: list[Pattern] = [
35        re.compile(r'print\s*\(\s*json\.dumps\s*\('),
36        re.compile(r'sys\.stdout\.write\s*\(\s*json\.dumps\s*\('),
37    ]
38
39    def _has_import(self, content: str, patterns: list[Pattern]) -> bool:
40        """檢查是否有符合任一模式的導入"""
41        return any(
42            pattern.search(content)  # 直接使用預編譯的 pattern
43            for pattern in patterns
44        )

效能測量

比較預編譯和非預編譯的效能差異:

 1import re
 2import time
 3
 4def compare_regex_performance():
 5    """比較預編譯 vs 非預編譯的效能"""
 6
 7    content = generate_test_content(1000)
 8    iterations = 1000
 9
10    # 字串 pattern
11    str_patterns = [
12        r"from\s+hook_io\s+import",
13        r"from\s+lib\.hook_io\s+import",
14    ]
15
16    # 預編譯 pattern
17    compiled_patterns = [re.compile(p) for p in str_patterns]
18
19    # 測試 1:使用字串 pattern
20    start = time.perf_counter()
21    for _ in range(iterations):
22        any(re.search(p, content) for p in str_patterns)
23    str_time = time.perf_counter() - start
24
25    # 測試 2:使用預編譯 pattern
26    start = time.perf_counter()
27    for _ in range(iterations):
28        any(p.search(content) for p in compiled_patterns)
29    compiled_time = time.perf_counter() - start
30
31    print(f"字串 pattern:    {str_time:.4f}s")
32    print(f"預編譯 pattern:  {compiled_time:.4f}s")
33    print(f"加速比:          {str_time / compiled_time:.2f}x")
34
35compare_regex_performance()

典型輸出:

1字串 pattern:    0.1234s
2預編譯 pattern:  0.0987s
3加速比:          1.25x

在這個例子中,預編譯帶來約 20-30% 的效能提升。雖然不是「快 10 倍」的驚人結果,但:

  1. 改動成本極低:只需要加上 re.compile()
  2. 無風險:行為完全相同
  3. 累積效果:當有更多 pattern 時,效果更明顯

快取策略:lru_cache

適用場景

functools.lru_cache 適合用於:

  1. 純函式:相同輸入總是產生相同輸出
  2. 計算昂貴:函式執行需要較長時間
  3. 重複呼叫:同樣的參數會被多次呼叫

實作範例:分支保護檢查

來看 .claude/lib/git_utils.py 中的 is_protected_branch() 函式:

 1import fnmatch
 2
 3# 保護分支列表(支援 glob 模式)
 4PROTECTED_BRANCHES = [
 5    "main",
 6    "master",
 7    "develop",
 8    "release/*",
 9    "production",
10]
11
12def is_protected_branch(branch: str) -> bool:
13    """
14    檢查是否為保護分支
15
16    Args:
17        branch: 分支名稱
18
19    Returns:
20        bool: 如果是保護分支返回 True
21    """
22    for pattern in PROTECTED_BRANCHES:
23        if fnmatch.fnmatch(branch, pattern):
24            return True
25    return False

這個函式:

  • 是純函式:相同的 branch 總是返回相同結果
  • 會被重複呼叫:在 Hook 執行期間可能檢查同一個分支多次

加入 lru_cache

 1from functools import lru_cache
 2import fnmatch
 3
 4PROTECTED_BRANCHES = [
 5    "main",
 6    "master",
 7    "develop",
 8    "release/*",
 9    "production",
10]
11
12@lru_cache(maxsize=128)
13def is_protected_branch(branch: str) -> bool:
14    """
15    檢查是否為保護分支(帶快取)
16
17    Args:
18        branch: 分支名稱
19
20    Returns:
21        bool: 如果是保護分支返回 True
22    """
23    for pattern in PROTECTED_BRANCHES:
24        if fnmatch.fnmatch(branch, pattern):
25            return True
26    return False

快取命中率分析

 1from functools import lru_cache
 2import fnmatch
 3
 4@lru_cache(maxsize=128)
 5def is_protected_branch(branch: str) -> bool:
 6    for pattern in PROTECTED_BRANCHES:
 7        if fnmatch.fnmatch(branch, pattern):
 8            return True
 9    return False
10
11def analyze_cache_performance():
12    """分析快取命中率"""
13
14    # 模擬真實的呼叫模式
15    branches = [
16        "main", "main", "main",  # 重複檢查 main
17        "feat/new-feature",
18        "main",
19        "fix/bug-123",
20        "main",
21        "release/1.0",
22        "feat/new-feature",  # 重複
23    ]
24
25    # 清除快取統計
26    is_protected_branch.cache_clear()
27
28    for branch in branches:
29        result = is_protected_branch(branch)
30        print(f"檢查 {branch}: {result}")
31
32    # 查看快取統計
33    info = is_protected_branch.cache_info()
34    print(f"\n快取統計:")
35    print(f"  命中: {info.hits}")
36    print(f"  未命中: {info.misses}")
37    print(f"  命中率: {info.hits / (info.hits + info.misses) * 100:.1f}%")
38    print(f"  快取大小: {info.currsize}/{info.maxsize}")
39
40analyze_cache_performance()

輸出:

 1檢查 main: True
 2檢查 main: True
 3檢查 main: True
 4檢查 feat/new-feature: False
 5檢查 main: True
 6檢查 fix/bug-123: False
 7檢查 main: True
 8檢查 release/1.0: True
 9檢查 feat/new-feature: False
10
11快取統計:
12  命中: 5
13  未命中: 4
14  命中率: 55.6%
15  快取大小: 4/128

lru_cache 的注意事項

 1# 1. 參數必須是可雜湊的(hashable)
 2@lru_cache
 3def process(data: list):  # 錯誤!list 不可雜湊
 4    pass
 5
 6@lru_cache
 7def process(data: tuple):  # 正確,tuple 可雜湊
 8    pass
 9
10# 2. 注意快取大小
11@lru_cache(maxsize=None)  # 無限制,可能耗盡記憶體
12def expensive_function(x):
13    pass
14
15@lru_cache(maxsize=128)  # 建議設定合理上限
16def expensive_function(x):
17    pass
18
19# 3. 可以手動清除快取
20expensive_function.cache_clear()
21
22# 4. 查看快取統計
23info = expensive_function.cache_info()
24# CacheInfo(hits=10, misses=5, maxsize=128, currsize=5)

資料結構選擇

O(n) vs O(1) 的差異

hook_validator.py 中,檢查測試檔案是否存在:

 1def check_test_exists(self, hook_path: Path) -> list[ValidationIssue]:
 2    """檢查對應的測試檔案是否存在"""
 3    issues = []
 4
 5    hook_name = hook_path.stem
 6    test_name = f"test_{hook_name.replace('-', '_')}.py"
 7
 8    # 測試檔案可能在這些位置
 9    possible_test_paths = [
10        self.project_root / ".claude" / "lib" / "tests" / test_name,
11        self.project_root / ".claude" / "hooks" / "tests" / test_name,
12    ]
13
14    test_exists = any(p.exists() for p in possible_test_paths)
15    # ...

如果需要檢查多個檔案是否在某個集合中:

 1# 不好的做法:用 list,O(n) 查詢
 2existing_tests = [
 3    "test_branch_verify.py",
 4    "test_hook_io.py",
 5    "test_config_loader.py",
 6    # ... 可能有幾十個
 7]
 8
 9def has_test_slow(test_name: str) -> bool:
10    return test_name in existing_tests  # O(n)
11
12# 好的做法:用 set,O(1) 查詢
13existing_tests_set = {
14    "test_branch_verify.py",
15    "test_hook_io.py",
16    "test_config_loader.py",
17    # ...
18}
19
20def has_test_fast(test_name: str) -> bool:
21    return test_name in existing_tests_set  # O(1)

真實案例:測試檔案存在性檢查

 1import time
 2from pathlib import Path
 3
 4def compare_data_structures():
 5    """比較 list vs set 的查詢效能"""
 6
 7    # 模擬測試檔案列表
 8    test_files_list = [f"test_hook_{i}.py" for i in range(100)]
 9    test_files_set = set(test_files_list)
10
11    # 要查詢的檔案
12    queries = [f"test_hook_{i}.py" for i in range(50, 150)]  # 50 個存在,50 個不存在
13
14    iterations = 10000
15
16    # 測試 list
17    start = time.perf_counter()
18    for _ in range(iterations):
19        for q in queries:
20            _ = q in test_files_list
21    list_time = time.perf_counter() - start
22
23    # 測試 set
24    start = time.perf_counter()
25    for _ in range(iterations):
26        for q in queries:
27            _ = q in test_files_set
28    set_time = time.perf_counter() - start
29
30    print(f"List 查詢: {list_time:.4f}s")
31    print(f"Set 查詢:  {set_time:.4f}s")
32    print(f"加速比:    {list_time / set_time:.1f}x")
33
34compare_data_structures()

輸出:

1List 查詢: 0.8234s
2Set 查詢:  0.0123s
3加速比:    66.9x

當資料量為 100 個元素時,set 比 list 快約 60-70 倍。隨著資料量增加,差距會更大。

何時使用哪種資料結構

操作listsetdict
查詢元素是否存在O(n)O(1)O(1)
依索引存取O(1)N/AN/A
依鍵存取N/AN/AO(1)
保持順序YesNo*Yes**
允許重複YesNoKeys: No

* Python 3.7+ 的 set 實際上保持插入順序,但這是實作細節,不是語言保證。 ** Python 3.7+ 的 dict 保證保持插入順序。

選擇指南

  • 需要頻繁查詢「是否存在」→ 用 set
  • 需要依索引存取 → 用 list
  • 需要鍵值對應 → 用 dict
  • 需要去重 → 用 set

優化的代價

每個優化都有代價,需要評估是否值得。

維護成本

優化技術效能提升程式碼複雜度維護成本
正則表達式預編譯20-30%
lru_cache視命中率而定中(需注意快取失效)
list → set數十倍
自訂資料結構視情況

何時不該優化

 1# 不值得優化的情況
 2
 3# 1. 執行次數很少
 4def setup_once():
 5    """只在程式啟動時執行一次"""
 6    config = load_all_configs()  # 即使慢也只執行一次
 7    return config
 8
 9# 2. 已經夠快了
10def check_single_file(path: str) -> bool:
11    """檢查單一檔案是否存在"""
12    return Path(path).exists()  # 0.0001s,不需要優化
13
14# 3. I/O 才是真正的瓶頸
15def process_files(paths: list[str]):
16    for path in paths:
17        content = Path(path).read_text()  # 瓶頸在這裡
18        result = process(content)  # 這裡快 10 倍也沒用

優化前檢查清單

在優化之前,問自己:

  1. 這段程式碼是瓶頸嗎? - 用 cProfile 確認
  2. 執行頻率高嗎? - 每秒執行一次 vs 每天執行一次
  3. 優化後維護成本增加多少? - 複雜度 vs 效能
  4. 有更簡單的解決方案嗎? - 演算法改進 vs 微優化

思考題

  1. 在什麼情況下,預編譯正則表達式反而可能降低效能?

  2. lru_cache 不適合用在什麼樣的函式上?

  3. 如果一個函式既需要快取,又需要在某些條件下強制重新計算,你會如何設計?

  4. 為什麼說「先測量,後優化」很重要?能舉一個反例嗎?

實作練習

練習 1:分析現有程式碼

用 cProfile 分析你自己專案中的一段程式碼,找出效能瓶頸。

 1import cProfile
 2import pstats
 3from pstats import SortKey
 4
 5# 替換為你要分析的函式
 6def your_function():
 7    pass
 8
 9profiler = cProfile.Profile()
10profiler.enable()
11
12# 執行多次以獲得可靠數據
13for _ in range(100):
14    your_function()
15
16profiler.disable()
17stats = pstats.Stats(profiler)
18stats.sort_stats(SortKey.CUMULATIVE)
19stats.print_stats(10)

練習 2:實作帶統計的快取

擴展 lru_cache,加入更詳細的統計資訊:

 1from functools import wraps
 2from typing import Callable, TypeVar
 3from collections import defaultdict
 4import time
 5
 6T = TypeVar('T')
 7
 8def cached_with_stats(maxsize: int = 128):
 9    """
10    帶統計資訊的快取裝飾器
11
12    統計項目:
13    - 命中/未命中次數
14    - 平均執行時間
15    - 各參數的呼叫次數
16    """
17    def decorator(func: Callable[..., T]) -> Callable[..., T]:
18        # 你的實作
19        pass
20    return decorator

練習 3:效能比較

比較以下三種檢查字串是否包含多個關鍵字的方法:

 1keywords = ["error", "warning", "critical", "fatal"]
 2
 3# 方法 1:多個 in 檢查
 4def method1(text: str) -> bool:
 5    return any(kw in text for kw in keywords)
 6
 7# 方法 2:正則表達式
 8import re
 9pattern = re.compile("|".join(keywords))
10def method2(text: str) -> bool:
11    return bool(pattern.search(text))
12
13# 方法 3:set 交集
14keywords_set = set(keywords)
15def method3(text: str) -> bool:
16    words = set(text.lower().split())
17    return bool(words & keywords_set)
18
19# 測試並比較效能
20# ...

延伸閱讀

入門系列

進階系列

官方文件

外部資源


上一章:並行處理實戰 下一章:案例研究