流暢的Python:函數裝飾器和閉包一

函數裝飾器和閉包

1.基礎

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

裝飾器是可調用的對象,其參數是另一個函數(被裝飾的函數)。裝飾器可能會處理被裝飾的函數,然後把它返回,或者將其替換成另一個函數或可調用對象。

demo:

def decor(func):
    def inner():
        print("innner")
    return inner  # 返回inner函數對象

@decor
def test():  # 用deco裝飾test
    print("test func")

test()
# innner

# 以上相當於:
def decor(func):
    def inner():
        print("innner")
    return inner

def test():
    print("test func")

test = decor(test)
test()

嚴格來說,裝飾器只是語法糖。如前所示,裝飾器可以像常規的可調用對象那樣調用,其參數是另一個函數。有時,這樣做更方便,尤其是做元編程(在運行時改變程序的行爲)時。

綜上,裝飾器的一大特性是,能把被裝飾的函數替換成其他函數。第二個特性是,裝飾器在加載模塊時立即執行


2. Python何時執行裝飾器

裝飾器的一個關鍵特性是,它們在被裝飾的函數定義之後立即運行。這通常是在導入時(即 Python 加載模塊時)。

# registration.py
registry_list = []

def register(func):
    print(f'running register {func}')
    registry_list.append(func)
    return func

@register
def f1():
    print(f'running f1()')

@register
def f2():
    print(f'running f2()')

def f3():
    print(f'running f3()')

def main():
    print('running main()')
    print(f'registry_list:{registry_list}')
    f1()
    f2()
    f3()

if __name__ == '__main__':
    main()

把 registration.py 當作腳本運行得到的輸出如下:

$ python3 registration.py
running register <function f1 at 0x000001EECF194F28>
running register <function f2 at 0x000001EED61FA378>
running main()
registry_list:[<function f1 at 0x000001EECF194F28>, <function f2 at 0x000001EED61FA378>]
running f1()
running f2()
running f3()

register 在模塊中其他函數之前運行(兩次)。調用 register 時,傳給它的參數是

被裝飾的函數,例如<function f1 at 0x000001EECF194F28>。

加載模塊後,registry 中有兩個被裝飾函數的引用:f1 和 f2。這兩個函數,以及 f3,只在 main 明確調用它們時才執行。

如果導入 registration.py 模塊(不作爲腳本運行),輸出如下:

>>>import registration
running register <function f1 at 0x000002494681A400>
running register <function f2 at 0x000002494681A488>

此時查看 registry 的值,得到的輸出如下:

>>> registration.registry
[<function f1 at 0x000002494681A400>, <function f2 at 0x000002494681A488>]

雖然示例中的 register 裝飾器原封不動地返回被裝飾的函數,但是這種技術並非沒有用處。很多 Python Web 框架使用這樣的裝飾器把函數添加到某種中央註冊處,例如把URL 模式映射到生成 HTTP 響應的函數上的註冊處。

通過上例可以得出結論:函數裝飾器在導入模塊時立即執行,而被裝飾的函數只在明確調用時運行。

考慮到裝飾器在真實代碼中的常用方式,上例有兩個不尋常的地方:

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

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

# 選擇最佳策略(使用裝飾器)
promo_list = []

def promotion(promo_func):
  	"""promotion 把 promo_func 添加到promo_list中,然後原封不動地將其返回。"""
    promo_list.append(promo_func)
    return promo_func

@promotion  # 被 @promotion 裝飾的函數都會添加到promo_list中。
def fidelity_promo(order):
    """1000積分以上顧客,5%折扣"""
    return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0

@promotion
def bulk_item_promo(order):
    """單個商品20個以上,10%折扣"""
    discount = 0
    for item in order.cart:
        if item.quantity >= 20:
            discount += item.total() * 0.1
    return discount

@promotion
def large_order_promo(order):
    """不同商品10個以上,7%折扣"""
    distinct_items = {item.product for item in order.cart}
    if len(distinct_items) >= 10:
        return order.total() * 0.07
    return 0

def best_promo(order):  # best_promos無需修改,它依賴promo_list
    """
    與其他幾個 *_promo 函數一樣,best_promo 函數的參數是一個Order實例;
    使用生成器表達式把 order 傳給 promos 列表中的各個函數,
    返回折扣額度最大的那個函數。
    """
    return max(promo(order) for promo in promo_list)

與之前給出的方案相比,這個方案有幾個優點:

  • 促銷策略函數無需使用特殊的名稱(即不用以 _promo 結尾)。

  • @promotion 裝飾器突出了被裝飾的函數的作用,還便於臨時禁用某個促銷策略:只需把裝飾器註釋掉。

  • 促銷折扣策略可以在其他模塊中定義,在系統中的任何地方都行,只要使用 @promotion裝飾即可。

不過,多數裝飾器會修改被裝飾的函數。通常,它們會定義一個內部函數,然後將其返回,替換被裝飾的函數。使用內部函數的代碼幾乎都要靠閉包才能正確運作。


4. 變量作用域規則

先看一個demo:

>>> b = 1
>>> def f1(a):
      print(a)
      print(b)
      b = 2
      
>>> f1(0)
0
Traceback (most recent call last):
  File "<pyshell#9>", line 1, in <module>
    f1(0)
  File "<pyshell#8>", line 3, in f1
    print(b)
UnboundLocalError: local variable 'b' referenced before assignment

Python 編譯函數的定義體時,它判斷 b 是局部變量,因爲在函數中給它賦值了。生成的字節碼證實了這種判斷,Python 會嘗試從本地環境獲取 b。後面調用 f1(0) 時,f1 的定義體會獲取並打印局部變量 a 的值,但是嘗試獲取局部變量 b 的值時,發現 b 沒有綁定值。

如果在函數中賦值時想讓解釋器把 b 當成全局變量,要使用 global 聲明。

>>> def f1(a):
      global b
      print(a)
      print(b)
      b = 2
      
>>> f1(0)
0
1

比較字節碼:dis 模塊爲反彙編 Python 函數字節碼提供了簡單的方式

一個函數,讀取一個局部變量和一個全局變量:[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-xgXtoiH5-1575737299531)(../../../markdown_pic/book2_varibale2.png)]
b是局部變量,因爲在函數的定義體中給它賦值了:
在這裏插入圖片描述
這裏的LOAD_GLOBAL 1 (b):加載本地名稱 b,這表明,編譯器把 b 視作局部變量,即使在後面才爲 b 賦值,因爲變量的種類(是不是局部變量)不能改變函數的定義體。


5. 閉包

閉包指延伸了作用域的函數,其中包含函數定義體中引用、但是不在定義體中定義的非全局變量。函數是不是匿名的沒有關係,關鍵是它能訪問定義體之外定義的非全局變量。

假如有個名爲 avg 的函數,它的作用是計算不斷增加的系列值的均值:

# 使用class
class Avg(object):
    def __init__(self):
        self.series = []
    
    def __call__(self, newvalue):
        self.series.append(newvalue)
        return sum(self.series)/len(self.series)

avg = Avg()
print(avg(1))
print(avg(3))


# 使用函數
def average():
    series = []
    def avg(new_value):
        series.append(new_value)
        total = sum(series)
        return total / len(series)
    return avg

avg2 = average()
print(avg2(3))  # 3.0
print(avg2(9))  # 6.0

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-3cNaXw8v-1575737299538)(../../../markdown_pic/book2_closure.png)]
avg 的閉包延伸到那個函數的作用域之外,包含自由變量 series 的綁定。

審查返回的 averager 對象,我們發現 Python 在__code__屬性(表示編譯後的函數定義體)中保存局部變量(co_varnames)和自由變量(co_freevars)的名稱。

avg2.__code__.co_varnames  # ('new_value', 'total')
avg2.__code__.co_freevars  # ('series',)

series 的綁定在返回的 avg2 函數的__closure__屬性中。avg2.__closure__中的各個元素對應於 avg2.__code__.co_freevars中的一個名稱。這些元素是 cell 對象,有個 cell_contents 屬性,保存着真正的值。

print(avg2.__closure__[0].cell_contents)  # [3, 9]

綜上,閉包是一種函數,它會保留定義函數時存在的自由變量的綁定,這樣調用函數時,雖然定義作用域不可用了,但是仍能使用那些綁定。注意,只有嵌套在其他函數中的函數纔可能需要處理不在全局作用域中的外部變量


6. nonlocal聲明

之前我們把所有值存儲在歷史數列中,然後在每次調用 avg時使用 sum 求和。更好的實現方式是,只存儲目前的總值和元素個數,然後使用這兩個數計算均值。

# 計算移動平均值的高階函數,不保存所有歷史值,但有bug
def average():
    total = count = 0
    def avg(new_var):
        total += new_var
        count += 1
        return total / count
    return avg

avg = average()
print(avg(2))
print(avg(8))

如果是用pycharm編碼,這裏有紅色的波浪線直接提示報錯(This inspection detects names that should resolve but don't. Due to dynamic dispatch and duck typing, this is possible in a limited but useful number of cases. Top-level and class-level items are supported better than instance items.), 強行執行後報錯:UnboundLocalError: local variable 'summary' referenced before assignment

問題是,當 count 是數字或任何不可變類型時,count += 1 語句的作用其實與 count = count + 1 一樣。因此,在 averager 的定義體中給 count 賦值時,會將 count 變成局部變量。total 變量也受這個問題影響。

之前沒遇到這個問題,因爲沒有給 series 賦值,我們只是調用 series.append,並把它傳給 sum 和 len。也就是說,我們利用了列表是可變的對象這一事實。

但是對數字、字符串、元組等不可變類型來說,只能讀取,不能更新。如果嘗試重新綁定,例如 count = count + 1,其實會隱式創建局部變量 count。這樣,count 就不是自由變量了,因此不會保存在閉包中。

爲了解決這個問題,Python3 引入了 nonlocal 聲明。它的作用是把變量標記爲自由變量,即使在函數中爲變量賦予新值了,也會變成自由變量。如果爲 nonlocal 聲明的變量賦予新值,閉包中保存的綁定會更新。

def average():
    total = count = 0
    def avg(new_var):
        nonlocal total, count
        total += new_var
        count += 1
        return total / count
    return avg
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章