Python 函式簽章的傳遞

functools.wraps 似乎達成了比我們想像的還要多事情

在寫 decorator 時通常會看到相關的文件建議補上 functools.wraps,但之前都沒有認真注意過它幫我們做了些什麼,現在來認真研究了一下。

所以,functools.wraps 的效果是什麼

首先,functools.wraps 是個 decorator,它的作用是幫我們跑過一遍 functools.update_wrapper;而 functools.update_wrapper 的工作是用來「把代理函數(wrapper function)變得看起來像是原始函數(wrapped function)」的——什麼意思呢?

這裡用一個比較惡搞的範例來做嘗試:

from functools import wraps
def add(a: int, b: int) -> int:
"""A + B = ?"""
return a + b
@wraps(add)
def example():
print("Hello World!")

在這段程式裡 example 是一個無輸入、無輸出、無文件的函數,如果不帶上 @wraps 時它應該是長這個樣子:

(註:本文中皆使用 IPython 的 dynamic object information 輸出進行演示)

In [ ]: example?
Signature: example()
Docstring: <no docstring>
Type: function

但經過 @wraps 的巧手之後,我們可以發現它的函式簽章(function signature)及說明字串(docstring)已經被調整到看起來跟 add 一樣了:

In [ ]: example?
Signature: example(a: int, b: int) -> int
Docstring: A + B = ?
Type: function

不過這終究是鏡花水月,在呼叫函數的那個當下 Python 還是會依照函數原生的簽章去判斷輸入是否合法。

有什麼好處

@wraps 是一個使用包裝函數(wrapper function)時的輔助工具,而 Python 的 decorator 便是一個實現包裝函數的語法糖

一種包裝函數的使用情境是代為管理某些外部資源,這邊用 HTTP 連線來做一個範例:

import functools
import httpx
_client = None
def set_client(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
global _client
if _client is None:
_client = httpx.Client(base_url="http://example.com")
return func(*args, **kwargs)
return wrapper
@set_client
def request_resource(client: httpx.Client, type_: str):
return _client.get(f"/api/{type_}")

在這個範例中, request_resource 在模組初始化階段就被 set_client 代換成一個代理函數( wrapper 物件)了。

但由於 @wraps 的效果,我們對 request_resource 這個物件做查詢,仍會得到原始的 request_resource 的簽章與說明。

怎麼達成的

Python 函數的各種屬性——諸如名稱、函式簽章、型別注釋及說明字串等,都會被帶在函數的實例(instance)上、以特殊屬性方式存在於實例裡。

functools.update_wrapper 做的事情大致上就是把原始函數上的這些特殊屬性都複製到代理函數上,然後在 IPython 之類的工具在分析代理函數時就會「認為」其長得就跟原始函數相同。

它做了哪些事

在 Python 走到了 3.13 的今天,文件上有說道 functools.update_wrapper 會幫我們更新以下幾個參數:

  1. __module__: 函數被定義時所在的模組名
  2. __name__: 函數的名字
  3. __qualname__: 函數的全名,會包含其所屬模組、類別的名字,算是一種在直譯器裡面的 path。
  4. __annotations__: 型別注釋
  5. __type_params__: 型別參數,此為 Python 3.11 起新增的功能
  6. __doc__: 說明字串

理論上來說,跑 update_wrapper 等於幫我們自動做完:

example.__module__ = add.__module__
example.__name__ = add.__name__
example.__qualname__ = add.__qualname__
example.__annotations__ = add.__annotations__
example.__type_params__ = add.__type_params__
example.__doc__ = add.__doc__

並且在未來、如果新增了其他跟函數外觀有關的特殊屬性時,使用 update_wrapper 也會一併接受到更新。

But, One more thing

上述的一切似乎都很理所當然,但事有蹊蹺——如果我們去檢查 __annotations__ 會發現它僅羅列了各輸入輸出的型別,以最前面建立的 add 為例:

In [ ]: add.__annotations__
Out[ ]: {'a': int, 'b': int, 'return': int}

問題是,這些資訊並不足以建立精確的函數簽章,舉例來說,這個物件提供的資訊並無法分辨位置引數(positional argument)與關鍵字引數(keyword argument)的;但套用過 functools.update_wrapper 的函數物件卻可以正確地反映出原生函數的簽章,故我們可以斷言函式簽章的資訊並不是由上述羅列的特殊屬性來提供。

接下來的事情就是追原始碼了,在調閱了 inspect.signature 的實作後整理出幾個很容易被略過的行為:

__wrapped__

根據 functools.update_wrapper 的文件,它會自動在代理函數上加上 __wrapped__ 屬性,指向被包裝的原始函數。

inspect.signature 在發現有目標帶有這個屬性時,預設會往其原始函數去追。

__signature__

inspect.signature 的文件有提及到這個特殊屬性、且被註明為 CPython 自有的行為:我們可以透過主動指派 Signature__signature__ 屬性來「覆寫」物件的函式簽章。

這邊使用「覆寫」的原因是 Python 本身不會產出這個屬性(即使 CPython 也不會),但 CPython 的 inspect.signature 實作在檢測到物件帶有這個屬性值會直接採用這個屬性的資料作為回傳。

真正的資料來源

上面提及了兩個不容易察覺、但會影響函式簽章解析的特殊屬性。那麼,在沒有帶著這些特殊屬性的物件上,inspect.signature 又是怎麼去分析函式簽章的呢?答案是——直接分析程式碼

果然是古老而可靠的手段啊。