《流暢的Python》 讀書筆記 第7章_函式裝飾器和閉包

松勤吳老師發表於2023-11-10

第7章 函式裝飾器和閉包

裝飾器這個名稱可能更適合在編譯器領域使用,因為它會遍歷並註解句法樹

函式裝飾器用於在原始碼中“標記”函式,以某種方式增強函式的行為。這是一項強大的功能,但是若想掌握,必須理解閉包

如果你想自己實現函式裝飾器,那就必須瞭解閉包的方方面面,因此也就需要知道 nonlocal

閉包還是回撥式非同步程式設計和函數語言程式設計風格的基礎

本章的最終目標是解釋清楚函式裝飾器的工作原理,包括最簡單的註冊裝飾器和較複雜的引數化裝飾器

討論如下話題

  • Python 如何計算裝飾器句法
  • Python 如何判斷變數是不是區域性的
  • 閉包存在的原因和工作原理
  • nonlocal 能解決什麼問題

再進一步探討

  • 實現行為良好的裝飾器
  • 標準庫中有用的裝飾器
  • 實現一個引數化裝飾器

7.1 裝飾器基礎知識

裝飾器是可呼叫的物件,其引數是另一個函式(被裝飾的函式)

裝飾器可能會處理被裝飾的函式,然後把它返回,或者將其替換成另一個函式或可呼叫物件

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

等價於

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

此處的decorate是你定義好的裝飾器,姑且認為是個函式

這個函式被更改了,這也是網上流傳裝飾器萬能公式,記住了這點其實理解裝飾器或寫個簡單的裝飾器是很容易的。

裝飾器的適用範圍非常廣泛,你可以參考《7.12 延伸閱讀- 關於裝飾器的一個典型應用》

來看一個完整的例子

def deco(func):
    def inner():
        func() # 注: 此處我加的
        print('running inner()')
    return inner #➊

@deco
def target(): #➋
    print('running target()')

target() #➌

print(target) #➍

➊ deco 返回 inner 函式物件。
➋ 使用 deco 裝飾 target。
➌ 呼叫被裝飾的 target 其實會執行 inner。
➍ 審查物件,發現 target 現在是 inner 的引用

執行結果

running target()
running inner()
<function deco.<locals>.inner at 0x00000190D7E77D30>

可以看到如果target沒加這個裝飾器,肯定是單單執行running target(),但加了裝飾器後

看似target執行可以多出來running inner(),實際上此時的target已經不再是原來的它了,它變了

根據萬能公式

@deco
def target():
	pass

你這樣後會讓target變為target = deco(target)

再根據deco的定義

def deco(func):
	...
    return inner #➊

你在執行deco(target)的時候,返回的是一個叫inner的東西

因為你最終執行的是target(),所以也就是inner()

再看inner定義

    def inner():
        func()
        print('running inner()')

inner()的時候,會執行func()func來自deco的實參,此處對應target,所以你會先執行target(),再執行print('running inner()')

裝飾器只是語法糖

裝飾器可以像常規的可呼叫物件那樣呼叫,其引數是另一個函式。有時,這樣做更方便,尤其是做超程式設計(在執行時改變程式的行為)時

裝飾器的一大特性是,能把被裝飾的函式替換成其他函式

第二個特性是,裝飾器在載入模組時立即執行

7.2 Python何時執行裝飾器

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

書中的示例 registration.py 模組

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)
    f1()
    f2()
    f3()
if __name__=='__main__': 
    main() # ➒

➊ registry 儲存被 @register 裝飾的函式引用。
➋ register 的引數是一個函式。
➌ 為了演示,顯示被裝飾的函式。
➍ 把 func 存入 registry。
➎ 返回 func:必須返回函式;這裡返回的函式與透過引數傳入的一樣。
➏ f1 和 f2 被 @register 裝飾。
➐ f3 沒有裝飾。
➑ main 顯示 registry,然後呼叫 f1()、f2() 和 f3()。
➒ 只有把 registration.py 當作指令碼執行時才呼叫 main()。

我做了一些測試

  1. 21行的main()不寫,直接就一個pass,也會執行
running register(<function f1 at 0x000001940F3D7EE0>)
running register(<function f2 at 0x000001940F3F6040>)
  1. 這個跟你import 這個py檔案的效果是一樣的,也充分說明了在匯入時立即執行
  2. 這也是為何你在列印registry這個列表的時候已經能看到裡面有2個
  3. 類似的你把21行改為f1(),會列印如下。注意,有了上面的概念,你可能反而會覺得是不是會多列印一個running register...,實則不然。
running register(<function f1 at 0x0000021998027E50>)
running register(<function f2 at 0x0000021998027D30>)
running f1()
  1. 最終寫上main()的執行效果
running register(<function f1 at 0x000002A0F6CF7E50>)
running register(<function f2 at 0x000002A0F6CF7D30>)
running main()
registry -> [<function f1 at 0x000002A0F6CF7E50>, <function f2 at 0x000002A0F6CF7D30>]
running f1()
running f2()
running f3()

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

  • 裝飾器函式與被裝飾的函式在同一個模組中定義。實際情況是,裝飾器通常在一個模組中定義,然後應用到其他模組中的函式上。
  • register 裝飾器返回的函式與透過引數傳入的相同。實際上,大多數裝飾器會在內部定義一個函式,然後將其返回。

裝飾器原封不動地返回被裝飾的函式,但是這種技術並非沒有用處。很多 Python Web 框架使用這樣的裝飾器把函式新增到某種中央註冊處,例如把URL 模式對映到生成 HTTP 響應的函式上的註冊處。這種註冊裝飾器可能會也可能不會修改被裝飾的函式

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

策略模式是第6章的內容,比較的拗口,就先不寫了。

TODO

7.4 變數作用域規則

>>> def f(a): print(a)
...     print(b)
  File "<stdin>", line 2
    print(b)
IndentationError: unexpected indent
>>> def f(a):
...     print(a)
...     print(b)
...
>>> f(1)
1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in f
NameError: name 'b' is not defined

在書中,最後一行是這樣的

NameError: global name 'b' is not defined

雖然顯示不同(從Python3.5開始的),但的確b還是那個global,用生成的位元組碼可以說明這點

>>> from dis import dis
>>> dis(f)
  2           0 LOAD_GLOBAL              0 (print)
              2 LOAD_FAST                0 (a)
              4 CALL_FUNCTION            1
              6 POP_TOP

  3           8 LOAD_GLOBAL              0 (print)
             10 LOAD_GLOBAL              1 (b)  # 看這裡
             12 CALL_FUNCTION            1
             14 POP_TOP
             16 LOAD_CONST               0 (None)
             18 RETURN_VALUE

加一個b的定義就能正常輸出了

>>> b=2
>>> f(1)
1
2

再看另外一個例子

>>> b = 1
>>> def func(a):
...     print(a)
...     print(b)
...     b = 1
...
>>> func(2)   你可以思考下會輸出什麼?為什麼?













2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in func
UnboundLocalError: local variable 'b' referenced before assignment

你可能會覺得應該列印b的值6,因為外面定義了一個全域性變數b,而在print(b)之後的b=9是後面執行的, 不會列印9才是。

事實是,Python 編譯函式的定義體時,它判斷 b 是區域性變數,因為在函式中給它賦值了

也就是說在函式中加了一句b = 1,下面的就是b就從global變成了local variable

而且在函式外定義了全域性變數b=1,這個函式是用不了的

從生成的位元組碼看下

>>> from dis import dis
>>> dis(func)
  2           0 LOAD_GLOBAL              0 (print)
              2 LOAD_FAST                0 (a)
              4 CALL_FUNCTION            1
              6 POP_TOP

  3           8 LOAD_GLOBAL              0 (print)
             10 LOAD_FAST                1 (b)  # 這裡
             12 CALL_FUNCTION            1
             14 POP_TOP

  4          16 LOAD_CONST               1 (1)
             18 STORE_FAST               1 (b)
             20 LOAD_CONST               0 (None)
             22 RETURN_VALUE

10 LOAD_FAST 1 (b)這一行暴露了b是個local variable

這不是缺陷,而是設計選擇:Python 不要求宣告變數,但是假定在函式定義體中賦值的變數是區域性變數

這比 JavaScript 的行為好多了,JavaScript 也不要求宣告變數,但是如果忘記把變數宣告為區域性變數(使用 var),可能會在不知情的情況下獲取全域性變數

b = 6
def fun(a):
    global b
    print(a)
    print(b)
    b=9

fun(3)
print(b)

這個global必須要在fun中定義

此時的位元組碼

 12           0 LOAD_GLOBAL              0 (print)
              2 LOAD_FAST                0 (a)
              4 CALL_FUNCTION            1
              6 POP_TOP

 13           8 LOAD_GLOBAL              0 (print)
             10 LOAD_GLOBAL              1 (b)
             12 CALL_FUNCTION            1
             14 POP_TOP

 14          16 LOAD_CONST               1 (9)
             18 STORE_GLOBAL             1 (b)
             20 LOAD_CONST               0 (None)
             22 RETURN_VALUE

7.5 閉包

人們有時會把閉包和匿名函式弄混。這是有歷史原因的:在函式內部定義函式不常見,直到開始使用匿名函式才會這樣做。而且,只有涉及巢狀函式時才有閉包問題。因此,很多人是同時知道這兩個概念的

閉包指延伸了作用域的函式,其中包含函式定義體中引用、但是不在定義體中定義的非全域性變數。

函式是不是匿名的沒有關係,關鍵是它能訪問定義體之外定義的非全域性變數

書中的一個例子,要實現類似下面的效果

它的作用是計算不斷增加的系列值的均值;例如,整個歷史中某個商品的平均收盤價。每天都會增加新價格,因此平均值要考慮至目前為止所有的價格

>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

乍一看這個題,你肯定會想到這是個函式,這個函式傳入1個引數,內部有個東西可以記錄它的值,並計算出迄今為止所有資料的平均值

難道是這樣的?V1

def avg(value):
    values = []
    values.append(value)
    return sum(values)/len(values)

print(avg(10))
print(avg(11))

顯然不對,每次呼叫的時候values會被重新初始化成[],所以始終就一個值

難道是這樣的?V2

values = []
def avg(value):
    values.append(value)
    return sum(values)/len(values)

print(avg(10))
print(avg(11))
print(avg(12))

竟然對了,但是這values不能在外面啊,你拿到外面去算啥嗎~

上面是我寫的,來看作者寫的

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))
print(avg(11))
print(avg(12))
    

看到avg()你指應該想到它是一個可呼叫物件,類例項也可以進行呼叫,實現__call__就行啦

那在類這裡你要實現這個程式碼就簡單了,上面的程式碼應該可以想通,跟我們之前的蹩腳程式碼異曲同工。

來看看函式式的實現:示例 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
avg = make_averager() # 你得到的是 averager 函式名這個一等物件
print(avg(10)) # averager(10) 就是平均值
print(avg(11)) 
print(avg(12))

書中給出的類和函式的實現有共通之處:呼叫 Averager() 或 make_averager() 得到一個可呼叫物件avg,它會更新歷史值,然後計算當前均值

這個函式為何能進行累加呢?當然你能看得到這個寫法的特殊之處,函式里面有區域性變數(series),又有內部函式averager。但注意這個內部函式用到了上面的區域性變數

Averager 類的例項 avg 在哪裡儲存歷史值很明顯:self.series 例項屬性。

但是第二個示例中的 avg 函式在哪裡尋找 series 呢?

而且呼叫 avg(10) 時,make_averager 函式已經返回了,而它的本地作用域也一去不復返了。來看原文給的圖,我稍微擬合了下。

def make_averager():
	series = []
	def averager(new_value):
		series.append(new_value)
		total = sum(series)
		return total/len(series)
	return averager
avg = make_averager()
avg(10)
avg(11)
avg(12)
# 審查返回的 averager 物件,我們發現 Python 在 __code__ 屬性(表示編譯後的函式定義體)中儲存區域性變數和自由變數的名稱
	# 區域性變數
print(avg.__code__.co_varnames)
    # 自由變數
print(avg.__code__.co_freevars)
# avg.__closure__ 中的各個元素對應於 avg.__code__.co_freevars 中的一個名稱。這些元素是 cell 物件,有個 cell_contents 屬性,儲存著真正的值
print(avg.__closure__)
print(avg.__closure__[0].cell_contents)

輸出

('new_value', 'total') # 區域性變數
('series',) #自由變數
(<cell at 0x00000197FA6B4FD0: list object at 0x00000197FA083240>,)#包含該函式可用變數的繫結的單元的元組
[10, 11, 12] # 單元的值

這裡再說明下這幾個屬性的作用

屬性 作用
co_varnames 引數名和區域性變數的元組
co_freevars 自由變數的名字組成的元組(透過函式閉包引用)
__closesure__ None 或包含該函式可用變數的繫結的單元的元組。有關 cell_contents 屬性的詳情見下。
cell_contents 單元物件具有 cell_contents 屬性。這可被用來獲取以及設定單元的值

引用自

https://docs.python.org/zh-cn/3.9/reference/datamodel.html?highlight=closure#the-standard-type-hierarchy

https://docs.python.org/zh-cn/3.9/library/inspect.html?highlight=inspect#module-inspect

avg.__closure__ 中的各個元素對應於 avg.__code__.co_freevars 中的一個名稱。這些元素是 cell 物件,有個 cell_contents 屬性,儲存著真正的值

劃重點: 閉包是一種函式,它會保留定義函式時存在的自由變數的繫結,這樣呼叫函式時,雖然定義作用域不可用了,但是仍能使用那些繫結

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

7.6 nonlocal宣告

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

在上面的做法中

實現 make_averager 函式的方法效率不高

我們把所有值儲存在歷史數列中,然後在每次呼叫 averager 時使用 sum 求和

更好的實現方式是,只儲存目前的總值和元素個數,然後使用這兩個數計算均值

書中也給了個示例,但是個陷阱,你還能看出來問題所在?

def make_averager():
	total = 0
	count = 0
	def averager(new_value):
		total += new_value
		count += 1
		return count/length
	return averager
avg = make_averager()
avg(10)

在Pycharm中定義函式就是紅色的警告,會提示類似未解析的引用 'count' ,裡面三行都紅的。

但執行的時候會提示

Traceback (most recent call last):
  File "demo_fluent7.py", line 10, in <module>
    avg(10)
  File "demo_fluent7.py", line 5, in averager
    count += new_value
UnboundLocalError: local variable 'count' referenced before assignment

你上一次遇到它是在這裡

>>> b = 1
>>> def func(a):
...     print(a)
...     print(b)
...     b = 1

說明,這個count又成了一個區域性變數?

看下dis

def make_averager():
    ...#省略
avg = make_averager()
from dis import dis
dis(avg)

輸出

  5           0 LOAD_FAST                1 (count)
              2 LOAD_FAST                0 (new_value)
              4 INPLACE_ADD
              6 STORE_FAST               1 (count)

  6           8 LOAD_FAST                2 (total )
             10 LOAD_CONST               1 (1)
             12 INPLACE_ADD
             14 STORE_FAST               2 (total )

  7          16 LOAD_FAST                1 (count) # 看此處
             18 LOAD_FAST                2 (total )
             20 BINARY_TRUE_DIVIDE
             22 RETURN_VALUE

為何會這樣呢?其實之前講過

當 count 是數字或任何不可變型別時,count += 1 語句的作用其實與 count =count + 1 一樣。因此,我們在 averager 的定義體中為 count 賦值了,這會把 count 變成區域性變數。total 變數也受這個問題影響。

當你寫series = []的時候,我們利用了列表是可變的物件這一事實,你在內部函式體中只是做了series.append,這個物件並沒有改變

但是對數字、字串、元組等不可變型別來說,只能讀取,不能更新。如果嘗試重新繫結,例如 count = count + 1,其實會隱式建立區域性變數 count。這樣,count 就不是自由變數了,因此不會儲存在閉包中

這個細節在本書《第2章 資料結構》的2.6 序列的增量賦值有描述,就是對數字而言,做count+=1的時候count不再是原來的count了

那是不是這樣的思路就不行了呢?倒也不是,就是稍微有點牽強

為了解決這個問題,Python 3 引入了 nonlocal 宣告。它的作用是把變數標記為自由變數,即使在函式中為變數賦予新值了,也會變成自由變數。如果為 nonlocal 宣告的變數賦予新值,閉包中儲存的繫結會更新

最終可以替代前面的例子的程式碼如下

def make_averager():
	total  = 0
	count = 0
	def averager(new_value):
		nonlocal count,total
		count += 1
		total  += new_value
		return total/count
	return averager
avg = make_averager()
print(avg(10))
print(avg(11))
print(avg(12))

在沒有實現nonlocal的情況下(比如Python2中)

http://www.python.org/dev/peps/pep-3104/

PEP 3104—Access to Names inOuter Scopes

其中的第三個程式碼片段給出了一種方法。基本上,這種處理方式是把內部函式需要修改的變數(如 count 和 total)儲存為可變物件(如字典或簡單的例項)的元素或屬性,並且把那個物件繫結給一個自由變數

7.7 實現一個簡單的裝飾器

示例 7-15 一個簡單的裝飾器,輸出函式的執行時間

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
def get_time():
	from time import sleep
	sleep(2)

get_time()

➊ 定義內部函式 clocked,它接受任意個定位注:位置引數。
➋ 這行程式碼可用,是因為 clocked 的閉包中包含自由變數 func。
➌ 返回內部函式,取代被裝飾的函式

關於第2點,用程式碼說明下

test = clock(get_time)
print(test.__code__.co_freevars) # ('func',)

示例 7-16 使用 clock 裝飾器

需要用到上面的程式碼

import time

@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))
    
    print(factorial.__name__)

執行效果

**************************************** Calling snooze(.123)
[0.12786180s] snooze(0.123) -> None
**************************************** Calling factorial(6)
[0.00000050s] factorial(1) -> 1
[0.00000770s] factorial(2) -> 2
[0.00001190s] factorial(3) -> 6
[0.00001650s] factorial(4) -> 24
[0.00002100s] factorial(5) -> 120
[0.00002730s] factorial(6) -> 720
6! = 720

clocked

工作原理

@clock
def factorial(n):
	return 1 if n < 2 else n*factorial(n-1)

等價於

def factorial(n):
	return 1 if n < 2 else n*factorial(n-1)
factorial = clock(factorial)

factorial成為了clock的實參,指向func形參;呼叫後clock(factorial)返回的是clocked

看上面我加的除錯程式碼print(factorial.__name__)得到的就是clocked

現在 factorial 儲存的是 clocked 函式的引用。自此之後,每次呼叫 factorial(n),執行的都是 clocked(n)。

程式碼上clocked做了以下事情:

(1) 記錄初始時間 t0。                    # t0 = time.perf_counter()
(2) 呼叫原來的 factorial 函式,儲存結果。 # result = func(*args) # ➋
(3) 計算經過的時間。                     # elapsed = time.perf_counter() - t0
(4) 格式化收集的資料,然後列印出來。  
		# 收集的資料:包括前面的 elapsed 和 result
		# name = func.__name__
		# arg_str = ', '.join(repr(arg) for arg in args)
         # print('[%0.8fs] %s(%s) -> %r' % (elapsed, name, arg_str, result))
(5) 返回第 2 步儲存的結果。              # return result

裝飾器的典型行為:

​ 1. 把被裝飾的函式替換成新函式

​ 2. 二者接受相同的引數

​ 3. 而且(通常)返回被裝飾的函式本該返回的值

​ 4. 同時還會做些額外操作

Gamma 等人寫的《設計模式:可複用物件導向軟體的基礎》一書是這樣概述“裝飾器”模式的:“動態地給一個物件新增一些額外的職責。”函式裝飾器符合這一說法。

但在實現層面,Python 裝飾器與《設計模式:可複用物件導向軟體的基礎》中所述的“裝飾器”沒有多少相似之處

示例 7-15 中實現的 clock 裝飾器有幾個缺點:不支援關鍵字引數,而且遮蓋了被裝飾函式的__name__ __doc__ 屬性

import time
def clock(func):
	'''doc of clock'''
	def clocked(*args): # ➊
		'''doc of clocked'''
		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
def snooze(seconds):
	time.sleep(seconds)
@clock
def factorial(n):
	'''doc of fact'''
	return 1 if n < 2 else n*factorial(n-1)
if __name__=='__main__':
	print(factorial.__doc__)
	print(factorial.__name__)
	print('*' * 40, 'Calling snooze(.123)')
	snooze(.123)
	snooze(seconds=.123)

注意上面的程式碼,做了一些更改

  1. 加了factorial、clock和clocked等函式的doc,你可以看到,print(factorial.__doc__)輸出的是clocked的doc
  2. 測試了下關鍵字輸入方式snooze(seconds=.123)提示如下
TypeError: clocked() got an unexpected keyword argument 'seconds'

如果要支援關鍵字只需做如下更改

def clock(func):
	'''doc of clock'''
	def clocked(*args,**kwargs): # ➊
		'''doc of clocked'''
		t0 = time.perf_counter()
		result = func(*args,**kwargs) # ➋

➊ clocked本身要支援**kwargs
➋ 內部呼叫的時候要接受**kwargs

輸出大致如下

doc of clocked
clocked
**************************************** Calling snooze(.123)
[0.13518720s] snooze(0.123) -> None
[0.12407520s] snooze() -> None

問題1: 你可以看到,factorial的__doc____name__被遮擋了,這點在前面的萬能公式中我們也有提到

怎麼處理呢?

使用 functools.wraps 裝飾器把相關的屬性從 func複製到 clocked 中

import time
import functools
def clock(func):
    @functools.wraps(func)
    def clocked(*args,**kwargs): # ➊
        t0 = time.perf_counter()
        result = func(*args,**kwargs) # ➋
        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
def snooze(seconds):
    time.sleep(seconds)
@clock
def factorial(n):
    '''doc of fact'''
    return 1 if n < 2 else n*factorial(n-1)
if __name__=='__main__':
    print(factorial.__doc__)  # doc of fact
    print(factorial.__name__) # factorial

可以看到__doc____name__改過來了


問題2:snooze(seconds=.123)這種呼叫方式在結果中沒有輸出引數

原因很簡單,你沒有處理,你處理的只是args,你還要處理kwargs,參考程式碼如下

import time
import functools
def clock(func):
    @functools.wraps(func)
    def clocked(*args,**kwargs): # ➊
        t0 = time.perf_counter()
        result = func(*args,**kwargs) # ➋
        elapsed = time.perf_counter() - 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 # ➌


@clock
def snooze(seconds):
    time.sleep(seconds)
@clock
def factorial(n):
    '''doc of fact'''
    return 1 if n < 2 else n*factorial(n-1)
if __name__=='__main__':
    print(factorial.__doc__)
    print(factorial.__name__)
    print('*' * 40, 'Calling snooze(.123)')
    snooze(.123)
    snooze(seconds=.123)

7.8 標準庫中的裝飾器

Python 內建了三個用於裝飾方法的函式:property、classmethod 和 staticmethod

另一個常見的裝飾器是 functools.wraps,它的作用是協助構建行為良好的裝飾器

標 準 庫 中 最 值 得 關 注 的 兩 個 裝 飾 器 是 lru_cache 和 全 新 的singledispatch(Python 3.4 新增)

7.8.1 使用functools.lru_cache做備忘

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

示例 7-18 生成第 n 個斐波納契數,遞迴方式非常耗時

@clock
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)
if __name__=='__main__':
    print(fibonacci(6))

這對你理解遞迴也是有幫助的

輸出如下(這個呼叫順序實在有點...)

[0.00000030s] fibonacci(1) -> 1
[0.00000030s] fibonacci(0) -> 0
[0.00002610s] fibonacci(2) -> 1
[0.00000020s] fibonacci(1) -> 1
[0.00003430s] fibonacci(3) -> 2
[0.00000020s] fibonacci(1) -> 1
[0.00000020s] fibonacci(0) -> 0
[0.00000760s] fibonacci(2) -> 1
[0.00004960s] fibonacci(4) -> 3
[0.00000010s] fibonacci(1) -> 1
[0.00000020s] fibonacci(0) -> 0
[0.00000750s] fibonacci(2) -> 1
[0.00000020s] fibonacci(1) -> 1
[0.00001490s] fibonacci(3) -> 2
[0.00007280s] fibonacci(5) -> 5
[0.00000020s] fibonacci(1) -> 1
[0.00000020s] fibonacci(0) -> 0
[0.00000750s] fibonacci(2) -> 1
[0.00000010s] fibonacci(1) -> 1
[0.00001470s] fibonacci(3) -> 2
[0.00000020s] fibonacci(1) -> 1
[0.00000020s] fibonacci(0) -> 0
[0.00000750s] fibonacci(2) -> 1
[0.00002930s] fibonacci(4) -> 3
[0.00010970s] fibonacci(6) -> 8
8

畫個圖

從圖上可以看到,這裡存在大量的重複操作

增加兩行程式碼,使用 lru_cache,效能會顯著改善

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

注意@functools.lru_cache()必須放@clock前面

這時候的輸出就是這樣,重複的呼叫都沒了

[0.00000040s] fibonacci(1) -> 1
[0.00000030s] fibonacci(0) -> 0
[0.00002740s] fibonacci(2) -> 1
[0.00003230s] fibonacci(3) -> 2
[0.00003680s] fibonacci(4) -> 3
[0.00004120s] fibonacci(5) -> 5
[0.00004570s] fibonacci(6) -> 8
8

另外一個注意的點是:必須像常規函式那樣呼叫 lru_cache,後面有個()

作者做了個測試,可以看出,提升是巨大的

示例 7-19 中的版本(加了lru_cache的)在 0.0005 秒內呼叫了 31 次fibonacci 函式

示例 7-18 中未快取版本呼叫 fibonacci 函式 2 692 537 次,在使用Intel Core i7 處理器的膝上型電腦中耗時 17.7 秒

lru_cache簽名

functools.lru_cache(maxsize=128, typed=False)
  • maxsize指定儲存多少個呼叫的結果,快取滿了之後,舊的結果會被扔掉,騰出空間。為了得到最佳效能,maxsize 應該設為 2 的冪
  • typed 引數如果設為 True,把不同引數型別得到的結果分開儲存,即把通常認為相等的浮點數和整數引數區分開
  • lru_cache 使用字典儲存結果,而且鍵根據呼叫時傳入的定位引數和關鍵字引數建立,所以被 lru_cache 裝飾的函式,它的所有引數都必須是可雜湊的,即不可變的

7.8.2 單分派泛函式

背景: 假設我們在開發一個除錯 Web 應用的工具,我們想生成 HTML,顯示不同型別的 Python物件

import html
def htmlize(obj):
    content = html.escape(repr(obj))
    return '<pre>{}</pre>'.format(content)

想改造這個函式,以期達到下面的效果

  • str:把內部的換行符替換為<br>\n';不使用<pre>,而是使用 <p>
  • int:以十進位制和十六進位制顯示數字。
  • list:輸出一個 HTML 列表,根據各個元素的型別進行格式化

示例 7-20 生成 HTML 的 htmlize 函式,調整了幾種物件的輸出

>>> htmlize({1, 2, 3}) ➊
'<pre>{1, 2, 3}</pre>'
>>> htmlize(abs)
'<pre>&lt;built-in function abs&gt;</pre>'
>>> htmlize('Heimlich & Co.\n- a game') ➋
'<p>Heimlich &amp; Co.<br>\n- a game</p>'
>>> htmlize(42) ➌
'<pre>42 (0x2a)</pre>'
>>> print(htmlize(['alpha', 66, {3, 2, 1}])) ➍
<ul>
<li><p>alpha</p></li>
<li><pre>66 (0x42)</pre></li>
<li><pre>{1, 2, 3}</pre></li>
</ul>

➊ 預設情況下,在 <pre></pre> 中顯示 HTML 轉義後的物件字串表示形式。

➋ 為 str 物件顯示的也是 HTML 轉義後的字串表示形式,不過放在 <p></p> 中,而且使用 <br> 表示換行。
➌ int 顯示為十進位制和十六進位制兩種形式,放在<pre></pre> 中。
➍ 各個列表專案根據各自的型別格式化,整個列表則渲染成 HTML 列表。

因為 Python 不支援過載方法或函式,所以我們不能使用不同的簽名定義 htmlize 的變體,也無法使用不同的方式處理不同的資料型別。

過載overload,java中可以。Python只有override(重寫)。

不同的簽名是不支援的

def htmlize(obj:int):
    pass
def htmlize(obj:str):
    pass
def htmlize(obj:list):
    pass

但我知道的一種做法是可以用第三方庫,類似於這樣

from multipledispatch import dispatch
@dispatch(int)
def htmlize(obj):
    print('int')
@dispatch(str)
def htmlize(obj):
    print('str')
@dispatch(list)
def htmlize(obj):
    print('list')

htmlize(1) # int
htmlize('1') # str
htmlize([1,]) # list
htmlize((1,)) # 報錯 
# NotImplementedError: Could not find signature for htmlize: <tuple>

書中還說了一句也無法使用不同的方式處理不同的資料型別,我沒太理解,不是可以用isinstance來處理嗎?莫非在寫的時候

沒有這個玩意

第二版英文原文如下

Because we don’t have Java-style method overloading in Python, we can’t simply cre‐ate variations of htmlize with different signatures for each data type we want to han‐dle differently

在 Python 中,一種常見的做法是把 htmlize變成一個分派函式,使用一串 if/elif/elif,呼叫專門的函式,如 htmlize_str、htmlize_int,等等。這樣不便於模組的使用者擴充套件,還顯得笨拙:時間一長,分派函式 htmlize 會變
得很大,而且它與各個專門函式之間的耦合也很緊密

書中給出的示例 7-21 singledispatch 建立一個自定義的 htmlize.register 裝飾器,把多個函式綁在一起組成一個泛函式

from functools import singledispatch
from collections import abc
import numbers
import html


@singledispatch  # ➊
def htmlize(obj):
    content = html.escape(repr(obj))
    return '<pre>{}</pre>'.format(content)


@htmlize.register(str)  # ➋
def _(text):  # ➌
    content = html.escape(text).replace('\n', '<br>\n')
    return '<p>{0}</p>'.format(content)


@htmlize.register(numbers.Integral)  # ➍
def _(n):
    return '<pre>{0} (0x{0:x})</pre>'.format(n)


@htmlize.register(tuple)  # ➎
@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>'
# 測試資料
print(htmlize({1, 2, 3}))
print(htmlize(abs))
print(htmlize('Heimlich & Co.\n- a game') )
print(htmlize(42) )
print(htmlize(['alpha', 66, {3, 2, 1}]))

➊ @singledispatch 標記處理 object 型別的基函式。
➋ 各個專門函式使用 @«base_function».register(«type») 裝飾。
➌ 專門函式的名稱無關緊要;_ 是個不錯的選擇,簡單明瞭。
➍ 為每個需要特殊處理的型別註冊一個函式。numbers.Integral 是 int 的虛擬超類。
➎ 可以疊放多個 register 裝飾器,讓同一個函式支援不同型別。

只要可能,註冊的專門函式應該處理抽象基類(如 numbers.Integral 和 abc.MutableSequence),不要處理具體實現(如 int 和 list)。這樣,程式碼支援的相容型別更廣泛。例如,Python擴充套件可以子類化 numbers.Integral,使用固定的位數實現 int 型別

使用抽象基類檢查型別,可以讓程式碼支援這些抽象基類現有和未來的具體子類或虛擬子類

singledispatch 機制的一個顯著特徵是,你可以在系統的任何地方和任何模組中註冊專門函式。

如果後來在新的模組中定義了新的型別,可以輕鬆地新增一個新的專門函式來處理那個型別。

此外,你還可以為不是自己編寫的或者不能修改的類新增自定義函式。

singledispatch 是經過深思熟慮之後才新增到標準庫中的,它提供的特性很多 , 詳見

PEP 443 — Single-dispatch generic functions

https://www.python.org/dev/peps/pep-0443/

@singledispatch 不是為了把 Java 的那種方法過載帶入 Python。在一個類中為同一個方法定義多個過載變體,比在一個函式中使用一長串 if/elif/elif/elif 塊要更好。但是這兩種方案都有缺陷,因為它們讓程式碼單元(類
或函式)承擔的職責太多。

@singledispath 的優點是支援模組化擴充套件:各個模組可以為它支援的各個型別註冊一個專門函式

7.9 疊放裝飾器

裝飾器是函式,因此可以組合起來使用(即,可以在已經被裝飾的函式上應用裝飾器)

前面已經多次這樣使用,比如

@functools.lru_cache()
@clock
def fibonacci(n):
    pass

但要注意順序

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

等價於f = d1(d2(f)),就近原則,最近@的最先裝飾

7.10 引數化裝飾器

解析原始碼中的裝飾器時,Python 把被裝飾的函式作為第一個引數傳給裝飾器函式。

那怎麼讓裝飾器接受其他引數呢?

答案是:建立一個裝飾器工廠函式,把引數傳給它,返回一個裝飾器,然後再把它應用到要裝飾的函式上

書中給了個示例示例 7-22 示例 7-2 中 registration.py 模組的刪減版

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 函式不是裝飾器,而是裝飾器工廠函式。呼叫它會返回真正的裝飾器,這才是應用到目標函式上的裝飾器。

示例 7-23 為了接受引數,新的 register 裝飾器必須作為函式呼叫

registry = set() #➊

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 #➏
    return decorate #➐
@register(active=False) #➑
def f1():
    print('running f1()')
@register() #➒
def f2():
    print('running f2()')
def f3():
    print('running f3()')

➊ registry 現在是一個 set 物件,這樣新增和刪除函式的速度更快。
➋ register 接受一個可選的關鍵字引數。
➌ decorate 這個內部函式是真正的裝飾器;注意,它的引數是一個函式。
➍ 只有 active 引數的值(從閉包中獲取)是 True 時才註冊 func。
➎ 如果 active 不為真,而且 func 在 registry 中,那麼把它刪除。
➏ decorate 是裝飾器,必須返回一個函式。
register 是裝飾器工廠函式,因此返回 decorate。然後把它應用到被裝飾的函式上
➑ @register 工廠函式必須作為函式呼叫,並且傳入所需的引數。
➒ 即使不傳入引數,register 也必須作為函式呼叫(@register()),即要返回真正的裝飾器 decorate

在終端下你可以測試出以下結果,假設檔案是demo.py

>>> import demo                                                     
running register(active=False)->decorate(<function f1 at 0x000002860CF2CEE0>)
running register(active=True)->decorate(<function f2 at 0x000002860CF2CF70>)
>>> demo.registry
{<function f2 at 0x000002860CF2CF70>}

跟之前7.2說的一樣匯入的時候就會執行

只有 f2 函式在 registry 中;f1 不在其中,因為傳給 register 裝飾器工廠函式的引數是 active=False,所以應用到 f1 上的 decorate 沒有把它新增到 registry 中

如果不使用 @ 句法,那就要像常規函式那樣使用 register;若想把 f 新增到 registry中,則裝飾 f 函式的句法是 register()(f);不想新增(或把它刪除)的話,句法是register(active=False)(f)

上面這部分是關鍵

@clock
def fibonacci(n):
    pass

你知道,fibonacci=clock(fibonacci)

那你現在要做的是

@clock(param='xxx')
def fibonacci(n):
    pass

那自然fibonacci=clock(param='xxx')(fibonacci)

所以你應該定義一個

def clock(param='xxx'):
    pass

而這個clock的返回需要是一個函式,引數應該是一個函式(比如fibonacci)

def clock(param='xxx'):
    def decorate(func):
        pass
    decorate

書中還給你做了下如下測試

>>> from registration_param import * # 我上面的測試改了此處的名字 demo
running register(active=False)->decorate(<function f1 at 0x10073c1e0>)
running register(active=True)->decorate(<function f2 at 0x10073c268>)
>>> registry # ➊
{<function f2 at 0x10073c268>}
>>> register()(f3) # ➋
running register(active=True)->decorate(<function f3 at 0x10073c158>)
<function f3 at 0x10073c158>
>>> registry # ➌
{<function f3 at 0x10073c158>, <function f2 at 0x10073c268>}
>>> register(active=False)(f2) # ➍
running register(active=False)->decorate(<function f2 at 0x10073c268>)
<function f2 at 0x10073c268>
>>> registry # ➎
{<function f3 at 0x10073c158>}

➊ 匯入這個模組時,f2 在 registry 中。
➋ register() 表示式返回 decorate,然後把它應用到 f3 上。
➌ 前一行把 f3 新增到 registry 中。
➍ 這次呼叫從 registry 中刪除 f2。
➎ 確認 registry 中只有 f3。

7.10.2 引數化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() - t0
            name = func.__name__
            args = ', '.join(repr(arg) for arg in _args)  # ➎

            result = repr(_result)  # ➏
            print(fmt.format(**locals()))  # ➐
            return _result  # ➑

        return clocked  # ➒

    return decorate  # ➓

➊ clock 是引數化裝飾器工廠函式。
➋ decorate 是真正的裝飾器。
➌ clocked 包裝被裝飾的函式。
➍ _result 是被裝飾的函式返回的真正結果。
➎ _args 是 clocked 的引數,args 是用於顯示的字串。
➏ result 是 _result 的字串表示形式,用於顯示。
➐ 這裡使用**locals()是為了在 fmt 中引用 clocked 的區域性變數。
➑ clocked 會取代被裝飾的函式,因此它應該返回被裝飾的函式返回的值。
➒ decorate 返回 clocked。
➓ clock 返回 decorate。在這個模組中測試,不傳入引數呼叫 clock(),因此應用的裝飾器使用預設的格式 str。應該是DEFAULT_FMT

**locals()** 函式會以字典型別返回當前位置的全部區域性變數,配合fmt來用,還是挺巧妙的~

locals: {'_args': (0.123,), 't0': 1699234406.3928096, '_result': None, 'elapsed': 0.12681794166564941, 'name': 'snooze', 'args': '0.123', 'result': 'None', 'fmt': '[{elapsed:0.8f}s] {name}({args}) -> {result}', 'func': <function snooze at 0x0000026ED4107F70>}
DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}' # 在上面也有

另外一點就是

引數化裝飾器通常會把被裝飾的函式替換掉,而且結構上需要多一層巢狀。

考慮上面的結構

def clock(fmt=DEFAULT_FMT):
    def decorate(func):
        def clocked(*_args):
            ...
            return _result
        return clocked
    return decorate
@clock()
def snooze(seconds):
	pass

結合萬能公式

snooze=clock()(snooze) #注意此處的第一個()

snooze=decorate(snooze) # 轉換下

snooze=clocked # 替換了

最終

    for i in range(3):
        snooze(.123)

就相當於

    for i in range(3):
        clocked(.123)

所以下面的幾個測試結果

測試1

if __name__ == '__main__':
    @clock()
    def snooze(seconds):
        time.sleep(seconds)
    for i in range(3):
        snooze(.123)

輸出

[0.13555145s] snooze(0.123) -> None
[0.12589598s] snooze(0.123) -> None
[0.12798786s] snooze(0.123) -> None

測試2

if __name__ == '__main__':
    @clock('{name}: {elapsed}s')
    def snooze(seconds):
        time.sleep(seconds)

    for i in range(3):
        snooze(.123)

輸出

snooze: 0.12915396690368652s
snooze: 0.1259920597076416s
snooze: 0.1258389949798584s

測試3

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

輸出

snooze(0.123) dt=0.126s
snooze(0.123) dt=0.126s
snooze(0.123) dt=0.126s

Graham Dumpleton 和 Lennart Regebro(本書的技術審校之一)認為,裝飾器最好透過實現 __call__ 方法的類實現,不應該像本章的示例那樣透過函式實現

import time

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

class Clock:
    def __init__(self,fmt=DEFAULT_FMT):
        self.fmt = fmt

    def __call__(self, 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('locals:',locals())
            print(self.fmt.format(**locals()))
            return _result

        return clocked


if __name__ == '__main__':
    @Clock()
    def snooze(seconds):
        time.sleep(seconds)
    for i in range(3):
        snooze(.123)
    @Clock('{name}: {elapsed}s')
    def snooze(seconds):
        time.sleep(seconds)
    for i in range(3):
        snooze(.123)
    @Clock('{name}({args}) dt={elapsed:0.3f}s')
    def snooze(seconds):
        time.sleep(seconds)
    for i in range(3):
        snooze(.123)

同樣的推導

snooze=Clock()(snooze)

其中Clock()是個例項,假設為clock_instance

那clock_instance(snnoze)就是在呼叫__call__,返回的就是clocked,也發生了替換

從寫法上更讓清晰一些

7.11 本章小結

從本章開始進入超程式設計領域

開始,我們先編寫了一個沒有內部函式的 @register 裝飾器;最後,我們實現了有兩層巢狀函式的引數化裝飾器 @clock()

引數化裝飾器基本上都涉及至少兩層巢狀函式,如果想使用 @functools.wraps 生成裝飾器,為高階技術提供更好的支援,巢狀層級可能還會更深,比如前面簡要介紹過的疊放裝飾器

討論了標準庫中 functools 模組提供的兩個出色的函式裝飾器:@lru_cache() 和@singledispatch

若想真正理解裝飾器,需要區分匯入時和執行時,還要知道變數作用域、閉包和新增的nonlocal 宣告。掌握閉包和 nonlocal 不僅對構建裝飾器有幫助,還能協助你在構建 GUI程式時面向事件程式設計,或者使用回撥處理非同步 I/O

7.12 延伸閱讀

素材 URL 相關資訊
Python Cookbook(第 3 版)中文版》第 9 章“超程式設計” 有幾個訣竅構建了基本的裝飾器和特別複雜的裝飾器
9.6 定義一個能接收可選引數的裝飾器”一節中的裝飾器可以作為常規的裝飾器呼叫,也可以作為裝飾器工廠函式呼叫,例如 @clock 或 @clock()
Graham Dumpleton 博 客 文 章 https://github.com/GrahamDumpleton/wrapt/blob/develop/blog/README.md 深入剖析瞭如何實現行為良好的裝飾器
How You Implemented Your Python Decorator is Wrong https://github.com/GrahamDumpleton/wrapt/blob/develop/blog/01-how-you-implemented-your-python-decorator-is-wrong.md
wrapt 模組 http://wrapt.readthedocs.org/en/latest 這個模組的作用是簡化裝飾器和動態函式包裝器的實現,即使多層裝飾也支援內省,而且行為正確,既可以應用到方法上,也可以作為描述符使用
Michele Simionato的decorator包 https://pypi.python.org/pypi/decorator 簡化普通程式設計師使用裝飾器的方式,並且透過各種複雜的示例推廣裝飾器
Python Decorator Library 維基頁面 https://wiki.python.org/moin/PythonDecoratorLibrary 裡面有很多示例
PEP 443 http://www.python.org/dev/peps/pep-0443 對單分派泛函式的基本原理和細節做了說明
Five-Minute Multimethods in Python http://www.artima.com/weblogs/viewpost.jsp?thread=101605 詳細說明了如何使用裝飾器實現泛函式(也叫多方法),他給出的程式碼支援多分派(即根據多個定位引數進行分派)
Martijn Faassen 開發的 Reg http://reg.readthedocs.io/en/latest/ 如果想使用現代 的技術實現多分派泛函式,並支援在生產環境中使用,可以用 它
Fredrik Lundh 寫的一篇短文Closures in Python http://effbot.org/zone/closure.htm 解說了閉包這個術語
PEP 3104—Access to Names in Outer Scopes http://www.python.org/dev/peps/pep-3104 說明了引入 nonlocal 宣告的原因:重新繫結既不在本地作用域中也不在全域性作用域中的名稱。這份 PEP 還概述了其他動態語言(Perl、Ruby、JavaScript,等等)解決這個問題的方式,以及 Python 中可用設計方案的優缺點
PEP 227—Statically Nested Scopes http://www.python.org/dev/peps/pep-0227/ 說明了 Python 2.1 引入的詞法作用域;這份 PEP 還說明了 Python 中閉包的基本原理和實現方式的選擇

雜談

  • 任何把函式當作一等物件的語言,它的設計者都要面對一個問題:作為一等物件的函式在某個作用域中定義,但是可能會在其他作用域中呼叫。問題是,如何計算自由變數?首先出現的最簡單的處理方式是使用“動態作用域”。也就是說,根據函式呼叫所在的環境計算自由變數。

  • 動態作用域易於實現,這可能就是 John McCarthy 建立 Lisp(第一門把函式視作一等物件的語言)時採用這種方式的原因

  • Python 函式裝飾器符合 Gamma 等人在《設計模式:可複用物件導向軟體的基礎》一書中對“裝飾器”模式的一般描述:“動態地給一個物件新增一些額外的職責。就擴充套件功能而言,裝飾器模式比子類化更靈活。”

  • 在設計模式中,Decorator 和 Component 是抽象類。為了給具體元件新增行為,具體裝飾器的例項要包裝具體元件的例項

  • 裝飾器與它所裝飾的元件介面一致,因此它對使用該元件的客戶透明。它將客戶請求轉發給該元件,並且可能在轉發前後執行一些額外的操作(例如繪製一個邊框)。透明性使得你可以遞迴巢狀多個裝飾器,從而可以新增任意多的功能

  • 一般來說,實現“裝飾器”模式時最好使用類表示裝飾器和要包裝的元件

還有很多,不再一一羅列了啦,雜談部分就當看Python歷史了

關於裝飾器的一個典型應用

引自 劉江的部落格

有一個大公司,下屬的基礎平臺部負責內部應用程式及API的開發。另外還有上百個業務部門負責不同的業務,這些業務部門各自呼叫基礎平臺部提供的不同函式,也就是API處理自己的業務,情況如下:

# 基礎平臺部門開發了上百個函式API
def f1():
    print("業務部門1的資料介面......")
def f2():
    print("業務部門2的資料介面......")
def f3():
    print("業務部門3的資料介面......")
def f100():
    print("業務部門100的資料介面......")

#各部門分別呼叫自己需要的API
f1()
f2()
f3()
f100()

公司還在創業初期時,基礎平臺部就開發了這些函式。由於各種原因,比如時間緊,比如人手不足,比如架構缺陷,比如考慮不周等等,沒有為函式的呼叫進行安全認證。現在,公司發展壯大了,不能再像初創時期的“草臺班子”一樣將就下去了,基礎平臺部主管決定彌補這個缺陷,於是(以下場景純屬虛構,調侃之言,切勿對號入座):

第一天:主管叫來了一個運維工程師,工程師跑上跑下逐個部門進行通知,讓他們在程式碼里加上認證功能,然後,當天他被開除了。

第二天:主管又叫來了一個運維工程師,工程師用shell寫了個複雜的指令碼,勉強實現了功能。但他很快就回去接著做運維了,不會開發的運維不是好運維....

第三天:主管叫來了一個python自動化開發工程師。哥們是這麼幹的,只對基礎平臺的程式碼進行重構,讓N個業務部門無需做任何修改。這哥們很快也被開了,連運維也沒得做。  

def f1():
    #加入認證程式程式碼
    print("業務部門1資料介面......")
def f2():
    # 加入認證程式程式碼
    print("業務部門2資料介面......")
def f3():
    # 加入認證程式程式碼
    print("業務部門3資料介面......")
def f100():
    #加入認證程式程式碼
    print("業務部門100資料介面......")

#各部門分別呼叫
f1()
f2()
f3()
f100()

第四天:主管又換了個開發工程師。他是這麼幹的:定義個認證函式,在原來其他的函式中呼叫它,程式碼如下。

def login():
    print("認證成功!")

def f1():
    login()
    print("業務部門1資料介面......")
def f2():
    login()
    print("業務部門2資料介面......")
def f3():
    login()
    print("業務部門3資料介面......")
def f100():
    login()
    print("業務部門100資料介面......")

#各部門分別呼叫
f1()
f2()
f3()
f100()

但是主管依然不滿意,不過這一次他解釋了為什麼。主管說:寫程式碼要遵循開放封閉原則,簡單來說,已經實現的功能程式碼內部不允許被修改,但外部可以被擴充套件。如果將開放封閉原則應用在上面的需求中,那麼就是不允許在函式f1 、f2、f3......f100的內部進行程式碼修改,但是可以在外部對它們進行擴充套件。

第五天:已經沒有時間讓主管找別人來幹這活了,他決定親自上陣,使用裝飾器完成這一任務,並且打算在函式執行後再增加個日誌功能。主管的程式碼如下:

def outer(func):
    def inner():
        print("認證成功!")
        result = func()
        print("日誌新增成功")
        return result
    return inner

@outer
def f1():
    print("業務部門1資料介面......")

@outer
def f2():
    print("業務部門2資料介面......")
@outer
def f3():
    print("業務部門3資料介面......")

@outer
def f100():
    print("業務部門100資料介面......")

#各部門分別呼叫
f1()
f2()
f3()
f100()

使用裝飾器@outer,也是僅需對基礎平臺的程式碼進行擴充,就可以實現在其他部門呼叫函式API之前都進行認證操作,在操作結束後儲存日誌,並且其他業務部門無需對他們自己的程式碼做任何修改,呼叫方式也不用變

相關文章