文章目錄
- 7.1 裝飾器基礎知識
- 7.2 Python何時執行裝飾器
- 7.3 使用裝飾器改進“策略”
- 7.4 變量作用域(global)
- 備注 -比較字節碼(暫略)
- 7.5 閉包
- 7.6 nonlocal聲明
- global和nonlocal的區別
- 7.7 實現一個簡單的裝飾器
- 7.8 標準庫中的裝飾器
- 7.8.1 使用functools.lru_cache做備忘
- 補充 @functools.lru_cache()可以配置參數
- 7.8.2 單分派泛函數( @functools.singledispatch)
- 7.9 疊放裝飾器
- 7.10 參數化裝飾器
- 7.10.1 一個參數化的注冊裝飾器
- 7.10.2 參數化clock裝飾器
函數裝飾器用于在源碼中“標記”函數,以某種方式增強函數的行為。這是一項強大的功能,但是若想掌握,必須理解閉包。
nonlocal 是新近出現的保留關鍵字,在 Python 3.0 中引入。
除了在裝飾器中有用處之外,閉包還是 回調式異步編程和 函數式編程風格的基礎。
本章的最終目標是解釋清楚函數裝飾器的工作原理,包括最簡單的 注冊裝飾器和較復雜的 參數化裝飾器。但是,在實現這一目標之前,我們要討論下述話題:
- Python 如何計算裝飾器句法
- Python 如何判斷變量是不是局部的
- 閉包存在的原因和工作原理
- nonlocal 能解決什么問題
掌握這些基礎知識后,我們可以進一步探討裝飾器: - 實現行為良好的裝飾器
- 標準庫中有用的裝飾器
- 實現一個參數化裝飾器
7.1 裝飾器基礎知識
裝飾器是可調用的對象,其參數是另一個函數(被裝飾的函數)。 裝飾器可能會處理被裝飾的函數,然后把它返回,或者將其替換成另一個函數或可調用對象。
假如有個名為 decorate 的裝飾器:
@decorate
def target():
print('running target()')
上述代碼的效果與下述寫法一樣:
def target():
print('running target()')
target = decorate(target)
兩種寫法的最終結果一樣:上述兩個代碼片段執行完畢后得到的 target 不一定是原來那
個 target 函數,而是 decorate(target) 返回的函數。
為了確認被裝飾的函數會被替換,請看示例 7-1 中的控制臺會話。
示例 7-1 裝飾器通常把函數替換成另一個函數
>>> def deco(func):
... def inner():
... print('running inner()')
... return inner
...
>>> @deco
... def target():
... print('running target()')
...
>>> target()
running inner()
>>> target
<function deco.<locals>.inner at 0x10063b598>
嚴格來說,裝飾器只是語法糖。如前所示,裝飾器可以像常規的可調用對象那樣調用,其
參數是另一個函數。有時,這樣做更方便,尤其是做元編程(在運行時改變程序的行
為)時。
綜上,裝飾器的一大特性是,能把被裝飾的函數替換成其他函數。第二個特性是,裝飾器
在加載模塊時立即執行。
7.2 Python何時執行裝飾器
裝飾器的一個關鍵特性是,它們在被裝飾的函數定義之后立即運行。這通常是在導入時
(即 Python 加載模塊時)
registry = []
def register(func):print('running register(%s)' % func)registry.append(func)return func
@register
def f1():print('running f1()')
@register
def f2():print('running f2()')
def f3():print('running f3()')
def main():print('running main()')print('registry ->', registry) # 發現registry這個數組并不是空f1()f2()f3()
if __name__=='__main__':main()
輸出后是什么樣子呢?
running register(<function f1 at 0x7ff079e400d0>)
running register(<function f2 at 0x7ff06c9e37b8>)
running main()
registry -> [<function f1 at 0x7ff079e400d0>, <function f2 at 0x7ff06c9e37b8>]
running f1()
running f2()
running f3()
注意:在調用f1()和f2()時,輸出的是 runnint f1()和running f2()。
上面的例子主要是強調:函數裝飾器在導入模塊時立即執行,而被裝飾的函數只在明確調用時運行。這突出了 Python 程序員所說的導入時和運行時之間的區別。
考慮到裝飾器在真實代碼中的常用方式,示例 7-2 有兩個不尋常的地方。裝飾器函數與被裝飾的函數在同一個模塊中定義。實際情況是,裝飾器通常在一個模塊中定義,然后應用到其他模塊中的函數上。
register 裝飾器返回的函數與通過參數傳入的相同。實際上,大多數裝飾器會在內部定義一個函數,然后將其返回。
雖然上示例中的 register 裝飾器原封不動地返回被裝飾的函數,但是這種技術并非沒有用處。很多 Python Web 框架使用這樣的裝飾器把函數添加到某種中央注冊處,例如把URL模式映射到生成 HTTP 響應的函數上的注冊處。這種注冊裝飾器可能會也可能不會修改被裝飾的函數。
7.3 使用裝飾器改進“策略”
使用注冊裝飾器可以改進之前的第六章中的電商促銷折扣示例。
回顧一下,示例 6-6 的主要問題是,定義體中有函數的名稱,但是 best_promo 用來判斷哪個折扣幅度最大的 promos 列表中也有函數名稱。這種重復是個問題,因為新增策略函數后可能會忘記把它添加到 promos 列表中,導致 best_promo 忽略新策略,而且不報錯,為系統引入了不易察覺的缺陷。以下這個例子使用注冊裝飾器解決了這個問題。
-
promos 列表中的值使用 promotion 裝飾器
from collections import namedtupleCustomer = namedtuple('Customer', 'name fidelity')class LineItem:def __init__(self, product, quantity, price):self.product = productself.quantity = quantityself.price = pricedef total(self):return self.price * self.quantitypromos =[]def promotion(promo_func):promos.append(promo_func)return promo_func@promotiondef fidelity(order):"""為積分為1000或者以上的顧客提供5%折扣"""return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0@promotiondef bulk_item(order):"""單個商品為20個或者以上時提供10%折扣"""discount = 0for item in order.cart:if item.quantity >= 20:discount += item.total() * 0.01return discount@promotiondef large_order(order):"""訂單中的不同商品達到10個以上時提供7%折扣"""distinct_items = {item.product for item in order.cart}if len(distinct_items) >= 10:return order.total() * 0.07return 0def best_promo(order):"""選擇可用的最佳折扣"""return max(promo(order) for promo in promos)class Order: #上下文def __init__(self, customer, cart, promotion=None):self.customer = customerself.cart = list(cart)self.promotion = promotiondef total(self):if not hasattr(self, '__total'):self.__total = sum(item.total() for item in self.cart)return self.__totaldef due(self):if self.promotion is None:discount = 0else:discount = self.promotion(self)return self.total() - discountdef __repr__(self):fmt = '<Order total: {:.2f} due: {:.2f}>'return fmt.format(self.total(), self.due())if __name__ == '__main__':joe = Customer('John Doe', 0)ann = Customer('Ann Smith', 1000)cart = [LineItem('banana', 4, 0.5),LineItem('apple', 10, 1.5),LineItem('watermellon', 5, 5.0)]print(Order(joe, cart, fidelity)) # <Order total: 42.00 due: 42.00>print(Order(ann, cart, fidelity)) #<Order total: 42.00 due: 39.90>
與 6.1 節給出的方案相比,這個方案有幾個優點。
- 促銷策略函數無需使用特殊的名稱(即不用以 _promo 結尾)。
- @promotion 裝飾器突出了被裝飾的函數的作用,還便于臨時禁用某個促銷策略:只需把裝飾器注釋掉。
- 促銷折扣策略可以在其他模塊中定義,在系統中的任何地方都行,只要使用@promotion 裝飾即可。
不過,多數裝飾器會修改被裝飾的函數。通常,它們會定義一個內部函數,然后將其返
回,替換被裝飾的函數。使用內部函數的代碼幾乎都要靠閉包才能正確運作。
7.4 變量作用域(global)
在示例 7-4 中,我們定義并測試了一個函數,它讀取兩個變量的值:一個是局部變量 a,
是函數的參數;另一個是變量 b,這個函數沒有定義它。
示例 7-4 一個函數,讀取一個局部變量和一個全局變量
>>> def f1(a):
... print(a)
... print(b)
...
>>> f1(3)
3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in f1
NameError: global name 'b' is not defined
出現錯誤并不奇怪。 在示例 7-4 中,如果先給全局變量 b 賦值,然后再調用 f,那就不
會出錯。
示例 7-5 b 是局部變量,因為在函數的定義體中給它賦值了
>>> b = 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) 語句執行了。但是第二個語句 print(b) 執行不了。一開始我很吃驚,我覺得會打印 6,因為有個全局變量 b,而且是在 print(b) 之后為局部變量 b 賦值的。
可事實是,Python 編譯函數的定義體時,它判斷 b 是局部變量,因為在函數中給它賦值了。生成的字節碼證實了這種判斷,Python 會嘗試從本地環境獲取 b。后面調用 f2(3)時, f2 的定義體會獲取并打印局部變量 a 的值,但是嘗試獲取局部變量 b 的值時,發現b 沒有綁定值。
這不是缺陷,而是設計選擇:Python 不要求聲明變量,但是假定在函數定義體中賦值的變量是局部變量。這比 JavaScript 的行為好多了,JavaScript 也不要求聲明變量,但是如果忘記把變量聲明為局部變量(使用 var),可能會在不知情的情況下獲取全局變量。
如果在函數中賦值時想讓解釋器把 b 當成全局變量,要使用 global 聲明:
>>> b = 6
>>> def f3(a):
... global b
... print(a)
... print(b)
... b = 9
...
>>> f3(3)
3
6
>>> b
9
>>> f3(3)
3
9
>>> b = 30
>>> b
30
>>>
了解 Python 的變量作用域之后,下一節可以討論閉包了。如果好奇示例 7-4 和示例 7-5 中的兩個函數生成的字節碼有什么區別,可以下面的備注:
備注 -比較字節碼(暫略)
7.5 閉包
在博客圈,人們有時會把閉包和匿名函數弄混。這是有歷史原因的:在函數內部定義函數不常見,直到開始使用匿名函數才會這樣做。而且,只有涉及嵌套函數時才有閉包問題。
因此,很多人是同時知道這兩個概念的。
其實,閉包指延伸了作用域的函數,其中包含函數定義體中引用、但是不在定義體中定義的非全局變量。函數是不是匿名的沒有關系,關鍵是它能訪問定義體之外定義的非全局變量。
這個概念難以掌握,最好通過示例理解。
假如有個名為 avg 的函數,它的作用是計算不斷增加的系列值的均值;例如,整個歷史中某個商品的平均收盤價。每天都會增加新價格,因此平均值要考慮至目前為止所有的價格。
示例 7-8 average_oo.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)
avg=Averager()
print(avg(10)) #10.0
print(avg(11)) #10.5
print(avg(12)) #11.0
Averager 的實例是可調用對象
示例 7-9 是函數式實現,使用高階函數 make_averager。
示例 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 時,它會
把參數添加到系列值中,然后計算當前平均值,如示例 7-10 所示。
示例 7-10 測試示例 7-9
>>> 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。不管怎樣,我們都只需調用 avg(n),把 n 放入系列值中,然后重新計算均值。
Averager 類的實例 avg 在哪里存儲歷史值很明顯:self.series 實例屬性。但是第二個示例中的 avg 函數在哪里尋找 series 呢?
注意,series 是 make_averager 函數的局部變量,因為那個函數的定義體中初始化了series:series = []。可是,調用 avg(10) 時,make_averager 函數已經返回了,而它的本地作用域也一去不復返了。
在 averager 函數中,series 是自由變量(free variable)。這是一個技術術語,指未在本地作用域中綁定的變量。
averager 的閉包延伸到那個函數的作用域之外,包含自由變量 series 的綁定。
綜上,閉包是一種函數,它會保留定義函數時存在的自由變量的綁定,這樣調用函數時,雖然定義作用域不可用了,但是仍能使用那些綁定。
注意,只有嵌套在其他函數中的函數才可能需要處理不在全局作用域中的外部變量。
7.6 nonlocal聲明
前面實現 make_averager 函數的方法效率不高。在示例 7-9 中,我們把所有值存儲在歷史數列中,然后在每次調用 averager 時使用 sum 求和。更好的實現方式是,只存儲目前的總值和元素個數,然后使用這兩個數計算均值。
示例 7-13 中的實現有缺陷,只是為了闡明觀點。你能看出缺陷在哪兒嗎?
示例 7-13 計算移動平均值的高階函數,不保存所有歷史值,但有缺陷
def make_averager():
count = 0
total = 0def averager(new_value):count += 1total += new_valuereturn total / count
return averager
Python 3 引入了 nonlocal 聲明。它的作用是把變量標記為自由變量,即使在函數中為變量賦予新值了,也會變成自由變量。如果為 nonlocal 聲明的變量賦予新值,閉包中保存的綁定會更新。
示例 7-14 計算移動平均值,不保存所有歷史(使用 nonlocal 修正)
def make_averager():
count = 0
total = 0def averager(new_value):nonlocal count, totalcount += 1total += new_valuereturn total / count
return averager
global和nonlocal的區別
global 表示將變量聲明為全局變量
nonlocal 表示將變量聲明為外層變量(外層函數的局部變量,而且不能是全局變量)。
-
- global關鍵字用來在函數或其他局部作用域中使用全局變量。但是如果不修改全局變量也可以不使用global關鍵字。
-
-
聲明全局變量,如果在局部要對全局變量修改,需要在局部也要先聲明該全局變量。
gcount = 0def global_test():global gcountgcount +=1print (gcount)global_test()
-
-
3.在局部如果不聲明全局變量,并且不修改全局變量。則可以正常使用全局變量:
gcount = 0def global_test():print(gcount)global_test()
-
-
nonlocal關鍵字用來在函數或其他作用域中使用外層(非全局)變量
def make_counter(): count = 0 def counter(): nonlocal count count += 1 return count return counter def make_counter_test(): mc = make_counter() print(mc())print(mc())print(mc())make_counter_test()
-
7.7 實現一個簡單的裝飾器
定義了一個裝飾器,它會在每次調用被裝飾的函數時計時,然后把經過的時間、傳入的參數和調用的結果打印出來。
import time
def clock(func):def clocked(*args):t0=time.perf_counter()result = func(*args)elapsed = time.perf_counter()name = func.__name__arg_str = ','.join(repr(arg) for arg in args)print('[%0.8fs] %s(%s)-> %r' % (elapsed, name, arg_str, result))return resultreturn clocked@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))
這是裝飾器的典型行為:把被裝飾的函數替換成新函數,二者接受相同的參數,而且(通常)返回被裝飾的函數本該返回的值,同時還會做些額外操作。
將上述例子改進一下:
7.8 標準庫中的裝飾器
Python 內置了三個用于裝飾方法的函數:property、classmethod 和
staticmethod。另一個常見的裝飾器是 functools.wraps,它的作用是協助構建行為良好的裝飾器。我
們在示例 7-17 中用過。標準庫中最值得關注的兩個裝飾器是 lru_cache 和全新的
singledispatch(Python 3.4 新增)。這兩個裝飾器都在 functools 模塊中定義。
7.8.1 使用functools.lru_cache做備忘
示例 7-18 生成第 n 個斐波納契數,遞歸方式非常
import timedef clock(func):def clocked(*args):print(*args)t0 = time.perf_counter()result = func(*args)elapsed = time.perf_counter()name = func.__name__arg_str = ','.join(repr(arg) for arg in args)print('[%0.8fs] %s(%s)-> %r' % (elapsed, name, arg_str, result))return resultreturn clocked@clock
def fibonacci(n):return n if n < 2 else fibonacci(n-2)+fibonacci(n-1)if __name__ == '__main__':print(fibonacci(6))
示例 7-19 使用緩存實現,速度
import time
import functools
def clock(func):def clocked(*args):print(*args)t0 = time.perf_counter()result = func(*args)elapsed = time.perf_counter()name = func.__name__arg_str = ','.join(repr(arg) for arg in args)print('[%0.8fs] %s(%s)-> %r' % (elapsed, name, arg_str, result))return resultreturn clocked@functools.lru_cache() #注意,必須像常規函數那樣調用 lru_cache。這一行中有一對括號:@functools.lru_cache()。這么做的原因是,lru_cache 可以接受配置參數
@clock
def fibonacci(n):return n if n < 2 else fibonacci(n-2)+fibonacci(n-1)if __name__ == '__main__':print(fibonacci(6))
補充 @functools.lru_cache()可以配置參數
7.8.2 單分派泛函數( @functools.singledispatch)
7.9 疊放裝飾器
示例 7-19 演示了疊放裝飾器的方式:@lru_cache 應用到 @clock 裝飾 fibonacci 得到的結果上。在示例 7-21 中,模塊中最后一個函數應用了兩個 @htmlize.register 裝飾器。
把 @d1 和 @d2 兩個裝飾器按順序應用到 f 函數上,作用相當于 f = d1(d2(f))。
也就是說,下述代碼:
@d1
@d2
def f():print('f')
等同于:
def f():print('f')f = d1(d2(f))
除了疊放裝飾器之外,本章還用到了幾個接受參數的裝飾器,
7.10 參數化裝飾器
解析源碼中的裝飾器時,Python 把被裝飾的函數作為第一個參數傳給裝飾器函數。那怎么讓裝飾器接受其他參數呢?答案是:創建一個裝飾器工廠函數,把參數傳給它,返回一個裝飾器,然后再把它應用到要裝飾的函數上。
registry = []
def register(func):print('running register(%s)' % func)registry.append(func)return func
@register
def f1():print('running f1()')
print('running main')
print('registry ->',registry)
f1()
7.10.1 一個參數化的注冊裝飾器
為了便于啟用或禁用 register 執行的函數注冊功能,我們為它提供一個可選的 active參數,設為 False 時,不注冊被裝飾的函數。實現方式參見下面這個例子。從概念上看,這個新的 register 函數不是裝飾器,而是裝飾器工廠函數。調用它會返回真正的裝飾器,這才是應用到目標函數上的裝飾器。
registry = []
def register(active=True):def decorate(func):print('running register(active=%s)->decorate(%s)'%(active, func))if active:registry.add(func)else:registry.discard(func)return func #decorate 是裝飾器,必須返回一個函數return decorate # register 是裝飾器工廠函數,因此返回 decorate
@register(active=True)
def f1():print('running f1()')
@register() #即使不傳入參數,register 也必須作為函數調用(@register()),即要返回真正的裝飾器 decorate。
def f2():print('running f2()')
def f3():print('running f3()')
如果不使用 @ 句法,那就要像常規函數那樣使用 register;若想把 f 添加到 registry中,則裝飾 f 函數的句法是 register()(f);不想添加(或把它刪除)的話,句法是register(active=False)(f)。
參數化裝飾器的原理相當復雜,我們剛剛討論的那個比大多數都簡單。參數化裝飾器通常會把被裝飾的函數替換掉,而且結構上需要多一層嵌套。
7.10.2 參數化clock裝飾器
本節再次探討 clock 裝飾器,為它添加一個功能:讓用戶傳入一個格式字符串,控制被裝飾函數的輸出。
import time
DEFAULT_FMT=’[{elapsed:0.8f}s] {name}({args})-> {result}’
def clock(fmt=DEFAULT_FMT):def decorate(func):def clocked(*_args):t0 = time.time()_result= func(*_args)elapsed=time.time-t0name = func._name__args = ','.join(repr(arg) for arg in _args)result = repr(_result)print(fmt.format(**locals()))return _resultreturn clockedreturn decorate
if __name__ == '__main__':@clockdef snooze(seconds):time.sleep(seconds)for i in range(3):snooze(.123)