Python 之裝飾器

Python 之裝飾器

1. 概念介紹

裝飾器(decorator),又稱“裝飾函數”,即一種返回值也是函數的函數,可以稱之爲“函數的函數”。其目的是在不對現有函數進行修改的情況下,實現額外的功能。最基本的理念來自於一種被稱爲“裝飾模式”的設計模式。

在 Python 中,裝飾器屬於純粹的“語法糖”,不使用也沒關係,但是使用的話能夠大大簡化代碼,使代碼更加易讀——當然,是對知道這是怎麼回事兒的人而言。

想必經過一段時間的學習,大概率已經在 Python 代碼中見過@這個符號。沒錯,這個符號正是使用裝飾器的標識,也是正經的 Python 語法。

語法糖:指計算機語言中添加的某種語法,這種語法對語言的功能並沒有影響,但是更方便程序員使用。通常來說使用語法糖能夠增加程序的可讀性,從而減少程序代碼出錯的機會。

2. 運行機制

簡單來說,下面兩段代碼在語義上是可以劃等號的(當然具體過程還是有一點微小區別的):

def IAmDecorator(foo):
    '''我是一個裝飾函數'''
    pass

@IAmDecorator
def tobeDecorated(...):
    '''我是被裝飾函數'''
    pass

與:

def IAmDecorator(foo):
    '''我是一個裝飾函數'''
    pass

def tobeDecorated(...):
    '''我是被裝飾函數'''
    pass
tobeDecorated = IAmDecorator(tobeDecorated)

可以看到,使用裝飾器的@語法,就相當於是將具體定義的函數作爲參數傳入裝飾器函數,而裝飾器函數則經過一系列操作,返回一個新的函數,然後再將這個新的函數賦值給原先的函數名。

最終得到的是一個與我們在代碼中顯式定義的函數同名異質的新函數。

而裝飾函數就好像爲原來的函數套了一層殼。如圖所示,最後得到的組合函數即爲應用裝飾器產生的新函數:

2019-09-23-裝飾器_03.gif

這裏要注意一點,上述兩段代碼在具體執行上還是存在些微的差異。在第二段代碼中,函數名tobeDecorated實際上是先指向了原函數,在經過裝飾器修飾之後,才指向了新的函數;但第一段代碼的執行就沒有這個中間過程,直接得到的就是名爲tobeDecorated的新函數。

此外,裝飾函數有且只能有一個參數,即要被修飾的原函數。

3. 用法

Python 中,裝飾器分爲兩種,分別是“函數裝飾器”和“類裝飾器”,其中又以“函數裝飾器”最爲常見,“類裝飾器”則用得很少。

3.1 函數裝飾器

3.1.1 大體結構

對裝飾函數的定義大致可以總結爲如圖所示的模板,即:

裝飾函數模板示意圖.png

由於要求裝飾函數返回值也爲一個函數的緣故,爲了在原函數的基礎上對功能進行擴充,並且使得擴充的功能能夠以函數的形式返回,因此需要在裝飾函數的定義中再定義一個內部函數,在這個內部函數中進一步操作。最後return的對象就應該是這個內部函數對象,也只有這樣才能夠正確地返回一個附加了新功能的函數。

如圖一的動圖所示,裝飾函數就像一個“包裝”,將原函數裝在了裝飾函數的內部,從而通過在原函數的基礎上附加功能實現了擴展,裝飾函數再將這個新的整體返回。同時對於原函數本身又不會有影響。這也是“裝飾”二字的含義。

這個地方如果不定義“內部函數”行不行呢?

答案是“不行”。

3.1.2 關於結構的解釋

讓我們來看看下面這段代碼:

>>> def IAmFakeDecorator(fun):
...     print("我是一個假的裝飾器")
...     return fun
...
>>> @IAmFakeDecorator
... def func():
...     print("我是原函數")
...
我是一個假的裝飾器

有點奇怪,怎麼剛一定義,裝飾器擴展的操作就執行了呢?

再來調用一下新函數:

>>> func()
我是原函數

誒呦奇了怪了,擴展功能哪兒去了呀?

不要着急,我們來分析一下上面的代碼。在裝飾函數的定義中,我們沒有另外定義一個內部函數,擴展操作直接放在裝飾函數的函數體中,返回值就是傳入的原函數。

在定義新函數的時候,下面兩段代碼又是等價的:

>>> @IAmFakeDecorator
... def func():
...     print("我是原函數")
...
我是一個假的裝飾器

>>> def func():
...     print("我是原函數")
...
>>> func = IAmFakeDecorator(func)
我是一個假的裝飾器

審視一下後一段代碼,我們可以發現,裝飾器只在定義新函數的同時調用一次,之後新函數名引用的對象就是裝飾器的返回值了,與裝飾器沒有半毛錢關係。

換句話說,裝飾器本身的函數體中的操作都是當且僅當函數定義時,纔會執行一次,以後再以新函數名調用函數,執行的只會是內部函數的操作。所以到實際調用新函數的時候,得到的效果跟原函數沒有任何區別。

如果不定義內部函數,單純返回傳入的原函數當然也是可以的,也符合裝飾器的要求;但卻得不到我們預期的結果,對原函數擴展的功能無法複用,只是一次性的。因此這樣的行爲沒有任何意義。

這個在裝飾函數內部定義的用於擴展功能的函數可以隨意取名,但一般約定俗成命名爲wrapper,即“包裝”之意。

正確的裝飾器定義應如下所示:

>>> def IAmDecorator(fun):
...     def wrapper(*args, **kw):
...         print("我真的是一個裝飾器")
...         return fun(*args, **kw)
...     return wrapper
...

3.1.3 參數設置的問題

內部函數參數設置爲(*args, **kw)的目的是可以接收任意參數,關於如何接收任意參數的內容在前面的函數參數部分已經介紹過。

之所以要讓wrapper能夠接收任意參數,是因爲我們在定義裝飾器的時候並不知道會用來裝飾什麼函數,具體函數的參數又是什麼情況;定義爲“可以接收任意參數”能夠極大增強代碼的適應性。

另外,還要注意給出參數的位置。

要明確一個概念:除了函數頭的位置,其他地方一旦給出了函數參數,表達式的含義就不再是“一個函數對象”,而是“一次函數調用”。

因此,我們的裝飾器目的是返回一個函數對象,返回語句的對象一定是不帶參數的函數名;在內部函數中,我們是需要對原函數進行調用,因此需要帶上函數參數,否則,如果內部函數的返回值還是一個函數對象,就還需要再給一組參數才能夠調用原函數。Show code:

>>> def IAmDecorator(fun):
...     def wrapper(*args, **kw):
...         print("我真的是一個裝飾器")
...         return fun
...     return wrapper
...
>>> @IAmDecorator
... def func(h):
...     print("我是原函數")
...
>>> func()
我真的是一個裝飾器
<function func at 0x000001FF32E66950>

原函數沒有被成功調用,只是得到了原函數對應的函數對象。只有進一步給出了下一組參數,才能夠發生正確的調用(爲了演示參數的影響,在函數func的定義中增加了一個參數h):

>>> func()(h=1)
我真的是一個裝飾器
我是原函數

只要明白了帶參數和不帶參數的區別,並且知道你想要的到底是什麼效果,就不會在參數上犯錯誤了。並且也完全不必拘泥上述規則,也許你要的就是一個未經調用的函數對象呢?

把握住這一點,嵌套的裝飾器、嵌套的內部函數這些也就都不是問題了。

3.1.4 函數屬性

本小節內容啓發於廖雪峯的官方網站-Python 教程-函數式編程-裝飾器

還應注意的是,經過裝飾器的修飾,原函數的屬性也發生了改變。

>>> def func():
...     print("我是原函數")
...
>>> func.__name__
'func'

正常來說,定義一個函數,其函數名稱與對應的變量應該是一致的,這樣在一些需要以變量名標識、索引函數對象時才能夠避免不必要的問題。但是事情並不是那麼順利:

>>> @IAmDecorator
... def func():
...     print("我是原函數")
...
>>> func.__name__
'wrapper'

變量名還是那個變量名,原函數還是那個原函數,但是函數名稱卻變成了裝飾器中內部函數的名稱。

在這裏我們可以使用 Python 內置模塊functools中的wraps工具,實現“在使用裝飾器擴展函數功能的同時,保留原函數屬性”這一目的。這裏functools.wraps本身也是一個裝飾器。運行效果如下:

>>> import functools
>>> # 定義保留原函數屬性的裝飾器
... def IAmDecorator(fun):
...     @functools.wraps(fun)
...     def wrapper(*args, **kw):
...         print("我真的是一個裝飾器")
...         return fun(*args, **kw)
...     return wrapper
...
>>> @IAmDecorator
... def func():
...     print("我是原函數")
...
>>> func.__name__
'func'

大功告成!

3.2 類裝飾器

本節部分參考[Python3 文檔-複合語句-類定義]和[python 一篇文章搞懂裝飾器所有用法]中類裝飾器相關部分

類裝飾器的概念與函數裝飾器類似,使用上語法也差不多:

@ClassDecorator
class Foo:
    pass

等價於

class Foo:
    pass
Foo = ClassDecorator(Foo)

在定義類裝飾器的時候,要保證類中存在__init____call__兩種方法。其中__init__方法用以接收原函數或類,__call__方法用以實現裝飾邏輯。

簡單來講,__init__方法負責在初始化類實例的時候,將傳入的函數或類綁定到這個實例上;而__call__方法則與一般的函數裝飾器差不多,連構造都沒什麼兩樣,可以認爲__call__方法就是一個函數裝飾器,因此不再贅述。

3.3 多個裝飾器的情況

多個裝飾器可以嵌套,具體情況可以理解爲從下往上結合的複合函數;或者也可以理解爲下一個裝飾器的值是前一個裝飾器的參數。

舉例來說,下面兩段代碼是等價的:

@f1(arg)
@f2
def func(): 
    pass

def func(): 
    pass
func = f1(arg)(f2(func))

理解了前面的內容,這種情況也很容易掌握。

4. 總結

本文介紹了 Python 中的裝飾器這一特性,詳細講解了裝飾器的實際原理和使用方式,能夠大大幫助學習者掌握有關裝飾器的知識,減小讀懂 Python 代碼的阻力,寫出更加 pythonic 的代碼。

參考資料

[1] Python3 術語表-裝飾器

[2] Python3 文檔-複合語句-函數定義

[3] Python3 文檔-複合語句-類定義

[4] 語法糖

[5] 廖雪峯的官方網站-Python 教程-函數式編程-裝飾器

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章