Python裝飾器:套層殼我變得更強了
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章「修飾器模式」