Python裝飾器:套層殼我變得更強了
昨天閱讀了《Python Tricks: The Book》的第三章“Effective Functions”,這一章節介紹了Python函式的靈活用法,包括lambda函式、裝飾器、不定長引數*args和**kwargs等,書中關於閉包的介紹讓我回想起了《你不知道的JavaScript-上卷》中的相關內容。本文主要記錄自己在學習Python閉包和裝飾器過程中的一些心得體會,部分內容直接摘抄自參考資料。
關於作用域和閉包可以聊點什麼?
什麼是作用域
作用域負責收集並維護由所有宣告的識別符號(變數)組成的一系列查詢,並實施一套非常嚴格的規則,確定當前執行的程式碼對這些識別符號的訪問許可權。換句話說,作用域是根據名稱查詢變數的一套規則。
作用域的以下兩點規則需要特別注意:
-
“遮蔽效應”:作用域查詢會在找到第一個匹配的識別符號時停止,巢狀作用域內部的識別符號會遮蔽外部的識別符號;
-
提升:無論作用域中的宣告出現在什麼地方,都將在程式碼本身被執行前首先進行處理,可以形象地認為變數和函式宣告從它們在程式碼中出現的位置被“移動”到了所在作用域的頂部。
下面通過一個例子進行說明:
level = 3
def upgrade():
"""在當前等級的基礎上提升一級"""
level += 1
def cprint():
print('當前等級:' + '*' * level)
upgrade() # UnboundLocalError: local variable 'level' referenced before assignment
cprint() # 當前等級:***
print(xyz) # NameError: name 'xyz' is not defined
為什麼同樣是引用全域性變數“level”,執行函式“upgrade”觸發了“UnboundLocalError”異常,而執行函式“cprint”就不會呢?這是因為在程式碼編譯的過程中,函式“upgrade”的賦值表示式“level += 1”會被解析為“level = level + 1”,這涉及變數宣告和變數賦值兩個過程。首先是變數宣告,“level”會被宣告為區域性變數(全域性作用域裡面的“level”被遮蓋了),並且它的宣告會被提升到函式作用域的頂部;其次是變數賦值,Python直譯器會從函式作用域中查詢“level”,並計算表示式“level + 1”的結果,由於此時“level”雖然被宣告瞭,但是還沒有被賦值(繫結?),計算失敗,觸發了“UnboundLocalError”異常。
“UnboundLocalError”異常和“NameError”異常的觸發條件是不同的:
-
UnboundLocalError: Raised when a reference is made to a local variable in a function or method, but no value has been bound to that variable.
-
NameError: Raised when a local or global name is not found.
從官方文件給出的描述中可以看到,“UnboundLocalError”異常是在變數被宣告瞭(在作用域中找到了)但是還沒有繫結值的時候觸發,而“NameError”異常是在作用域中找不到變數的時候觸發,兩者是有比較明顯的區別的。
通過為函式“upgrade”中的變數“level”加上global宣告可以規避“UnboundLocalError”異常:
level = 3
def upgrade():
"""在當前等級的基礎上提升一級"""
global level # global宣告將“level”標記為全域性變數
level += 1
upgrade() # 太棒了,沒有觸發異常!
print(level) # 4
global宣告將“level”標記為全域性變數,在程式碼編譯過程中不會再宣告“level”為函式作用域裡面的區域性變數了。nonlocal宣告具有相似的功能,但使用的場景與global不同,由於篇幅限制,這裡不再展開說明。
什麼是閉包
A closure remembers the values from its enclosing lexical scope even when the program flow is no longer in that scope.
當函式可以記住並訪問所在的詞法作用域(定義函式時所在的作用域),即使函式是在詞法作用域之外執行,這時就產生了閉包。
通過計算移動平均值的例子說明Python閉包:
def make_averager():
"""工廠函式"""
series = []
def averager(new_value):
"""移動平均值計算器"""
series.append(new_value) # series是外部作用域中的變數
total = sum(series)
return total / len(series)
return averager # 返回內部定義的函式averager
averager = make_averager()
averager(10) # 10
averager(20) # 15
averager(30) # 20
可以看到函式“averager”的定義體中引用了工廠函式“make_averager”的詞法作用域中的區域性變數“series”,當“averager”被當作物件返回並且在全域性作用域中被呼叫,它仍然能夠訪問“series”的值,據此計算移動平均值。這就是閉包。
Python在函式的“__code__”屬性中儲存了詞法作用域中的區域性變數和自由變數(free variable,“series”就是自由變數)的名稱,在函式的“__closure__”屬性中儲存了自由變數的值:
averager.__code__.co_varnames # ('new_value', 'total')
averager.__code__.co_freevars # ('series',)
averager.__closure__ # (<cell at 0x000002135DE72FD8: list object at 0x000002135D589488>,)
averager.__closure__[0].cell_contents # [10, 20, 30]
裝飾器:套層殼我變得更強了
裝飾器常用於把被裝飾的函式(或可呼叫的物件)替換成其他函式,它的輸入引數是一個函式,輸出結果也是一個函式。裝飾器是實現橫切關注點(cross-cutting concerns)的絕佳方案,使用場景包括資料校驗(使用者登入了嗎?使用者有許可權訪問資料嗎?)、快取(functools.lru_cache)、日誌列印等。
def uppercase(func):
def wrapper():
original_result = func() # 引用了uppercase函式作用域中的變數func
modified_result = original_result.upper()
return modified_result
return wrapper
def make_greeting_words():
"""來段問候語"""
return 'Hello, World!'
greet = uppercase(make_greeting_words) # 用uppercase裝飾make_greeting_words
greet() # 'HELLO, WORLD!',好耶,單詞變成大寫的了!
greet.__name__ # 'wrapper'
greet.__doc__ # None
觀察以上例子可以發現:
- 裝飾器的輸入是一個函式,輸出也是一個函式;
- 被裝飾的函式的一些元資訊(原始函式名、文件字串)被覆蓋了;
- 裝飾器基於閉包。
Python提供了通過@decorator_name的方式使用裝飾器的語法糖。此外,通過使用functools.wraps(func),被裝飾的函式的元資訊能夠得以保留,這有助於程式碼的除錯:
import functools
def uppercase(func):
@functools.wraps(func)
def wrapper():
original_result = func() # 引用了uppercase函式作用域中的變數func
modified_result = original_result.upper()
return modified_result
return wrapper
@uppercase
def make_greeting_words():
"""來段問候語"""
return 'Hello, World!'
make_greeting_words() # 'HELLO, WORLD!'
make_greeting_words.__name__ # 'make_greeting_words'
make_greeting_words.__doc__ # '來段問候語'
帶引數的裝飾器:
import functools
def cache(func):
"""memorization裝飾器,用於提高遞迴效率"""
known = dict()
@functools.wraps(func)
def wrapper(*args):
if args not in known:
known[args] = func(*args)
return known[args]
return wrapper
@cache
def fibonacci(n):
"""計算Fibonacci數列的第n項"""
assert n >= 0, 'n必須大於等於0'
return n if n in {0, 1} else fibonacci(n - 1) + fibonacci(n - 2)
fibonacci(5) # 5
fibonacci(50) # 12586269025
參考資料
- Python Tricks: The Book
- 《你不知道的JavaScript-上卷》第一部分“作用域和閉包”
- 《流暢的Python》第7章“函式裝飾器和閉包”
- Python UnboundLocalError和NameError錯誤根源解析
- Built-in Exceptions
- 《精通Python設計模式》第5章“修飾器模式”