decorator為meta programming的技巧之一,可以改變被其裝飾obj之行為。
由於decorator可以用function及class兩種方法來實作,我們定義以function實作的,稱為decorator function;而以class實作的,稱為decorator class。
又由於decorator可以裝飾在function或class上,所以可能會有以下四種組合:
decorator function裝飾於function上([Day05]內容)。decorator function裝飾於class上([Day07]內容)。decorator class裝飾於function上([Day06]內容)。decorator class裝飾於class上。其中第四種decorator class裝飾於class上的使用情況,我們沒有想到適合的實例,所以決定只針對前三種decorator進行筆記。
本日接下來內容,會以decorator來作為decorator function的簡稱。
decorator的核心概念為接受一個function,從中做一些操作,最後返回一個function。一般來說,返回的function會接收與原function相同的參數,並返回相同的結果,但卻能具有decorator額外賦予的功能(註1)。
decorator的原理,可以用# 01來說明。
# 01
def dec(func):
return func
def my_func():
pass
if __name__ == '__main__':
orig_func_id = id(my_func)
my_func = dec(my_func)
deced_func_id = id(my_func)
print(orig_func_id == deced_func_id) # True
decorator,名為dec。function,名為my_func。my_func相同,來接收dec(my_func)的回傳值。dec其實只是接收了一個變數my_func,再將其返回。比較特別的是,我們新定義了一個與被裝飾的my_func同名的變數來接收其回傳值。在這個範例裡,新變數my_func其實就是一開始定義的my_func function,這可以透過觀察前後兩個my_func的id確認。
由於經常需要使用被裝飾的function名作為新變數名,Python提供了下列的@dec作為語法糖(syntax suger),來幫助大家快速完成這類操作。因此,# 01可以改寫為# 02這個較常見的寫法:
# 02
def dec(func):
return func
@dec
def my_func():
pass
觀察# 02,會發現my_func沒有接受任何參數,這使得它的應用有些局限。我們通常會希望function能根據不同的參數,給出相對應的結果。
根據上述期望,可以寫出# 03:
# 03
def dec(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@dec
def my_func(*args: int, **kwargs: int) -> int:
pass
decorator,名為dec。其接收一個function,但卻返回另一個於內部建立的wrapper function。dec裝飾的function,名為my_func,其可接受*args及**kwargs。藉由decorator,my_func已經從原先的my_func變成wrapper這個function了。由於wrapper與原先的my_func接收相同的參數(即*args及**kwargs),所以裝飾前後,my_func的呼叫方式是一致的。
當我們呼叫my_func時,實際上是在呼叫wrapper。舉例來說,此時的my_func(1, 2),相當於呼叫wrapper(1, 2)。而wrapper則返回原先傳入的my_func搭配上args = (1, 2),kwargs = {}這些參數的計算結果。
就# 03而言,不管my_func有沒有被dec裝飾,其結果是一樣的。但decorator可以視為一個hook,讓我們可以於函數呼叫前或後,進行一些操作。
functools.wraps)如果仔細觀察一下my_func及其相關的metadata:
my_func=<function dec.<locals>.wrapper at 0x000001DBF55C4FE0>
my_func.__module__='__main__'
my_func.__name__='wrapper'
my_func.__doc__=None
my_func.__qualname__='dec.<locals>.wrapper'
my_func.__annotations__={}
my_func.__dict__={}
會發現my_func顯示為wrapper,且其metadata也不符合我們的預期,我們會希望即使是被裝飾過的function,其metadata還是可以保留。
Python內建的functools.wraps可以作為decorator使用(註2),幫助我們更新正確的metadata至wrapper function,如# 04所示。
# 04
from functools import wraps
def dec(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@dec
def my_func(*args: int, **kwargs: int) -> int:
pass
my_func=<function my_func at 0x00000239ECC14FE0>
my_func.__module__='__main__'
my_func.__name__='my_func'
my_func.__doc__=None
my_func.__qualname__='my_func'
my_func.__annotations__={'args': <class 'int'>, 'kwargs': <class 'int'>, 'return': <class 'int'>}
my_func.__dict__={'__wrapped__': <function my_func at 0x00000239ECC16660>}
一個常用的情況是透過decorator來logging一些資訊,如# 05:
# 05
import logging
from functools import wraps
def log(func):
@wraps(func)
def wrapper(*args, **kwargs):
logging.info(f'wrapper is called, {func=}, {args=}, {kwargs=}')
return func(*args, **kwargs)
return wrapper
@log
def add(a: int, b: int) -> int:
return a + b
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO)
print(add(1, 2)) # 3
在wrapper中,我們加了一行logging.info來協助記錄每次wrapper被呼叫時,其實際使用的func、args及kwargs。
此時若呼叫add,會顯示logging的記錄,且返回正確答案。
INFO:root:wrapper is called, func=<function add at 0x000001E918BC6660>, args=(1, 2), kwargs={}
3
由於我們希望裝飾前後的函數,會接收相同的參數,如此較為方便使用。所以當想要傳入一些自訂的參數或是flag時,可以將其作為decorator本身的參數傳入。
舉例來說,當我們想要有一個flag來控制這個decorator是否要logging,可以寫成# 06:
# 06
import logging
from functools import wraps
def log(to_log=True):
def dec(func):
@wraps(func)
def wrapper(*args, **kwargs):
if to_log:
logging.info(f'wrapper is called, {func=}, {args=}, {kwargs=}')
return func(*args, **kwargs)
return wrapper
return dec
@log(to_log=False)
def add(a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
log這個function內又包了兩層function,乍看好像有點複雜,讓我們逐層拆解看看。
log接收一個參數,名為to_log,其預設值為True,並會返回內部第一層的dec function。此時,對於被裝飾的function來說,相當於我們直接將dec裝飾其上,並將給定的to_log往下傳遞。基本型態,dec接收一個func(即add),並返回wrapper function。wrapper內,實作大部份邏輯。此時,於wrapper中我們擁有:
to_log參數。add function。add參數(*args及**kwargs)。此時,我們利用to_log來決定是否進行logging,並將呼叫add(*args, **kwargs)的結果作為wrapper的返回值。這樣的pattern,可以稱為decorator factory,因為實際上dec才是decorator,第一層的參數只是為了提供真正的decorator一些額外的訊息。
# 07為一個decorator factory實例。
# 07
import logging
from functools import wraps
from numbers import Real
from typing import get_type_hints
def log(*, to_log=True, validate_input=True):
def dec(func):
@wraps(func)
def wrapper(*args, **kwargs):
if to_log:
logging.info(
f' `wrapper` is called, {func=}, {args=}, {kwargs=}')
if validate_input:
n = len(args) + len(kwargs)
type_hints = get_type_hints(func)
if n and n+1 > len(type_hints): # return is included in type_hints
if to_log:
logging.error(
f'Annotations={type_hints}, {args=}, {kwargs=}')
raise TypeError('Some annotations might be missing.')
if args and not all(isinstance(arg, type_)
for arg, type_ in zip(args, type_hints.values())):
if to_log:
logging.error(
f'Annotations={type_hints}, {args=}, {kwargs=}')
raise TypeError(
f'Possible incorrect type assignment in {args=}')
if kwargs and not all(isinstance(kw_value, type_)
for name, type_ in type_hints.items()
if (kw_value := kwargs.get(name))):
if to_log:
logging.error(
f'Annotations={type_hints}, {args=}, {kwargs=}')
raise TypeError(
f'Possible incorrect type assignment in {kwargs=}')
result = func(*args, **kwargs)
if validate_input:
expected_return_type = type_hints['return']
if not isinstance(result, expected_return_type):
logging.warning(
f' Return value: {result}(type={type(result)}) is not an ' +
f'instance of {expected_return_type}')
if to_log:
logging.info(' `wrapper` is finished.')
return result
return wrapper
return dec
@log(to_log=True, validate_input=True)
def add(a: Real, b: Real) -> Real:
"""Take two reals and return their sum."""
return a + b
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG)
r = add(1.2, b=3.2)
print(r, type(r)) # 3.5, float
INFO:root: `wrapper` is called, func=<function add at 0x000002D40BB3CFE0>, args=(1.2,), kwargs={'b': 2.3}
INFO:root: `wrapper` is finished.
3.5 <class 'float'>
log接收兩個keyword-only arguments,to_log及validate_input,預設皆為True。當to_log為True時,會呼叫logging模組,記錄不同等級的資訊,如logging.info,logging.warning及logging.error等等。當validate_input為True,會確認給定的args、kwargs及計算結果與type hint是否相符(註3)。最後會回傳第二層的dec function。dec function 會接收被裝飾的function為參數,即func。最後會回傳第三層的wrapper function。wrapper會接收使用者呼叫func的參數,即*args及**kwargs。我們將functools.wraps裝飾於wrapper上,讓其幫忙將func的metadata更新給wrapper。wrapper逐段說明。
to_log為Ture,則呼叫logging.info記錄wrapper被呼叫。validate_input為True,將會分別確認三件事。如果其中任何一件事不符合,則會呼叫logging.error並raise TypeError。
types.get_type_hints來取得func的type hint,接著確認是不是每個參數與回傳值都有給定annotation。add時,可能會使用positional或keyword兩種型態傳遞參數,所以要分別確認args及kwargs。args必定會出現於kwargs之前,再加上Python於3.7後,其dict會維持插入時的順序(3.6屬於非正式支援),所以我們可以使用zip(args, type_hints.values())搭配isinstance來確認args內的每個obj都是type_hints內所描述type的instance(註4)。kwargs,我們可以試著尋找kwargs與type_hints的共同key,並搭配isinstance確認這些共同key的value皆為type_hints內所描述型態的instance。result = func(*args, **kwargs)是func真正被呼叫,進行計算的地方。validate_input為Ture,則檢查result是否為給定type hint的instance。此時,即便是未通過檢查,也僅呼叫logging.warning,而不raise TypeError。這是一個折衷的寫法,一般來說如果result能順利計算完畢,相比於raise Exception,我們會傾向回傳所計算結果,但以warning提醒,讓使用者自己確認,究竟是給錯型態,又或者結果真的不符預期。to_log為Ture,呼叫logging.info記錄wrapper執行完畢。result。如果依照上述的寫法,我們需要呼叫decorator factory,才能取得真正的decorator,也就是說即使我們沒有要修改decorator factory的預設值,我們仍然需要使用@log()的語法才能裝飾function,使用起來有點麻煩。
我們的目標是,希望在沒有要修改decorator factory預設值時,能夠僅使用@log,且@log與@log()是同義的。
要能夠達成目標的關鍵是,如何區分第一個參數是decorator factory的參數,還是已經是要被裝飾的function。這可以仰仗Python的positional-only argument及keyword-only argument。我們可以指定第一個參數一定要是positional-only(預設值為None),而第二個參數以後一定要是keyword-only。
如此一來,我們可以判斷當第一個參數為None時,是使用了@log()這種語法;而當其不為None時,表示是接收了要被裝飾的function,即使用了@log這種語法。
decorator function的寫法,除了可以裝飾於function上,也適用於class中的function。
以下提供兩種常見的寫法:
# 08
import logging
from functools import wraps
def log(func=None, /, *, to_log=True):
def dec(func):
@wraps(func)
def wrapper(*args, **kwargs):
if to_log:
logging.info(f'wrapper is called, {func=}, {args=}, {kwargs=}')
return func(*args, **kwargs)
return wrapper
if func is None:
return dec
return dec(func)
@log()
def add1(a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
@log
def add2(a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
class MyClass:
@log()
def add1(self, a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
@log
def add2(self, a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO)
print(add1(1, 2)) # 3
print(add2(1, 2)) # 3
my_inst = MyClass()
print(my_inst.add1(1, 2)) # 3
print(my_inst.add2(1, 2)) # 3
INFO:root:wrapper is called, func=<function add1 at 0x0000029A770C6980>, args=(1, 2), kwargs={}
3
INFO:root:wrapper is called, func=<function add2 at 0x0000029A770C6660>, args=(1, 2), kwargs={}
3
INFO:root:wrapper is called, func=<function MyClass.add1 at 0x0000029A772A62A0>, args=(<__main__.MyClass object at 0x0000029A77167810>, 1, 2), kwargs={}
3
INFO:root:wrapper is called, func=<function MyClass.add2 at 0x0000029A772A6200>, args=(<__main__.MyClass object at 0x0000029A77167810>, 1, 2), kwargs={}
3
方法1較為直觀,以func是否為None來決定回傳值。
# 09
import logging
from functools import partial, wraps
def log(func=None, /, *, to_log=True):
if func is None:
return partial(log, to_log=to_log)
@wraps(func)
def wrapper(*args, **kwargs):
if to_log:
logging.info(f'wrapper is called, {func=}, {args=}, {kwargs=}')
return func(*args, **kwargs)
return wrapper
@log()
def add1(a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
@log
def add2(a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
class MyClass:
@log()
def add1(self, a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
@log
def add2(self, a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO)
print(add1(1, 2)) # 3
print(add2(1, 2)) # 3
my_inst = MyClass()
print(my_inst.add1(1, 2)) # 3
print(my_inst.add2(1, 2)) # 3
INFO:root:wrapper is called, func=<function add1 at 0x000002607BBC67A0>, args=(1, 2), kwargs={}
3
INFO:root:wrapper is called, func=<function add2 at 0x000002607BBC4FE0>, args=(1, 2), kwargs={}
3
INFO:root:wrapper is called, func=<function MyClass.add1 at 0x000002607C0862A0>, args=(<__main__.MyClass object at 0x000002607BA98950>, 1, 2), kwargs={}
3
INFO:root:wrapper is called, func=<function MyClass.add2 at 0x000002607C0863E0>, args=(<__main__.MyClass object at 0x000002607BA98950>, 1, 2), kwargs={}
3
方法2的寫法非常優雅,且可以免去定義一層中間的function,但需要對decorator有較深的體會才容易運用自如(註5)。
decorator包含了幾層function,我們的目的可以想作是返回一個wrapper function,其所接收的參數與返回值,會與被裝飾的function相同,而我們可以在過程中動點手腳,例如進行logging或驗證型別等。當在多層function中迷路時,建議回到核心原理,以# 08的架構逐層往下思考。
@log(to_log=False)
def add(a: int, b: int) -> int:
return a + b
相當於
add = log(to_log=False)(add)
相當於
add = dec(add)
dec中內含to_log=False。
相當於
add = wrapper
wrapper中內含to_log=False及func為add,且functools.wraps會幫忙將add的metadata更新給wrapper。
此時的add就是wrapper,所以add(1, 2)即相當於wrapper(1, 2)。
functools.wraps可以快速將基本型態1轉為基本型態2。常用型態 方法1直觀但比較繁瑣,方法2稍難理解但優雅。註1:由於decorator是meta programming的一種,所以我們可以返回任何obj。舉例來說,雖然很奇怪,但是我們是可以將args中每一項加1之後,再傳遞給func。
# 101
from functools import wraps
def dec(func):
@wraps(func)
def wrapper(*args, **kwargs):
args = (arg+1 for arg in args)
return func(*args, **kwargs)
return wrapper
@dec
def add(a: int, b: int) -> int:
return a + b
if __name__ == '__main__':
print(add(1, 2)) # 5
註2:functools.wraps會呼叫functools.update_wrapper來更新metadata,如果有除了預設的__module__, __name__, __qualname__, __doc__,__annotations__及__dict__的attribute需要更新的話,必須自己呼叫functools.update_wrapper或是手動更新。
註3:如果您有很多參數或有類似json、database等有schema需要驗證的情形,以Rust重新改寫的PydanticV2或許會是不錯的選擇。
註4:從Python3.10開始,isinstance可以接收多種Union Type,也就是說isinstance(1, type.Union[int, str])、isinstance(1, int | str)或是# isinstance(1, (int, str))等,都是可接受的語法。
註5:對於想更深入研究# 09寫法的朋友,可以參考這篇文章。
註6:*args與**kwargs的type hints。