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

觀察以上例子可以發現:

  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章「修飾器模式」