本文 AI 產出,尚未審核

Python – 函式式編程(Functional Programming)

裝飾器再探(functools.wraps


簡介

在 Python 中,裝飾器(decorator) 是一種非常好用的語法糖,讓我們可以在不改變原始函式本體的情況下,為函式「加上」額外的行為。
然而,在實作裝飾器的過程中,最常見的問題就是「函式的原始資訊(名稱、說明文件、簽名)會被隱藏」——這對除錯、文件產生、IDE 補全等都會造成困擾。

functools.wraps 正是為了解決這個問題而設計的,它會把 原始函式的元資料__name____doc____module____annotations__ 等)正確地複製到包裝後的函式上。
本文將從概念說明、實作範例、常見陷阱與最佳實踐,一直到真實的應用情境,完整介紹 functools.wraps 的使用方式,讓你在寫裝飾器時既安全又好維護。


核心概念

1️⃣ 為什麼需要 functools.wraps

普通的裝飾器會回傳一個 全新函式,而不是原本的函式本身。舉例:

def simple_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before call")
        result = func(*args, **kwargs)
        print("After call")
        return result
    return wrapper

使用 @simple_decorator 包裝後的函式,__name__ 會變成 wrapper__doc__ 會變成 None,這在:

  • 除錯:堆疊追蹤會顯示 wrapper,不易辨識是哪一個原始函式。
  • 文件生成:自動產生的 API 文件會失去說明文字。
  • IDE 補全:簽名資訊遺失,開發者必須自行查閱原始程式碼。

functools.wraps 只需要一行裝飾,就能把這些資訊「搬回」包裝函式。

import functools

def better_decorator(func):
    @functools.wraps(func)          # ← 這行是關鍵
    def wrapper(*args, **kwargs):
        print("Before call")
        return func(*args, **kwargs)
    return wrapper

2️⃣ functools.wraps 的底層原理

functools.wraps 本身是一個 裝飾器工廠,它返回 functools.update_wrapper 的簡化版:

def wraps(wrapped,
          assigned=WRAPPER_ASSIGNMENTS,
          updated=WRAPPER_UPDATES):
    return partial(update_wrapper, wrapped=wrapped,
                   assigned=assigned, updated=updated)
  • assigned:決定哪些屬性會被直接賦值(預設 ('__module__', '__name__', '__qualname__', '__doc__', '__annotations__'))。
  • updated:決定哪些屬性會被更新(預設 ('__dict__',),即把原函式的 __dict__ 合併到 wrapper 中)。

了解這些參數可以在特殊需求下自行調整(例如保留自訂屬性)。


3️⃣ 常見的裝飾器範例

以下提供 5 個實務上常見 且搭配 functools.wraps 的範例,每個範例都附有說明與執行結果示意。

3.1 記錄執行時間(Timing Decorator)

import time
import functools

def timing(func):
    """測量函式執行時間的裝飾器"""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        value = func(*args, **kwargs)
        end = time.perf_counter()
        print(f"[Timing] {func.__name__} 執行了 {end - start:.4f}s")
        return value
    return wrapper

@timing
def compute(n: int) -> int:
    """簡單的累加運算"""
    total = 0
    for i in range(n):
        total += i
    return total

# 使用範例
compute(1_000_000)

輸出

[Timing] compute 執行了 0.0321s
  • functools.wrapscompute.__doc__ 仍保留 「簡單的累加運算」,而不是 wrapper 的說明。

3.2 日誌記錄(Logging Decorator)

import logging
import functools

logging.basicConfig(level=logging.INFO)

def logger(level=logging.INFO):
    """依照指定等級記錄函式呼叫資訊"""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            logging.log(level, f"呼叫 {func.__name__},參數: {args}, {kwargs}")
            result = func(*args, **kwargs)
            logging.log(level, f"{func.__name__} 回傳 {result!r}")
            return result
        return wrapper
    return decorator

@logger(logging.DEBUG)
def greet(name: str, *, prefix="Hello"):
    """向使用者打招呼"""
    return f"{prefix}, {name}!"

greet("Alice", prefix="Hi")

日誌

DEBUG:root:呼叫 greet,參數: ('Alice',), {'prefix': 'Hi'}
DEBUG:root:greet 回傳 'Hi, Alice!'
  • 透過 @functools.wrapsgreet.__name__greet.__doc__ 都不會被覆蓋。

3.3 快取(Caching)— lru_cache 的自訂封裝

import functools

def cached(maxsize=128):
    """自訂快取裝飾器,內部使用 lru_cache"""
    def decorator(func):
        @functools.wraps(func)
        @functools.lru_cache(maxsize=maxsize)
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)
        return wrapper
    return decorator

@cached(maxsize=64)
def fib(n: int) -> int:
    """計算第 n 個 Fibonacci 數字(遞迴版)"""
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

print(fib(35))

輸出

9227465
  • fib.__doc__ 仍然是 「計算第 n 個 Fibonacci 數字」,且 functools.lru_cache 已經被正確套用。

3.4 驗證參數(Argument Validation)

import functools

def validate_positive(func):
    """確保所有位置參數皆為正整數"""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        for i, v in enumerate(args):
            if isinstance(v, int) and v <= 0:
                raise ValueError(f"第 {i+1} 個參數必須為正整數, got {v}")
        return func(*args, **kwargs)
    return wrapper

@validate_positive
def multiply(a: int, b: int) -> int:
    """回傳兩數相乘的結果"""
    return a * b

# multiply(3, -5)  # 會拋出 ValueError
multiply(4, 7)      # 正常回傳 28
  • 使用 @functools.wraps 後,multiply.__signature__(由 inspect.signature 取得)仍保持原樣,方便在自動化測試或 API 文件產生時使用。

3.5 裝飾類別方法(Class Method Decorator)

import functools

def method_logger(func):
    """記錄類別方法的呼叫與回傳值"""
    @functools.wraps(func)
    def wrapper(self, *args, **kwargs):
        cls_name = self.__class__.__name__
        print(f"[{cls_name}] 呼叫 {func.__name__},參數: {args}, {kwargs}")
        result = func(self, *args, **kwargs)
        print(f"[{cls_name}] {func.__name__} 回傳 {result!r}")
        return result
    return wrapper

class Calculator:
    @method_logger
    def add(self, x, y):
        """回傳 x + y"""
        return x + y

c = Calculator()
c.add(5, 3)

輸出

[Calculator] 呼叫 add,參數: (5, 3), {}
[Calculator] add 回傳 8
  • @functools.wrapsCalculator.add.__doc__ 仍為 「回傳 x + y」,而不是 wrapper

常見陷阱與最佳實踐

陷阱 可能的結果 解決方式
忘記加 @functools.wraps __name____doc____module__ 皆被覆寫,除錯訊息不明確。 一定在 wrapper 函式上方寫 @functools.wraps(original_func)
直接返回 wrapper 而非 functools.update_wrapper 只保留 __name__,其他屬性仍遺失。 使用 functools.wraps(其內部呼叫 update_wrapper),或自行呼叫 functools.update_wrapper(wrapper, original_func)
在裝飾器內部使用可變預設值 多次呼叫時共享同一個狀態,導致奇怪的行為。 使用 None 作為預設值,再在函式內部初始化(如 cache = {})。
裝飾器接受參數時忘記兩層 @functools.wraps 只包裝了最內層 wrapper,外層仍未保留原始資訊。 兩層都需要 @functools.wraps(外層包裝內層函式)。
使用 functools.wraps 後仍看不到簽名 inspect.signature 只顯示 *args, **kwargs 需要 functools.wraps + functools.update_wrapper(..., assigned=('__signature__',)) 或使用 decorator 第三方套件。

最佳實踐清單

  1. 永遠使用 @functools.wraps,除非你真的不需要保留任何原始資訊。
  2. 保持 wrapper 的簽名與原函式相同(使用 *args, **kwargs),若需要更精確的簽名,可參考 functools.wraps(..., assigned=('__signature__',))
  3. 將裝飾器寫成可重用的函式(接受參數的情況使用三層結構),提升可測試性與可讀性。
  4. 加入型別註解typing),讓 IDE 能正確推斷回傳值與參數型別。
  5. 使用 functools.update_wrapper 直接操作時,明確指定 assignedupdated,避免意外覆寫自訂屬性。

實際應用場景

場景 為何需要 functools.wraps 範例
Web 框架的路由註冊(如 Flask、FastAPI) 框架透過 __name__ 產生 URL,若被覆寫會產生錯誤路徑。 @app.route('/login') @functools.wraps(view_func) def wrapper(...):
自動產生 API 文件(Swagger、OpenAPI) 文件生成工具會抓 __doc__ 與型別註解,失去資訊會導致文件不完整。 @dataclass @functools.wraps 用於服務層函式。
測試框架的 Mock/Spy 測試時需要取得原始函式的 __module__ 以定位來源,wraps 可保持正確。 @patch('module.func') 搭配自訂裝飾器。
企業級日誌與監控 需要在日誌中顯示真正的函式名稱與說明,才能快速定位問題。 前文的 Logging Decorator
函式快取與記憶化 functools.lru_cache 需要正確的函式簽名來決定快取鍵值。 前文的 cached 範例。

總結

  • 裝飾器 為 Python 函式式編程提供了強大的「行為注入」能力,但如果不妥善保留原始函式的元資料,會造成除錯與文件維護的困擾。
  • functools.wraps 只是一行簡單的裝飾器,卻能自動把 名稱、說明文件、模組、註解與 __dict__ 從原始函式搬回包裝函式,使得 堆疊追蹤、IDE 補全與自動化文件生成 都保持正確。
  • 在實務開發中,幾乎所有自訂裝飾器(日誌、計時、驗證、快取、授權等)都應該 配合 functools.wraps 使用,並遵守「保持簽名、避免可變預設值、明確指定 assigned」的最佳實踐。

掌握了 functools.wraps 後,你的裝飾器將不再是「黑盒」——它們既能提供彈性的功能增強,又不會隱藏原本的程式碼意圖,真正做到 可讀、可維、可測。祝你在 Python 的函式式編程旅程中寫出更乾淨、更易維護的程式!