程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式師世界 >> 編程語言 >  >> 更多編程語言 >> Python

[Python] 函數裝飾器和閉包

編輯:Python

《流暢的Python》盧西亞諾·拉馬略 第7章 函數裝飾器和閉包  讀書筆記

目錄

7.1 裝飾器基礎知識

7.2 Python何時執行裝飾器

7.3 使用裝飾器改進“策略”模式

7.4 變量作用域規則

7.5 閉包

7.6 nonlocal聲明

7.7 實現一個簡單的裝飾器

7.8 標准庫中的裝飾器

7.8.1 使用functools.lru_cache做備忘

7.9 疊放裝飾器

7.10 參數化裝飾器

7.10.1 一個參數化的注冊裝飾器

7.10.2 參數化clock裝飾器


本章首先要討論下述話題:
Python 裝飾器句法
Python 如何判斷變量是不是局部的 Ch7.4
閉包存在的原因和工作原理 Ch7.5
nonlocal 能解決什麼問題 Ch7.6

掌握這些基礎知識後,我們可以進一步探討裝飾器(有點難,先把能明白的部分簡單記錄):
實現行為良好的裝飾器
標准庫中有用的裝飾器
實現一個參數化裝飾器

7.1 裝飾器基礎知識

裝飾器是可調用的對象,其參數是另一個函數(被裝飾的函數)。裝飾器可能會
- 處理被裝飾的函數,然後把它返回
- 將被裝飾的函數替換成另一個函數或可調用對象

假如有個名為 decorate 的裝飾器:

@decorate
def target():
    print('running target()')

上述代碼的效果與下述寫法一樣:

def target():
    print('running target()')
target = decorate(target)

裝飾器的兩大特性:
-能把被裝飾的函數替換成其他函數
-裝飾器在加載模塊時立即執行

7.2 Python何時執行裝飾器

裝飾器的一個關鍵特性是,它們在被裝飾的函數定義之後立即運行。這通常是在導入時(即 Python 加載模塊時)。

示例 7-2 registration.py 模塊

以下示例主要想強調,函數裝飾器在導入模塊時立即執行,而被裝飾的函數只在明確調用時運行。這突出了 導入時和運行時之間的區別。

registry = [] #1
def register(func): #2
print('running register(%s)' % func) #3
registry.append(func) #4
return func #5
@register #6
def f1():
print('running f1()')
@register
def f2():
print('running f2()')
def f3(): #7
print('running f3()')
def main(): #8
print('running main()')
print('registry ->', registry)
f1()
f2()
f3()
if __name__=='__main__':
main() #9

(1)把 registration.py 當作腳本運行得到的輸出如下:

# python3 registration.py
running register(<function f1 at 0x7ff58ca96310>)
running register(<function f2 at 0x7ff58ca963a0>)
running main()
registry -> [<function f1 at 0x7ff58ca96310>, <function f2 at 0x7ff58ca963a0>]
running f1()
running f2()
running f3()

注意,register 在模塊中其他函數之前運行(兩次)。調用register 時,傳給它的參數是被裝飾的函數,例如 <function f1 at 0x7ff58ca96310>。

加載模塊後,registry 中有兩個被裝飾函數的引用:f1 和 f2。這兩個函數,以及 f3,只在 main 明確調用它們時才執行。

(2)如果導入 registration.py 模塊(不作為腳本運行),輸出如下:

>>> import registration
running register(<function f1 at 0x7f802bba35e0>)
running register(<function f2 at 0x7f802bba3670>)
>>>

此時查看 registry 的值,得到的輸出如下:

>>> registration.registry
[<function f1 at 0x7f802bba35e0>, <function f2 at 0x7f802bba3670>]
>>>

說明:上例中,
裝飾器函數與被裝飾的函數在同一個模塊中定義。實際上,裝飾器通常在一個模塊中定義,然後應用到其他模塊中的函數上。
register 裝飾器返回的函數與通過參數傳入的相同。實際上,大多數裝飾器會在內部定義一個函數,然後將其返回。裝飾器原封不動地返回被裝飾的函數,但是這種技術並非沒有用處。很多 Python Web 框架使用這樣的裝飾器把函數添加到某種中央注冊處,例如把 URL模式映射到生成 HTTP 響應的函數上的注冊處。這種注冊裝飾器可能會也可能不會修改被裝飾的函數。

7.3 使用裝飾器改進“策略”模式

使用注冊裝飾器可以改進 6.1 節中的電商促銷折扣示例。
示例 6-6 的主要問題是,定義體中有函數的名稱,但是best_promo 列表中也有函數名稱。新增策略函數後可能會忘記把它添加到promos 列表中,導致 best_promo 忽略新策略,而且不報錯,為系統引入了不易察覺的缺陷。

示例 7-3 promos 列表中的值使用 promotion 裝飾器填充

promos = [] #1
def promotion(promo_func): #2
promos.append(promo_func)
return promo_func
@promotion #3
def fidelity(order):
"""為積分為1000或以上的顧客提供5%折扣"""
return order.total() * .05 if order.customer.fidelity >= 1000 else 0
@promotion
def bulk_item(order):
"""單個商品為20個或以上時提供10%折扣"""
discount = 0
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * .1
return discount
@promotion
def large_order(order):
"""訂單中的不同商品達到10個或以上時提供7%折扣"""
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * .07
return 0
def best_promo(order): #4
"""選擇可用的最佳折扣"""
return max(promo(order) for promo in promos)

說明:

#1 promos 列表起初是空的。
#2 promotion 把 promo_func 添加到 promos 列表中,然後原封不動地將其返回。
#3 被 @promotion 裝飾的函數都會添加到 promos 列表中。
#4 best_promos 無需修改,因為它依賴 promos 列表。

與 6.1 節給出的方案相比,這個方案有幾個優點:
促銷策略函數無需使用特殊的名稱(即不用以 _promo 結尾)。
@promotion 裝飾器突出了被裝飾函數的作用,還便於禁用某個促銷策略:只需把裝飾器注釋掉。
促銷折扣策略可以在其他模塊中定義,只要使用 @promotion 裝飾即可。

7.4 變量作用域規則

示例 7-5 b 是局部變量,因為在函數的定義體中給它賦值了

>>> def f1(a):
... print(a)
... print(b)
...
>>> b = 6
>>> f1(3)
3
6
>>>
--------------------------------------
>>> def f2(a):
... print(a)
... print(b)
... b = 9
...
>>> f2(3)
3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in f2
UnboundLocalError: local variable 'b' referenced before assignment
>>>

首先輸出了 3,這表明 print(a) 語句執行了。
雖然有個全局變量 b,第二個語句print(b) 執行不了,局部變量 b是在 print(b) 之後賦值的。
可事實是,Python 編譯函數的定義體時,它判斷 b 是局部變量,因為在函數中給它賦值了。生成的字節碼證實了這種判斷,Python 會嘗試從本地環境獲取 b。後面調用 f2(3) 時, f2 的定義體會獲取並打印局部變量 a 的值,但是嘗試獲取局部變量 b 的值時,發現 b 沒有綁定值。

這是設計選擇:Python 不要求聲明變量,但是假定在函數定義體中賦值的變量是局部變量。
如果在函數中賦值時想讓解釋器把 b 當成全局變量,要使用 global 聲明

>>> b = 6
>>> def f3(a):
... global b
... print(a)
... print(b)
... b = 9
...
>>> f3(3)
3
6
>>>

7.5 閉包

閉包指延伸了作用域的函數,其中包含函數定義體中引用、但是不在定義體中定義的非全局變量。關鍵是它能訪問定義體之外定義的非全局變量

舉例說明 --> 假如有個名為 avg 的函數,它的作用是計算不斷增加的系列值的均值;
例如,整個歷史中某個商品的平均收盤價。每天都會增加新價格,因此平均值要考慮至目前為止所有的價格。
起初,avg 是這樣使用的:
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

示例 7-8 average0.py:計算移動平均值的類

class Averager():
def __init__(self):
self.series = []
def __call__(self, new_value):
self.series.append(new_value)
total = sum(self.series)
return total/len(self.series)

Averager 的實例是可調用對象:

>>> from average0 import Averager
>>> avg = Averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0
>>>

示例 7-9 average.py:計算移動平均值的高階函數

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

調用 make_averager 時,返回一個 averager 函數對象。每次調用averager 時,它會把參數添加到系列值中,然後計算當前平均值。

>>> from average import make_averager
>>> avg = make_averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0
>>>

說明:這兩個示例有共通之處:調用 Averager() 或make_averager() 得到一個可調用對象 avg,它會更新歷史值,然後計算當前均值。在示例 7-8 中,avg 是 Averager 的實例;在示例 7-9
中是內部函數 averager。

比較

示例 7-8示例 7-9共同點:得到一個可調用對象 avg調用Averager()調用make_averager()存儲歷史值Averager 類的實例 avg 在 self.series 實例屬性存儲歷史值series 是 make_averager 函數的局部變量,因為該函數的定義體中初始化了,調用 avg(10)時,make_averager 函數已經返回了,而它的本地作用域也一去不復返了。
怎麼存儲的??詳見後續 

在 averager 函數中,series 是自由變量(free variable)

自由變量:指未在本地作用域中綁定的變量

averager 的閉包延伸到那個函數的作用域之外,包含自由變量 series 的綁定

審查返回的 averager 對象,我們發現 Python 在 __code__ 屬性(表示編譯後的函數定義體)中保存局部變量和自由變量的名稱

>>> avg.__code__.co_varnames
('new_value', 'total')
>>> avg.__code__.co_freevars
('series',)
>>>

series 的綁定在返回的 avg 函數的 __closure__ 屬性中。avg.__closure__ 中的各個元素對應於avg.__code__.co_freevars 中的一個名稱。這些元素是 cell 對象,有個 cell_contents 屬性,保存著真正的值。

>>> avg.__closure__
(<cell at 0x7f802c0ba1c0: list object at 0x7f802bdf3ec0>,)
>>> avg.__closure__[0].cell_contents
[10, 11, 12]
>>>

綜上,閉包會保留定義函數時存在的自由變量的綁定,這樣調用函數時,雖然定義作用域不可用了,但是仍能使用那些綁定。注意,只有嵌套在其它函數中的函數才可能需要處理不在全局作用域中的外部變量

7.6 nonlocal聲明

前面實現 make_averager 函數的方法效率不高。更好的實現方式是,只存儲目前的總值和元素個數,然後使用這兩個數計算均值。

示例 7-13 計算移動平均值的高階函數,不保存所有歷史值(有缺陷)

>>> def make_averager():
... count = 0
... total = 0
... def averager(new_value):
... count += 1
... total += new_value
... return total / count
... return averager
...
>>> avg = make_averager()
>>> avg(10)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 5, in averager
UnboundLocalError: local variable 'count' referenced before assignment
>>>

問題在於,當 count 是數字或任何不可變類型時,count += 1 <=>  count = count + 1 因此,我們在 averager 的定義體中為 count 賦值了,這會把 count 變成局部變量(和示例7-5一個道理)。

示例 7-9 沒遇到這個問題,因為我們沒有給 series 賦值,只是調用 series.append,並把它傳給 sum 和 len。也就是利用了列表是可變的對象這一事實。

但是對數字、字符串、元組等不可變類型來說,只能讀取,不能更新。
如果嘗試重新綁定,例如 count = count + 1,其實會隱式創建局部變量 count。這樣,count 就不是自由變量了,因此不會保存在閉包中。

解決辦法 --> nonlocal 聲明

nonlocal 聲明的作用是把變量標記為自由變量。如果為 nonlocal 聲明的變量賦予新值,閉包中保存的綁定會更新。

示例 7-14 計算移動平均值,不保存所有歷史(使用 nonlocal 修正)

>>> def make_averager():
... count = 0
... total = 0
... def averager(new_value):
... nonlocal count, total
... count += 1
... total += new_value
... return total / count
... return averager
...
>>> avg = make_averager()
>>> avg(10)
10.0
>>> avg(8)
9.0
>>>

7.7 實現一個簡單的裝飾器

示例 7-15 輸出函數的運行時間、傳入的參數和調用的結果

定義一個裝飾器 clockdeco.py

import time
def clock(func):
def clocked(*args): #1
t0 = time.perf_counter()
result = func(*args) #2
elapsed = time.perf_counter() - t0
name = func.__name__
arg_str = ', '.join(repr(arg) for arg in args)
print('[%0.8fs] %s(%s) -> %r' % (elapsed, name, arg_str, result))
return result
return clocked #3

說明:     
#1 定義內部函數 clocked,它接受任意個定位參數。
#2 這行代碼可用,是因為 clocked 的閉包中包含自由變量 func。
#3 返回內部函數,取代被裝飾的函數。

clockdeco_demo.py

import time
from clockdeco import clock
@clock
def snooze(seconds):
time.sleep(seconds)
@clock
def factorial(n):
return 1 if n < 2 else n*factorial(n-1)
if __name__=='__main__':
print('*' * 40, 'Calling snooze(.123)')
snooze(.123)
print('*' * 40, 'Calling factorial(6)')
print('6! =', factorial(6))

執行結果:

# python3 clockdeco_demo.py
**************************************** Calling snooze(.123)
[0.12405594s] snooze(0.123) -> None
**************************************** Calling factorial(6)
[0.00000079s] factorial(1) -> 1
[0.00001218s] factorial(2) -> 2
[0.00001909s] factorial(3) -> 6
[0.00002585s] factorial(4) -> 24
[0.00003433s] factorial(5) -> 120
[0.00004931s] factorial(6) -> 720
6! = 720

clocked 大致做了下面幾件事
(1) 記錄初始時間 t0。
(2) 調用原來的 factorial 函數,保存結果。
(3) 計算經過的時間。
(4) 格式化收集的數據,然後打印出來。
(5) 返回第 2 步保存的結果。
這是裝飾器的典型行為:把被裝飾的函數替換成新函數,二者接受相同的參數,而且(通常)返回被裝飾的函數本該返回的值,同時還會做些額外操作。

示例 7-15 中實現的 clock 裝飾器有幾個缺點:不支持關鍵字參數,而且遮蓋了被裝飾函數的 __name__ 和 __doc__ 屬性。示例 7-17 使用functools.wraps 裝飾器把相關的屬性從 func 復制到 clocked 中。此外,這個新版還能正確處理關鍵字參數
示例 7-17 改進後的 clock 裝飾器

clockdeco.py

import time
import 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

-----------------------------

(以下記錄逐漸潦草)

7.8 標准庫中的裝飾器

標准庫中最值得關注的兩個裝飾器是 lru_cache 和全新的 singledispatch(Python 3.4 新增)。
這兩個裝飾器都在 functools 模塊中定義。

7.8.1 使用functools.lru_cache做備忘

functools.lru_cache 是非常實用的裝飾器,它實現了備忘(memoization)功能。這是一項優化技術,它把耗時的函數的結果保存起來,避免傳入相同的參數時重復計算。LRU 三個字母是"Least Recently Used"的縮寫,表明緩存不會無限制增長,一段時間不用的緩存條目會被扔掉。

示例 7-19 使用緩存實現,速度更快
 

import functools
from clockdeco import clock
@functools.lru_cache() #1
@clock #2
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-2) + fibonacci(n-1)
if __name__=='__main__':
    print(fibonacci(6))

運行結果:

# python3 test.py
[0.00000071s] fibonacci(0) -> 0
[0.00000112s] fibonacci(1) -> 1
[0.00009533s] fibonacci(2) -> 1
[0.00000108s] fibonacci(3) -> 2
[0.00010672s] fibonacci(4) -> 3
[0.00000094s] fibonacci(5) -> 5
[0.00011868s] fibonacci(6) -> 8
8

說明:
#1 注意,必須像常規函數那樣調用 lru_cache。這一行中有一對括號:@functools.lru_cache(),lru_cache 可以接受配置參數
#2 這裡疊放了裝飾器:@lru_cache() 應用到 @clock 返回的函數上

7.9 疊放裝飾器

把 @d1 和 @d2 兩個裝飾器按順序應用到 f 函數上,作用相當於 f = d1(d2(f))。
下述代碼:

@d1
@d2
def f():
    print('abcd')

等同於:

def f():
print('abcd')
f = d1(d2(f))

7.10 參數化裝飾器

Python 把被裝飾的函數作為第一個參數傳給裝飾器函數。
那怎麼讓裝飾器接受其他參數呢?
答案是:創建一個裝飾器工廠函數,把參數傳給它,返回一個裝飾器,然後再把它應用到要裝飾的函數上。

7.10.1 一個參數化的注冊裝飾器

為了便於啟用或禁用 register 執行的函數注冊功能,我們為它提供一個可選的 active 參數,設為 False 時,不注冊被裝飾的函數。
從概念上看,這個新的 register 函數不是裝飾器,而是裝飾器工廠函數。調用它會返回真正的裝飾器,這才是應用到目標函數上的裝飾器。
示例 7-23 為了接受參數,新的 register 裝飾器必須作為函數調用

registration_param.py

registry = set() #1
def register(active=True): #2
def decorate(func): #3
print('running register(active=%s)->decorate(%s)' % (active, func))
if active: #4
registry.add(func)
else:
registry.discard(func) #5
return func #6
return decorate #7
@register(active=False) #8
def f1():
print('running f1()')
@register() #9
def f2():
print('running f2()')
def f3():
print('running f3()')

執行結果如下

>>> import registration_param
running register(active=False)->decorate(<function f1 at 0x7efc9f385ca0>)
running register(active=True)->decorate(<function f2 at 0x7efc9f385dc0>)
>>>
>>> registration_param.registry
{<function f2 at 0x7efc9f385dc0>}
>>>

說明:
#1 registry 現在是一個 set 對象,這樣添加和刪除函數的速度更快。
#2 register 接受一個可選的關鍵字參數。
#3 decorate 這個內部函數是真正的裝飾器;注意,它的參數是一個函數。
#4 只有 active 參數的值(從閉包中獲取)是 True 時才注冊 func。
#5 如果 active 不為真,而且 func 在 registry 中,那麼把它刪除。
#6 decorate 是裝飾器,必須返回一個函數。
#7 register 是裝飾器工廠函數,因此返回 decorate。
#8 @register 工廠函數必須作為函數調用,並且傳入所需的參數。
#9 即使不傳入參數,register 也必須作為函數調用(@register()),即要返回真正的裝飾器 decorate。
這裡的關鍵是,register() 要返回 decorate,然後把它應用到被裝飾的函數上。

7.10.2 參數化clock裝飾器

為clock 裝飾器添加一個功能:讓用戶傳入一個格式字符串,控制被裝飾函數的輸出
簡單起見,示例 7-25 基於示例 7-15 中最初實現的clock
示例 7-25 clockdeco_param.py 模塊:參數化 clock 裝飾器

clockdeco_param.py

import time
DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}'
def clock(fmt=DEFAULT_FMT): #1
def decorate(func): #2
def clocked(*_args): #3
t0 = time.time()
_result = func(*_args) #4
elapsed = time.time() - t0
name = func.__name__
args = ', '.join(repr(arg) for arg in _args) #5
result = repr(_result) #6
print(fmt.format(**locals())) #7
return _result #8
return clocked #9
return decorate #10
if __name__ == '__main__':
@clock()
def snooze(seconds):
time.sleep(seconds)
for i in range(3):
snooze(.123)

說明:

#1 clock 是參數化裝飾器工廠函數。
#2 decorate 是真正的裝飾器。
#3 clocked 包裝被裝飾的函數。
#4 _result 是被裝飾的函數返回的真正結果。
#5 _args 是 clocked 的參數,args 是用於顯示的字符串。
#6 result 是 _result 的字符串表示形式,用於顯示。
#7 這裡使用 **locals() 是為了在 fmt 中引用 clocked 的局部變量。(??)
#8 clocked 會取代被裝飾的函數,因此它應該返回被裝飾的函數返回的值。
#9 decorate 返回 clocked。
#10 clock 返回 decorate。
#11 在這個模塊中測試,不傳入參數調用 clock(),因此應用的裝飾器使用默認的格式 str。

在 shell 中運行示例 7-25,會得到下述結果:

# python3 clockdeco_param.py
[0.12325597s] snooze(0.123) -> None
[0.12458444s] snooze(0.123) -> None
[0.12462711s] snooze(0.123) -> None

示例 7-26 clockdeco_param_demo1.py 傳入了字符串輸出格式

import time
from clockdeco_param import clock
@clock('{name}: {elapsed}s')
def snooze(seconds):
time.sleep(seconds)
for i in range(3):
snooze(.123)

執行結果如下:

# python3 clockdeco_param_demo1.py
snooze: 0.12509799003601074s
snooze: 0.12530088424682617s
snooze: 0.12441754341125488s

示例 7-26 clockdeco_param_demo2.py 傳入了另一種字符串輸出格式

import time
from clockdeco_param import clock
@clock('{name}({args}) dt={elapsed:0.3f}s')
def snooze(seconds):
time.sleep(seconds)
for i in range(3):
snooze(.123)

執行結果如下:

# python3 clockdeco_param_demo2.py
snooze(0.123) dt=0.125s
snooze(0.123) dt=0.124s
snooze(0.123) dt=0.124s


  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式師世界 All Rights Reserved