裝飾器

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 函式接受類別,在函式內部修改了類別的 contentprice 方法,最後原類別被傳回,如果使用 @sidedish1 裝飾 FriedChicken 類別,在建構類別實例之後,透過實例呼叫 contentprice 方法,都會先呼叫原類別定義之方法,然後加上額外的資訊。

對類別定義的方法裝飾也是可行的,若是實例方法,傳回的函式,第一個參數是用來接受類別的實例。例如,若要以函式來實作方法裝飾器,一個簡單的例子如下:

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)

因為要接受實例,wrapperself 參數不可省略。可以將上例設計的更通用一些,讓 @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)

分享到 LinkedIn 分享到 Facebook 分享到 Twitter