裝飾器
May 11, 2022在〈屬性與方法〉看過 @property
,在〈使用 with as〉 看過 @contextmanager
等裝飾器(decorator),這些裝飾器本質上就是接受 callable 物件 傳回 callable 物件的 callable 物件。
基於函式實作
簡單的裝飾器可以使用函式定義,該函式接受函式且傳回函式。這邊以實際的例子來說明,假設你設計了點餐程式,目前主餐有炸雞,價格為 49 元:
def friedchicken():
return 49.0
print(friedchicken()) # 49.0
之後在程式中其他幾個地方都呼叫了 friedchicken
函式,若現在打算增加附餐,以便客戶點主餐時可以搭配附餐,問題在於程式碼該怎麼做?修改 friedchicken
函式?另外增加一個 friedchickenside1
函式?也許你的主餐不只有炸雞,還有漢堡、義大利麵等各式主餐呢!無論是修改各個主餐的相關函式,或者新增各種 xxxxside1
函式,顯然都很麻煩而沒有彈性。
Python 的函式是一級值,函式可以接受函式並傳回函式:
def sidedish1(meal):
return lambda: meal() + 30
def friedchicken():
return 49.0
friedchicken = sidedish1(friedchicken)
print(friedchicken()) # 顯示79.0
對於底下的程式碼,若 decorator
是個函式:
@decorator
def func():
pass
執行時結果相當於:
func = decorator(func)
因此方才的範例,可以使用以下方式撰寫:
def sidedish1(meal):
return lambda: meal() + 30
@sidedish1
def friedchicken():
return 49.0
print(friedchicken()) # 顯示 79.0
使用 @sidedish1
這樣的標註方式,讓 @sidedish1
像是對 friedchicken
函式加以裝飾,在不改變 friedchicken
的行為下,增加了附餐的行為,這類的函式實現了 Decorator 模式,若必要,也可以進一步堆疊裝飾器:
def sidedish1(meal: PriceFunc) -> PriceFunc:
return lambda: meal() + 30
def sidedish2(meal: PriceFunc) -> PriceFunc:
return lambda: meal() + 40
@sidedish1
@sidedish2
def friedchicken():
return 49.0
print(friedchicken()) # 顯示119.0
最後執行時的函式順序,就是從堆疊最底層開始往上層呼叫;@
之後可以是傳回函式的運算式,因此若裝飾器語法需要帶有參數,用來作為裝飾器的函式,必須先以指定的參數執行一次,傳回的函式物件再用來裝飾指定的函式。
例如以下這個帶參數的裝飾器:
@deco('param')
def func():
pass
實際上執行時,相當於:
func = deco('param')(func)
@wraps 裝飾器
如果經過裝飾的 friedchicken
函式本身 print
出來,會顯示什麼呢?
@sidedish1
def friedchicken():
return 49.0
# <function sidedish1.<locals>.<lambda> at 0x0000027090022F70>
print(friedchicken)
明明就是 friedchicken
,顯示出來卻說它是…嗯…sidedish1
中的 lambda
函式,這是因為傳回的函式不是原本的函式,就目前這個範例來說,雖然不會有什麼問題,然而,若這是某模組中的函式,被裝飾的過程是私有實作,透過查看函式的資訊,就有可能因曝露相關的實作細節而被誤導。
若希望被裝飾的函式,可以假裝它就是原本的函式,必須修改傳回函式的名稱等相關資訊(像是記錄在 __module__
、__name__
、__doc__
等的資訊),想達到此目的,捷徑之一是透過 functools.wraps
裝飾要傳回的函式:
from functools import wraps
def sidedish1(meal):
@wraps(meal)
def wrapper():
return meal() + 30
return wrapper
@sidedish1
def friedchicken():
return 49.0
# 顯示 79.0
print(friedchicken())
# 顯示 <function friedchicken at 0x00000270900180D0>
print(friedchicken)
裝飾類別/方法
裝飾器可以用來裝飾類別,例如:
def decorator(cls):
pass
@decorator
class Some:
pass
程式碼執行的效果相當於:
def decorator(cls):
pass
class Some:
pass
Some = decorator(Some)
例如,若先前的 friedchicken
函式,因為設計上的考量,打算定義為 FriedChicken
,若想對 FriedChicken
類別裝飾,加上附餐功能,可以設計一個函式如下:
from functools import wraps
def sidedish1(cls):
wrapped_content = cls.content
wrapped_price = cls.price
@wraps(wrapped_content)
def content(self):
return wrapped_content(self) + ' | 可樂 | 薯條'
@wraps(wrapped_price)
def price(self):
return wrapped_price(self) + 30.0
cls.content = content
cls.price = price
return cls
@sidedish1
class FriedChicken:
def content(self):
return "不黑心炸雞"
def price(self):
return 49.0
friedchicken = FriedChicken()
print(friedchicken.content()) # 不黑心炸雞 | 可樂 | 薯條
print(friedchicken.price()) # 79.0
這個範例的 sidedish1
函式接受類別,在函式內部修改了類別的 content
與 price
方法,最後原類別被傳回,如果使用 @sidedish1
裝飾 FriedChicken
類別,在建構類別實例之後,透過實例呼叫 content
與 price
方法,都會先呼叫原類別定義之方法,然後加上額外的資訊。
對類別定義的方法裝飾也是可行的,若是實例方法,傳回的函式,第一個參數是用來接受類別的實例。例如,若要以函式來實作方法裝飾器,一個簡單的例子如下:
from functools import wraps
def log(mth):
@wraps(mth)
def wrapper(self, a, b):
print(self, a, b)
return mth(self, a, b)
return wrapper
class Some:
@log
def doIt(self, a, b):
return a + b
s = Some()
print(s.doIt(1, 2))
@log
標註了 doIt
方法,這相當於在類別中定義了:
doIt = log(doIt)
因為要接受實例,wrapper
的 self
參數不可省略。可以將上例設計的更通用一些,讓 @log
裝飾的對象,不限於可接受兩個引數的方法。例如:
from functools import wraps
def log(mth: Callable) -> Callable:
@wraps(mth)
def wrapper(self, *arg, **kwargs):
print(self, arg, kwargs)
return mth(self, *arg, **kwargs)
return wrapper
class Some:
@log
def doIt(self, a, b):
return a + b
s = Some()
print(s.doIt(1, 2))
基於 callable 物件實作
在〈callable 物件〉談過,函式不過就是 callable 物件的一種,如果裝飾器實作時需要有帶有狀態,使用函式實現時會有較為複雜、不直覺等原因時,可以考慮以類別定義 __call__
,也就是基於 callable 物件實作裝飾器。
因此,若基於 callable 物件實作裝飾器,基本概念是:
class decorator:
def __init__(self, func):
self.func = func
def __call__(self, *args):
result = self.func(*args)
# 對 result 作裝飾(傳回)
@decorator
def some(arg):
pass
some(1)
執行以上的程式片段,其實相當於:
some = decorator(some)
some(1) # 呼叫 some.__call__(1)
若最後 some
參考之實例,想偽裝為被裝飾前的函式,必須額外下點功夫。例如:
from functools import wraps
class CallableWrapper:
def __init__(self, func):
self.func = func
def __call__(self, *args):
result = self.func(*args)
# 對 result 作裝飾(傳回)
def decorator(func):
callable_wrapper = CallableWrapper(func)
@wraps(func)
def wrapper(arg):
return callable_wrapper(arg)
return wrapper
@decorator
def some(arg):
pass
some(1)
wraps
會使用 func
的資訊,修改傳回的 wrapper
函式,而最後呼叫 some(1)
等同於呼叫 wrapper(1)
,結果就是 callable_wrapper(1)
,最後呼叫了 callable_wrapper.__call__(1)
。