流暢的python——7 函式裝飾器和閉包
七、函式裝飾器和閉包
nonlocal
@decorate
def target():
print('running target()')
等同於
def target():
print('running target()')
target = decorate(target)
綜上,裝飾器的一大特性是,能把被裝飾的函式替換成其他函式。第二個特性是,裝飾器
在載入模組時立即執行。
裝飾器什麼時候執行
裝飾器的一個關鍵特性是,它們在被裝飾的函式定義之後立即執行。這通常是在匯入時(即 Python 載入模組時)
函式裝飾器在匯入模組時立即執行,而被裝飾的函式只在明確呼叫時執行。這突出了 Python 程式設計師所說的匯入時和執行時之間的區別。
一層裝飾器:將函式原封不動的返回,例子:策略裝飾器。
def register(func):
print('running register(%s)' % func)
registry.append(func)
return func
使用裝飾器改進策略模式
註冊裝飾器:將策略註冊到策略列表中。
promos = [] # 策略列表 def promotion(promo_func): # 策略裝飾器,一層裝飾器。用於新增策略到 策略列表 promos.append(promo_func) return promo_func @promotion 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): """選擇可用的最佳折扣""" return max(promo(order) for promo in promos)
1 促銷策略函式無需使用特殊的名稱(即不用以 _promo 結尾)。
2 @promotion 裝飾器突出了被裝飾的函式的作用,還便於臨時禁用某個促銷策略:只
需把裝飾器註釋掉。
3 促銷折扣策略可以在其他模組中定義,在系統中的任何地方都行,只要使用 @promotion 裝飾即可。
變數作用域規則
>>> 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
python 編譯函式的定義體時,它判斷 b 是區域性變數,因為在函式中給它賦值了,所以,print(b)
會從區域性環境獲取 b 。所以,報錯:在繫結值之前呼叫。
如果讓 b 為全域性變數:global b
即可
>>> b = 6
>>> def f3(a):
... global b
... print(a)
... print(b)
... b = 9
...
>>> f3(3)
36
>>> b
9
>>> f3(3)
39
>>> b = 30
>>> b
30
>>>
閉包
在部落格圈,人們有時會把閉包和匿名函式弄混。這是有歷史原因的:在函式內部定義函式不常見,直到開始使用匿名函式才會這樣做。而且,只有涉及巢狀函式時才有閉包問題。因此,很多人是同時知道這兩個概念的。
閉包指延伸了作用域的函式,其中包含函式定義體中引用、但是不在定義體中定義的非全域性變數。函式是不是匿名的沒有關係,關鍵是它能訪問定義體之外定義的非全域性變數。
注意,只有巢狀在其他函式中的函式才可能需要處理不在全域性作用域中的外部變數。
計算一系列數的累計的平局值:
方式一:類
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)
方式二:高階函式
def make_averager():
series = []
def averager(new_value):
series.append(new_value)
total = sum(series)
return total/len(series)
return averager
在 averager 函式中,series 是自由變數(free variable)。這是一個技術術語,指未在本地作用域中繫結的變數
審查 返回的 averager 物件:在 __code__
屬性(表示編譯後的函式定義體)
In [32]: def make_averager():
...: series = []
...: def avg(new_value):
...: series.append(new_value)
...: total = sum(series)
...: return total / len(series)
...: return avg
...:
In [33]: avg = make_averager()
In [34]: avg.__code__.co_varnames
Out[34]: ('new_value', 'total')
In [35]: avg.__code__.co_freevars
Out[35]: ('series',)
series 的繫結在返回的 avg 函式的 __closure__
屬性中。avg.__closure__
中的各個元素對應於 avg.__code__.co_freevars
中的一個名稱。這些元素是 cell 物件,有個 cell_contents 屬性,儲存著真正的值。
In [36]: avg.__closure__
Out[36]: (<cell at 0x00000230A826B5E8: list object at 0x00000230A7F7B488>,)
In [37]: avg.__closure__[0].cell_contents
Out[37]: []
In [38]: for i in avg.__closure__:
...: print(i)
<cell at 0x00000230A826B5E8: list object at 0x00000230A7F7B488>
In [40]: for i in avg.__closure__:
...: print(dir(i))
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'cell_contents']
綜上,閉包是一種函式,它會保留定義函式時存在的自由變數的繫結,這樣呼叫函式時,雖然定義作用域不可用了,但是仍能使用那些繫結。
nonlocal 宣告
avg 更好的思路:儲存總值和總個數。而不是每次 sum 求和。
def make_averager():
count = 0
total = 0
def averager(new_value):
count += 1
total += new_value
return total / count
return averager
但是貌似沒錯誤,其實,count ,total 會被識別為區域性變數。
>>> avg = make_averager()
>>> avg(10)
Traceback (most recent call last):
...
UnboundLocalError: local variable 'count' referenced before assignment
問題是,當 count 是數字或任何不可變型別時,count += 1 語句的作用其實與 count = count + 1 一樣。因此,我們在 averager 的定義體中為 count 賦值了,這會把 count 變成區域性變數,隱式建立區域性變數 count。total 變數也受這個問題影響。
之前的 series 是列表,append 操作,不是重新賦值。
為了解決這個問題,Python 3 引入了 nonlocal 宣告。它的作用是把變數標記為自由變數,即使在函式中為變數賦予新值了,也會變成自由變數。如果為 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
python2 沒有 nonlocal ,怎麼解決?
處理方式是把內部函式需要修改的變數(如 count 和 total)儲存為可變物件(如字典或簡單的例項)的元素或屬性,並且把那個物件繫結給一個自由變數。
實現一個簡單的裝飾器
裝飾器的典型行為:把被裝飾的函式替換成新函式,二者接受相同的引數,而且(通常)返回被裝飾的函式本該返回的值,同時還會做些額外操作。
“動態地給一個物件新增一些額外的職責。”
import time
def clock(func):
def clocked(*args):
t0 = time.perf_counter()
result = func(*args)
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
clock 裝飾器有幾個缺點:不支援關鍵字引數,而且遮蓋了被裝飾函式的 __name__
和 __doc__
屬性。
使用 functools.wraps 裝飾器把相關的屬性從 func 複製到 clocked 中。此外,這個新版還能正確處理關鍵字引數。functools.wraps 只是標準庫中拿來即用的裝飾器之一。
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
標準庫中的裝飾器
property、classmethod 和 staticmethod。
functools.wraps,它的作用是協助構建行為良好的裝飾器。
functools.lru_cache 是非常實用的裝飾器,它實現了備忘(memoization)功能。這是一項優化技術,它把耗時的函式的結果儲存起來,避免傳入相同的引數時重複計算。LRU三個字母是“Least Recently Used”的縮寫,表明快取不會無限制增長,一段時間不用的快取條目會被扔掉。
遞迴斐波那契耗時,使用該裝飾器裝飾,執行時間減半,n的每個值只調用一次函式。
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))
在計算 fibonacci(30) 的另一個測試中,示例 7-19 中的版本在 0.0005 秒內呼叫了 31 次fibonacci 函式,而示例 7-18 中未快取的版本呼叫 fibonacci 函式 2 692 537 次,在使用 Intel Core i7 處理器的膝上型電腦中耗時 17.7 秒。
除了優化遞迴演算法, lru_cache 在從 Web 中獲取資訊的應用中也能發揮巨大作用。
引數:
functools.lru_cache(maxsize=128, typed=False)
maxsize 引數指定儲存多少個呼叫的結果。快取滿了之後,舊的結果會被扔掉,騰出空間。為了得到最佳效能,maxsize 應該設為 2 的冪。typed 引數如果設為 True,把不同引數型別得到的結果分開儲存,即把通常認為相等的浮點數和整數引數(如 1 和 1.0)區分開。順便說一下,因為 lru_cache 使用字典儲存結果,而且鍵根據呼叫時傳入的定位引數和關鍵字引數建立,所以被 lru_cache 裝飾的函式,它的所有引數都必須是可雜湊的。
單分派泛函數
例子:根據資料型別的不同,進行類似的處理,htmlize_int , htmlize_str
因為 Python 不支援過載方法或函式,所以我們不能使用不同的簽名定義 htmlize 的變體,也無法使用不同的方式處理不同的資料型別。在 Python 中,一種常見的做法是把htmlize 變成一個分派函式,使用一串 if/elif/elif,呼叫專門的函式,這樣不便於模組的使用者擴充套件,還顯得笨拙:時間一長,分派函式 htmlize 會變得很大,而且它與各個專門函式之間的耦合也很緊密。
Python 3.4 新增的 functools.singledispatch 裝飾器可以把整體方案拆分成多個模組,甚至可以為你無法修改的類提供專門的函式。使用 @singledispatch 裝飾的普通函式會變成泛函數(generic function):根據第一個引數的型別,以不同方式執行相同操作的一組函式。這才稱得上是單分派。如果根據多個引數選擇專門的函式,那就是多分派了。
from functools import singledispatch
from collections import abc
import numbers
import html
@singledispatch # 標記為處理obj型別的基函式
def htmlize(obj):
content = html.escape(repr(obj))
return '<pre>{}</pre>'.format(content)
@htmlize.register(str) # 各個專門函式使用 @«base_function».register(«type») 裝飾
def _(text):
content = html.escape(text).replace('\n', '<br>\n')
return '<p>{0}</p>'.format(content)
@htmlize.register(numbers.Integral) # 為每個需要特殊處理的型別註冊一個函式。numbers.Integral 是 int 的虛擬超類
def _(n): # 函式名稱無關緊要
return '<pre>{0} (0x{0:x})</pre>'.format(n)
@htmlize.register(tuple) # 可以疊放多個 register 裝飾器,讓同一個函式支援不同型別。
@htmlize.register(abc.MutableSequence)
def _(seq):
inner = '</li>\n<li>'.join(htmlize(item) for item in seq)
return '<ul>\n<li>' + inner + '</li>\n</ul>'
只要可能,註冊的專門函式應該處理抽象基類(如 numbers.Integral 和 abc.MutableSequence),不要處理具體實現(如 int 和 list)。這樣,程式碼支援的相容型別更廣泛。例如,Python 擴充套件可以子類化 numbers.Integral,使用固定的位數實現 int 型別。
singledispatch 機制的一個顯著特徵是,你可以在系統的任何地方和任何模組中註冊專門函式。如果後來在新的模組中定義了新的型別,可以輕鬆地新增一個新的專門函式來處理那個型別。此外,你還可以為不是自己編寫的或者不能修改的類新增自定義函式。
@singledispatch 不是為了把 Java 的那種方法過載帶入 Python。在一個類中為同一個方法定義多個過載變體,比在一個函式中使用一長串 if/elif/elif/elif塊要更好。但是這兩種方案都有缺陷,因為它們讓程式碼單元(類或函式)承擔的職責太多。@singledispath 的優點是支援模組化擴充套件:各個模組可以為它支援的各個型別註冊一個專門函式。
裝飾器是函式。
registry = set()
def register(active=True):
def decorate(func): # decorate 這個內部函式是真正的裝飾器;注意,它的引數是一個函式。
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=False) # @register 工廠函式必須作為函式呼叫,並且傳入所需的引數。
def f1():
print('running f1()')
@register()
def f2():
print('running f2()')
def f3():
print('running f3()')
引數化 clock
import time
DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}'
def clock(fmt=DEFAULT_FMT): # clock 是引數化裝飾器工廠函式。
def decorate(func): # decorate 是真正的裝飾器。
def clocked(*_args): # clocked 包裝被裝飾的函式。
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())) # clocked 區域性變數
return _result
return clocked
return decorate
if __name__ == '__main__':
@clock()
def snooze(seconds):
time.sleep(seconds)
for i in range(3):
snooze(.123)
Python 裝飾器和裝飾器設計模式
Python 函式裝飾器符合 Gamma 等人在《設計模式:可複用面向物件軟體的基礎》一書中對“裝飾器”模式的一般描述:“動態地給一個物件新增一些額外的職責。就擴充套件功能而言,裝飾器模式比子類化更靈活。”