Python學習之路26-函式裝飾器和閉包

VPointer發表於2018-06-03

《流暢的Python》筆記

本篇將從最簡單的裝飾器開始,逐漸深入到閉包的概念,然後實現引數化裝飾器,最後介紹標準庫中常用的裝飾器。

1. 初步認識裝飾器

函式裝飾器用於在原始碼中“標記”函式,以某種方式增強函式的行為。裝飾器就是函式,或者說是可呼叫物件,它以另一個函式為引數,最後返回一個函式,但這個返回的函式並不一定是原函式。

1.1 裝飾器基礎用法

以下是裝飾器最基本的用法:

# 程式碼1
#裝飾器用法
@decorate
def target(): pass

# 上述程式碼等價於以下程式碼
def target(): pass
target = decorate(target)
複製程式碼

即,最終的target函式是由decorate(target)返回的函式。下面這個例子說明了這一點:

# 程式碼2
def deco(func):
    def inner():
        print("running inner()")
    return inner

@deco
def target():
    print("running target()")

target()
print(target)

# 結果
running inner() # 輸出的是裝飾器內部定義的函式的呼叫結果
<function deco.<locals>.inner at 0x000001AF32547D90>
複製程式碼

從上面可看出,裝飾器的一大特性是能把被裝飾的函式替換成其他函式。但嚴格說來,裝飾器只是語法糖(語法糖:在程式語言中新增某種語法,但這種語法對語言的功能沒有影響,只是更方便程式設計師使用)。

裝飾器還可以疊加。下面是一個說明,具體例子見後面章節:

# 程式碼3
@d1
@d2
def f(): pass

#上述程式碼等價於以下程式碼:
def f(): pass
f = d1(d2(f))
複製程式碼

1.2 Python何時執行裝飾器

裝飾器的另一個關鍵特性是,它在被裝飾的函式定義後立即執行,這通常是在匯入時,即Python載入模組時:

# 程式碼4
registry = []

def register(func):
    print("running register(%s)" % func)
    registry.append(func)
    return func

@register
def f1():
    print("running f1()")

def f2():
    print("running f2()")

if __name__ == "__main__":
    print("running in main")
    print("registry ->", registry)
    f1()
    f2()

# 結果
running register(<function f1 at 0x0000027745397840>)
running in main # 進入到主程式
registry -> [<function f1 at 0x0000027745397840>]
running f1()
running f2()
複製程式碼

裝飾器register在載入模組時就對f1()進行了註冊,所以當執行主程式時,列表registry並不為空。

函式裝飾器在匯入模組時立即執行,而被裝飾的函式只在明確呼叫時執行。 這突出了Python程式設計師常說的匯入時執行時之間的區別。

裝飾器在真實程式碼中的使用方式與程式碼4中有所不同:

  • 裝飾器和被裝飾函式一般不在一個模組中,通常裝飾器定義在一個模組中,然後應用到其他模組中的函式上;
  • 大多數裝飾器會在內部定義一個函式,然後將其返回。

程式碼4中的裝飾器原封不動地返回了傳入的函式。這種裝飾器並不是沒有用,正如程式碼4中的裝飾器的名字一樣,這類裝飾器常充當了註冊器,很多Web框架就使用了這種方法。下一小節也是該類裝飾器的一個例子。

1.3 使用裝飾器改進策略模式

上一篇中我們用Python函式改進了傳統的策略模式,其中,我們定義了一個promos列表來記錄有哪些具體策略,當時的做法是用globals()函式來獲取具體的策略函式,現在我們用裝飾器來改進這一做法:

# 程式碼5,對之前的程式碼進行了簡略
promos = []

def promotion(promo_func): # 只充當了註冊器
    promos.append(promo_func)
    return promo_func

@promotion
def fidelity(order): pass  

@promotion
def bulk_item(order): pass

@promotion
def large_order(order): pass

def best_promo(order):
    return max(promo(order) for promo in promos)
複製程式碼

該方案相比之前的方案,有以下三個優點:

  • 促銷策略函式無需使用特殊名字,即不用再以_promo結尾
  • @promotion裝飾器突出了被裝飾函式的作用,還便於臨時禁用某個促銷策略(只需將裝飾器註釋掉)
  • 促銷策略函式在任何地方定義都行,只要加上裝飾器即可。

2. 閉包

正如前文所說,多數裝飾器會在內部定義函式,並將其返回,已替換掉傳入的函式。這個機制的實現就要靠閉包,但在理解閉包之前,先來看看Python中的變數作用域。

2.1 變數作用域規則

通過下述例子來解釋區域性變數和全域性變數:

# 程式碼6
>>> def f1(a):
...     print(a)
...     print(b)
    
>>> f1(3)
3
Traceback (most recent call last):
  -- snip --
NameError: name 'b' is not defined
複製程式碼

當程式碼執行到print(a)時,Python查詢變數a,發現變數a存在於區域性作用域中,於是順利執行;當執行到print(b)時,python查詢變數b,發現區域性作用域中並沒有變數b,便接著查詢全域性作用域,發現也沒有變數b,最終報錯。正確的呼叫方式相信大家也知道,就是在呼叫f1(3)之前給變數b賦值。

我們再看如下程式碼:

# 程式碼7
>>> b = 6
>>> def f2(a):
...     print(a)
...     print(b)
...     b = 9
    
>>> f2(3)
3
Traceback (most recent call last):
  -- snip --
UnboundLocalError: local variable 'b' referenced before assignment
複製程式碼

按理說不應該報錯,並且b的值應該列印為6,但結果卻不是這樣。

事實是:變數b本來是全域性變數,但由於在f2()中我們為變數b賦了值,於是Python在區域性作用域中也註冊了一個名為b的變數(全域性變數b依然存在,有程式設計基礎的同學應該知道,這叫做“覆蓋”)。當Python執行到print(b)語句時,Python先搜尋區域性作用域,發現其中有變數b,但是b此時還沒有被賦值(全域性變數b被覆蓋,而區域性變數b的賦值語句在該句後面),於是Python報錯。

如果不想程式碼7報錯,則需要使用global語句,將變數b宣告為全域性變數:

# 程式碼8
>>> b = 6
>>> def f2(a):
...     global b
...     -- snip --
複製程式碼

2.2 閉包的概念

現在開始真正接觸閉包。閉包指延伸了作用域的函式,它包含函式定義體中引用,但不在定義體中定義的非全域性變數,即這類函式能訪問定義體之外的非全域性變數。只有涉及巢狀函式時才有閉包問題。

下面用一個例子來說明閉包以及非全域性變數。定義一個計算某商品一段時間內均價的函式avg,它的表現如下:

# 程式碼9
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0
複製程式碼

假定商品價格每天都在變化,因此需要一個變數來儲存這些值。如果用類的思想,我們可以定義一個可呼叫物件,把這些值存到內部屬性中,然後實現__call__方法,讓其表現得像函式;但如果按裝飾器的思想,可以定義一個如下的巢狀函式:

# 程式碼10
def make_averager():
    series = []

    def averager(new_value):
        series.append(new_value)
        total = sum(series)
        return total / len(series)

    return averager
複製程式碼

然後以如下方式使用這個函式:

# 程式碼11
>>> avg = make_averager()
>>> avg(10)
10.0
-- snip --
複製程式碼

不知道大家剛接觸這個內部的averager()函式時有沒有疑惑:程式碼11中,當執行avg(10)時,它是到哪裡去找的變數seriesseries是函式make_averager()的區域性變數,當make_averager()返回了averager()後,它的區域性作用域就消失了,所以按理說series也應該跟著消失,並且上述程式碼應該報錯才對。

事實上,在averager函式中,series自由變數(free variable),即未在區域性作用域中繫結的變數。這裡,自由變數series和內部函式averager共同組成了閉包,參考下圖:

Python學習之路26-函式裝飾器和閉包

實際上,Python在averager__code__屬性中儲存了區域性變數和自由變數的名稱,在__closure__屬性中儲存了自由變數的值:

# 程式碼12,注意這些變數的單詞含義,一目瞭然
>>> avg.__code__.co_varnames  # co_varnames儲存區域性變數的名稱
('new_value', 'total')
>>> avg.__code__.co_freevars # co_freevars儲存自由變數的名稱
('series',)
>>> avg.__closure__ # 單詞closure就是閉包的意思
# __closure__是一個cell物件列表,其中的元素和co_freevars元組一一對應
(<cell at 0x0000024EE023D7F8: list object at 0x0000024EDFE76288>,)
>>> avg.__closure__[0].cell_contents 
[10, 11, 12] # cell物件的cell_contents屬性才是真正儲存自由變數的值的地方
複製程式碼

綜上:閉包是一種函式,它會儲存定義函式時存在的自由變數的繫結,這樣呼叫函式時,雖然外層函式的區域性作用域不可用了,但仍能使用那些繫結。

注意:只有巢狀在其他函式中的函式才可能需要處理不在全域性作用域中的外部變數。

2.3 nonlocal宣告

程式碼10中的make_averager函式並不高效,因為如果只計算均值的話,其實不用儲存每次的價格,我們可按如下方式改寫程式碼10

# 程式碼13
def make_averager():
    count = 0
    total = 0

    def averager(new_value):
        count += 1
        total += new_value
        return total / count

    return averager
複製程式碼

但此時直接執行程式碼11的話,則會報程式碼7中的錯誤:UnboundLocalError

問題在於:由於count是不可變型別,在執行count += 1時,該語句等價於count = count + 1,而這就成了賦值語句,count不再是自由變數,而變成了averager的區域性變數。total也是一樣的情況。而在之前的程式碼10中沒有這個問題,因為series是個可變型別,我們只是呼叫series.append,以及把它傳給了sumlen,它並沒有變為區域性變數。

**對於不可變型別來說,只能讀取,不能更新,否則會隱式建立區域性變數。**為了解決這個問題,Python3引入了nonlocal宣告。它的作用是把變數顯式標記為自由變數:

# 程式碼14
def make_averager():
    count = 0
    total = 0

    def averager(new_value):
        nonlocal count, total
        -- snip --
複製程式碼

3. 裝飾器

瞭解了閉包後,現在開始正式使用巢狀函式來實現裝飾器。首先來認識標準庫中三個重要的裝飾器。

3.1 標準庫中的裝飾器

3.1.1 functools.wraps裝飾器

來看一個簡單的裝飾器:

# 程式碼15
def deco(func):
    def test():
        func()
    return test

@deco
def Test():
    """This is a test"""
    print("This is a test")

print(Test.__name__)
print(Test.__doc__)

# 結果
test
None
複製程式碼

我們想讓裝飾器來自動幫我們做一些額外的操作,但像改變函式屬性這樣的操作並不一定是我們想要的:從上面可以看出,Test現在指向了內部函式testTest自身的屬性被遮蓋。如果想保留函式原本的屬性,可以使用標準庫中的functools.wraps裝飾器。下面以一個更復雜的裝飾器為例,它會在每次呼叫被裝飾函式時計時,並將經過的時間,傳入的引數和呼叫的結果列印出來:

# 程式碼16
# clockdeco.py
import time, functools

def clock(func): # 兩層巢狀
    @functools.wraps(func)  # 繫結屬性
    def clocked(*args, **kwargs):
        t0 = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - t0
        name = func.__name__
        arg_lst = [] # 引數列表
        if args:
            arg_lst.append(", ".join(repr(arg) for arg in args))
        if kwargs:
            pairs = ["%s=%r" % (k, w) for k, w in sorted(kwargs.items())]
            arg_lst.append(", ".join(pairs))
        arg_str = ", ".join(arg_lst)
        print("[%0.8fs] %s(%s) -> %r" % (elapsed, name, arg_str, result))
        return result
    return clocked
複製程式碼

它的使用將和下一個裝飾器一起展示。

3.1.2 functools.lru_cache裝飾器

functools.lru_cache實現了備忘(memoization)功能,這是一項優化技術,他把耗時的函式的結果儲存起來,避免傳入相同引數時重複計算。以斐波那契函式為例,我們知道以遞迴形式實現的斐波那契函式會出現很多重複計算,此時,就可以使用這個裝飾器。以下程式碼是沒使用該裝飾器時的執行情況:

# 程式碼17
from clockdeco import clock

@clock
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 2) + fibonacci(n - 1)

if __name__ == "__main__":
    print(fibonacci.__name__)
    print(fibonacci.__doc__)
    print(fibonacci(6))

# 結果:
fibonacci  # fibonacci原本的屬性得到了保留
None
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(3) -> 2
[0.00000000s] fibonacci(4) -> 3
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(3) -> 2
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00049996s] fibonacci(2) -> 1
[0.00049996s] fibonacci(3) -> 2
[0.00049996s] fibonacci(4) -> 3
[0.00049996s] fibonacci(5) -> 5
[0.00049996s] fibonacci(6) -> 8
8
複製程式碼

可以看出,fibonacci(1)呼叫了8次,下面我們用functools.lru_cache來改進上述程式碼:

# 程式碼18
import functools
from clockdeco import clock

@functools.lru_cache()  # 注意此處有個括號!該裝飾器就收引數!不能省!
@clock
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 2) + fibonacci(n - 1)

if __name__ == "__main__":
    print(fibonacci(6))
    
# 結果:
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(3) -> 2
[0.00000000s] fibonacci(4) -> 3
[0.00000000s] fibonacci(5) -> 5
[0.00000000s] fibonacci(6) -> 8
8
複製程式碼

functools.lru_cache裝飾器可以接受引數,並且此程式碼還疊放了裝飾器。

lru_cache有兩個引數:functools.lru_cache(maxsize=128, typed=False)

  • maxsize指定儲存多少個呼叫的結果,該引數最好是2的冪。當快取滿後,根據LRU演算法替換快取中的內容,這也是為什麼這個函式叫lru_cache
  • type如果設定為True,它將把不同引數型別下得到的結果分開儲存,即把通常認為相等的浮點數和整數引數分開(比如區分1和1.0)。
  • lru_cache使用字典儲存結果,字典的鍵是傳入的引數,所以被lru_cache裝飾的函式的所有引數都必須是可雜湊的!

3.1.3 functools.singledispatch裝飾器

我們知道,C++支援函式過載,同名函式可以根據引數型別的不同而呼叫相應的函式。以Python程式碼為例,我們希望下面這個函式表現出如下行為:

# 程式碼19
def myprint(obj):
    return "Hello~~~"

# 以下是我們希望它擁有的行為:
>>> myprint(1)
Hello~~~
>>> myprint([])
Hello~~~
>>> myprint("hello") # 即,當我們傳入特定型別的引數時,函式返回特定的結果
This is a str
複製程式碼

單憑這一個myprint還無法實現上述要求,因為Python不支援方法或函式的過載。為了實現類似的功能,一種常見的做法是將函式變為一個分派函式,使用一串if/elif/elif來判斷引數型別,再呼叫專門的函式(如myprint_str),但這種方式不利於程式碼的擴充套件和維護,還顯得沒有B格。。。

為解決這個問題,從Python3.4開始,可以使用functools.singledispath裝飾器,把整體方案拆分成多個模組,甚至可以為無法修改的類提供專門的函式。被@singledispatch裝飾的函式會變成泛函式(generic function),它會根據第一個引數的不同而呼叫響應的專門函式,具體用法如下:

# 程式碼20
from functools import singledispatch
import numbers

@singledispatch
def myprint(obj):
    return "Hello~~~"

# 可以疊放多個register,讓同一函式支援不同型別
@myprint.register(str)
# 註冊的專門函式最好處理抽象基類,而不是具體實現,這樣程式碼支援的相容型別更廣泛
@myprint.register(numbers.Integral) 
def _(text): # 專門函式的名稱無所謂,使用 _ 可以避免起名字的麻煩
    return "Special types"
複製程式碼

對泛函式的補充:根據引數型別的不同,以不同方式執行相同操作的一組函式。如果依據是第一個引數,則是單分派;如果依據是多個引數,則是多分派。

3.2 引數化裝飾器

3.2.1 簡單版引數化裝飾器

從上面諸多例子我們可以看到兩大類裝飾器:不帶引數的裝飾器(呼叫時最後沒有括號)和帶引數的裝飾器(帶括號)。Python將被裝飾的函式作為第一個引數傳給了裝飾器函式,那裝飾器函式如何接受其他引數呢?做法是:建立一個裝飾器工廠函式,在這個工廠函式內部再定義其它函式作為真正的裝飾器。工廠函式代為接受引數,這些引數作為自由變數供裝飾器使用。然後工廠函式返回裝飾器,裝飾器再應用到被裝飾函式上。

我們把1.2中程式碼4@register裝飾器改為帶引數的版本,以active引數來指示裝飾器是否註冊某函式(雖然這麼做有點多餘)。這裡只給出@register裝飾器的實現,其餘程式碼參考程式碼4

# 程式碼21
registry = set()

def register(active=True):
    def decorate(func): # 變數active對於decorate函式來說是自由變數
        print("running register(active=%s)->decorate(%s)" % (active, func))
        if active: 
            registry.add(func)
        else:
            registry.discard(func)
        return func
    return decorate

# 用法
@register(active=False) # 即使不傳引數也要作為函式呼叫@register()
def f():pass

# 上述用法相當於如下程式碼:
# register(active=False)(f)
複製程式碼

3.2.2 多層巢狀版引數化裝飾器

引數化裝飾器通常會把被裝飾函式替換掉,而且結構上需要多一層巢狀。下面以3.1.1中程式碼16裡的@clock裝飾器為例,讓它按使用者要求的格式輸出資料。為了簡便,不呼叫functools.wraps裝飾器:

# 程式碼22
import time

DEFAULT_FMT = "[{elapsed:0.8f}s] {name}({args}) -> {result}"

def clock(fmt=DEFAULT_FMT):   # 裝飾器工廠,fmt是裝飾器的引數
    def decorate(func):       # 裝飾器
        def clocked(*_args):  # 最終的函式
            t0 = time.time()
            _result = func(*_args)
            elapsed = time.time() - t0
            name = func.__name__
            args = ", ".join(repr(arg) for arg in _args)
            result = repr(_result)
            print(fmt.format(**locals())) #locals()函式以字典形式返回clocked的區域性變數
            return _result
        return clocked
    return decorate
複製程式碼

可以得到如下結論:裝飾器函式有且只有一個引數,即被裝飾器的函式;如果裝飾器要接受其他引數,請在原本的裝飾器外再套一層函式(工廠函式),由它來接受其餘引數;而你最終使用的函式應該定義在裝飾器函式中,且它的引數列表應該和被裝飾的函式一致。

4. 總結

本篇首先介紹了最簡單裝飾器如何定義和使用,介紹了裝飾器在什麼時候被執行,以及用最簡單的裝飾器改造了上一篇的策略模式;隨後更進一步,介紹了與閉包相關的概念,包括變數作用域,閉包和nonlocal宣告;最後介紹了更復雜的裝飾器,包括標準庫中的裝飾器的用法,以及如何定義帶引數的裝飾器。

但上述對裝飾器的描述都是基本的, 更復雜、工業級的裝飾器還需要更深入的學習。


迎大家關注我的微信公眾號"程式碼港" & 個人網站 www.vpointer.net ~

Python學習之路26-函式裝飾器和閉包

相關文章