今天我們分享decorator class
裝飾於function
上的情況。本日接下來內容,會以decorator
來作為decorator class
的簡稱。
另外,有些於decorator function
提過的細節,將不特別重複,直接入進本日重點。
decorator
的核心概念為接受一個function
,從中做一些操作,最後返回一個class
的instance
。一般來說,返回的instance
是個callable
,會接收與原function
相同的參數,並返回相同的結果,但卻能具有decorator
額外賦予的功能。
# 01
class dec:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
@dec
def my_func(*args, **kwargs):
pass
decorator
,名為dec
。其接收一個function
,但卻返回一個dec
生成的instance
,稱作dec_instance
。接收的function
會被指定為dec_instance
的instance variable
self.func
。dec
裝飾的function
,名為my_func
,其可接受*args
及**kwargs
。藉由decorator
,my_func
已經從原先的my_func
變成dec_instance
了。由於__call__
(註1
)與原先的my_func
接收相同的參數(即*args
及**kwargs
),所以裝飾前後,my_func
的呼叫方式是一致的。
當呼叫my_func
時,實際上是在呼叫dec_instance
。舉例來說,此時的my_func(1, 2)
,相當於呼叫dec_instance(1, 2)
,即dec_instance.__call__(1, 2)
。而dec_instance.__call__
則返回原先傳入的self.func
搭配上args = (1, 2),kwargs = {}
這些參數的計算結果。
__get__
)# 01
的code
有個潛在問題,就是當它裝飾在class
內的function
時,如01a
,會raise TypeError
。
# 01a
class dec:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
class MyClass:
@dec
def my_func(self, *args, **kwargs):
pass
if __name__ == '__main__':
my_inst = MyClass()
my_inst.my_func() # TypeError
01a
的錯誤訊息為MyClass.my_func() missing 1 required positional argument: 'self'
。這是怎麼一回事呢?
我們一樣回到核心原理開始思考,雖然MyClass
中的my_func
原本是個function
,但經過dec
的裝飾後,my_func
已經變作dec_instance
,所以上述的MyClass
可視為下面這個寫法的簡潔版(記得@dec
是語法糖嗎?)。
# 01a
class MyClass:
def my_func(self, *args, **kwargs):
pass
my_func = dec(my_func)
這麼一來,很清楚的看出my_func
是位於MyClass
中,由dec
所建立的一個dec_instance
。此時my_inst.my_func()
,相當於dec_instance
作為一個callable
來呼叫dec_instance.__call__()
,而其__call__
需接收一個self
參數,及選擇性給予的*args
及**kwargs
。由於我們沒有傳參數給__call__
,所以Python提醒我們最少需要給self
參數,才能呼叫成功。
或許您還是有疑惑,為什麼我們明明使用my_inst.my_func()
,為什麼my_inst
沒有自動傳遞給my_func
,作為第一個參數self
呢?那是因為function
是一種non-data descriptor
,其具備有__get__
,並於其內使用MethodType
來將descriptor
的instance
與呼叫其的instance
bound
在一起,所以才會有我們習慣的自動傳遞instance
到function
中,作為第一個參數self
的行為。如果有些不明白的話,我們會於後續導讀Descriptor HowTo Guide
的部份再詳談。
重點是,當使用decorator class
裝飾class
中的function
,實作__get__
可以讓它用起來,就像是一般的instance method
(註2
),如# 02
。
# 02
from types import MethodType
class dec:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
def __get__(self, instance, owner_class):
if instance is None:
return self
return MethodType(self, instance)
class MyClass:
@dec
def my_func(self, *args, **kwargs):
pass
if __name__ == '__main__':
my_inst = MyClass()
my_inst.my_func() # ok
dec
的__get__
中的MethodType(self, instance)
會幫忙將dec_instance
與my_inst
bound
在一起。
基本型態1
與基本型態2
的寫法皆會喪失被裝飾function
的metadata
。一個折衷的辦法是將這些metadata
更新到dec_instance.__dict__
,即__init__
中的update_wrapper(self, self.func)
。
# 03
from functools import update_wrapper
from types import MethodType
class dec:
def __init__(self, func):
self.func = func
update_wrapper(self, self.func)
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
def __get__(self, instance, owner_class):
if instance is None:
return self
return MethodType(self, instance)
# 04
透過decorator
來logging
一些資訊:
# 04
import logging
from functools import update_wrapper
from types import MethodType
class log:
def __init__(self, func):
self.func = func
update_wrapper(self, self.func)
def __call__(self, *args, **kwargs):
logging.info(f'__call__ is called, {self.func=}, {args=}, {kwargs=}')
return self.func(*args, **kwargs)
def __get__(self, instance, owner_class):
if instance is None:
return self
return MethodType(self, instance)
@log
def add(a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
class MyClass:
@log
def add(self, a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO)
my_inst = MyClass()
print(add(1, 2)) # 3
print(my_inst.add(1, 2)) # 3
在__call__
中,我們加了一行logging.info
來協助記錄每次log
生成的instance
被呼叫時,其實際使用的func
、args
及kwargs
。
INFO:root:__call__ is called, self.func=<function add at 0x0000015CBE110C20>, args=(1, 2), kwargs={}
3
INFO:root:__call__ is called, self.func=<function MyClass.add at 0x0000015CBE88A160>, args=(<__main__.MyClass object at 0x0000015CBE884950>, 1, 2), kwargs={}
3
可以順便觀察metadata
更新的狀況。
add=<__main__.log object at 0x0000015CBE4CF910>
add.__module__='__main__'
add.__name__='add'
add.__doc__='Take two integers and return their sum.'
add.__qualname__='add'
add.__annotations__={'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
add.__dict__={'func': <function add at 0x0000015CBE110C20>, '__module__': '__main__', '__name__': 'add', '__qualname__': 'add', '__doc__': 'Take two integers and return their sum.', '__annotations__': {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}, '__wrapped__': <function add at 0x0000015CBE110C20>}
my_inst.add=<bound method MyClass.add of <__main__.MyClass object at 0x0000015CBE884950>>
my_inst.add.__module__='__main__'
my_inst.add.__name__='add'
my_inst.add.__doc__='Take two integers and return their sum.'
my_inst.add.__qualname__='MyClass.add'
my_inst.add.__annotations__={'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
my_inst.add.__dict__={'func': <function MyClass.add at 0x0000015CBE88A160>, '__module__': '__main__', '__name__': 'add', '__qualname__': 'MyClass.add', '__doc__': 'Take two integers and return their sum.', '__annotations__': {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}, '__wrapped__': <function MyClass.add at 0x0000015CBE88A160>}
當我們希望有一個flag來控制這個decorator
是否要logging
,可以寫成# 05
:
# 05
import logging
from functools import wraps
class log:
def __init__(self, to_log=True):
self.to_log = to_log
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
if self.to_log:
logging.info(
f'__call__ wrapper is called, {func=}, {args=}, {kwargs=}')
return func(*args, **kwargs)
return wrapper
@log()
def add(a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
class MyClass:
@log()
def add(self, a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO)
my_inst = MyClass()
print(add(1, 2)) # 3
print(my_inst.add(1, 2)) # 3
decorator factory
一樣,可施加於一般function
,及class
內的function
。
@log()
中的log()
會先得到log
的instance
,稱作log_instace
。由於log
有實作__call__
,所以log_instace
為callable
。@log()
相當於@log_instance(add)
(to_log
的資訊已傳至self.to_log
)。由於__call__
是一個instance method
,log_instance
將自動傳遞至__call__
作為第一個參數self
。__call__
會回傳wrapper
,其接受參數與add
相同,並會返回相同結果,只是額外針對self.to_log
的值來決定是否進行logging
。所以最終呼叫add
相當於呼叫wrapper
。wrapper
function
,不再是基本型態中的instance
,所以我們可以直接像在decorator function
中一樣,使用較為方便的functools.wraps
。值得一提的是,這個情況我們不需替log
實作__get__
,即可施加於class
內的function
。原因是這次我們返回的是wrapper
,其本身是function
,function
本身就有實作__get__
,所以當使用my_inst.add(1, 2)
,其會返回一個bound
好add
及my_inst
的MethodType
instance
來接收1
跟2
這兩個參數。
# 06
import logging
from functools import wraps
from numbers import Real
from typing import get_type_hints
class log:
def __init__(self, *, to_log=True, validate_input=True):
self.to_log = to_log
self.validate_input = validate_input
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
if self.to_log:
logging.info(
f' `wrapper` is called, {func=}, {args=}, {kwargs=}')
if self.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 self.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 self.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 self.to_log:
logging.error(
f'Annotations={type_hints}, {args=}, {kwargs=}')
raise TypeError(
f'Possible incorrect type assignment in {kwargs=}')
result = func(*args, **kwargs)
if self.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 self.to_log:
logging.info(' `wrapper` is finished.')
return result
return wrapper
@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=2.3)
print(r, type(r)) # 3.5, float
INFO:root: `wrapper` is called, func=<function add at 0x0000024A7C3C4EA0>, args=(1.2,), kwargs={'b': 2.3}
INFO:root: `wrapper` is finished.
3.5 <class 'float'>
# 06
與[Day05]的# 07
寫法不同的地方,只在:
# 06
將接收的參數邏輯放在__init__
。# 06
將wrapper
放在__call__
。如果想要同時能夠使用@log
及@log()
兩種語法,勢必要面對回傳值有時是function
,有時是instance
的情況,所以相關metadata
的處理也要記得分開處理,我們做了一些嘗試(註3
)。
相較之下,我們會建議使用一個function
來包住一個decorator class
,如# 07
所示。原理其實和decorator function
的# 08
差不多。
# 07
import logging
from functools import wraps
class log:
def __init__(self, *, to_log=True):
self.to_log = to_log
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
if self.to_log:
logging.info(
f'__call__ wrapper is called, {func=}, {args=}, {kwargs=}')
return func(*args, **kwargs)
return wrapper
def logf(func=None, /, *, to_log=True):
if func is None:
return log(to_log=to_log)
return log(to_log=to_log)(func)
@logf()
def add1(a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
@logf
def add2(a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
class MyClass:
@logf()
def add1(self, a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
@logf
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
若檢查所有metadata
,也都有一起更新。
INFO:root:__call__ wrapper is called, func=<function add1 at 0x000001D48F3C4E00>, args=(1, 2), kwargs={}
3
INFO:root:__call__ wrapper is called, func=<function add2 at 0x000001D48F886200>, args=(1, 2), kwargs={}
3
INFO:root:__call__ wrapper is called, func=<function MyClass.add1 at 0x000001D48F8863E0>, args=(<__main__.MyClass object at 0x000001D48F8886D0>, 1, 2), kwargs={}
3
INFO:root:__call__ wrapper is called, func=<function MyClass.add2 at 0x000001D48F886520>, args=(<__main__.MyClass object at 0x000001D48F8886D0>, 1, 2), kwargs={}
3
基本型態1
時,decorator
可施加於一般的function
。基本型態2
時,decorator
可施加於一般的function
及class
內的function
上。基本型態3
時,decorator
可施加於一般的function
及class
內的function
上,且被裝飾function
的metadata
會更新至decorator
生成的instance
內。decorator factory
最終會返回的是function
,其本身已具有__get__
,所以不用額外處理。常用型態
時,建議使用一個function
包住一個decorator class
使用,我們覺得會比註3
的寫法更簡單優雅。註1:當class
內有實作__call__
,該class
生成的instance
則為callable
。
註2:其實對於classmethod
或staticmethod
上,decorator class
也是可以用的,只是必須注意順序,要得先@log
再加上@classmethod
或@staticmethod
。
# 101
class MyClass:
@classmethod
@log
def class_method(cls):
pass
@staticmethod
@log
def static_method():
pass
註3:# 102
中,除了需要同時考慮兩種邏輯,還要記得實作__get__
,相比於# 07
的寫法複雜不少。
# 102
import logging
from functools import update_wrapper
from types import MethodType
class log:
def __init__(self, func=None, /, *, to_log=True):
self.func = func
self.to_log = to_log
if func is not None:
update_wrapper(self, func)
def _make_wrapper(self, func):
def wrapper(*args, **kwargs):
if self.to_log:
logging.info(
f'__call__ inner is called, {func=}, {args=}, {kwargs=}')
return func(*args, **kwargs)
return wrapper
def __call__(self, *args, **kwargs):
if self.func is None:
func = args[0]
wrapper = self._make_wrapper(func)
update_wrapper(wrapper, func)
return wrapper
else:
func = self.func
wrapper = self._make_wrapper(func)
return wrapper(*args, **kwargs)
def __get__(self, instance, owner_class):
if instance is None:
return self
return MethodType(self, instance)