Python裝飾器:套層殼我變得更強了!

i發表於2022-04-20

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

觀察以上例子可以發現:

  1. 裝飾器的輸入是一個函式,輸出也是一個函式;
  2. 被裝飾的函式的一些元資訊(原始函式名、文件字串)被覆蓋了;
  3. 裝飾器基於閉包。

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

參考資料

  1. Python Tricks: The Book
  2. 《你不知道的JavaScript-上卷》第一部分“作用域和閉包”
  3. 《流暢的Python》第7章“函式裝飾器和閉包”
  4. Python UnboundLocalError和NameError錯誤根源解析
  5. Built-in Exceptions
  6. 《精通Python設計模式》第5章“修飾器模式”

相關文章