Python-【函數裝飾器 | 閉包】-讀(chao)書筆記

此文章的內容來自《流暢的Python(第一版)》的第七章:函數裝飾器和閉包。因爲自己初學Python,做筆記可以說完全是抄書,到後面越來越沒心勁兒了,所以不再抄書(做筆記)了,,從頁數p171,節數爲7.8.2之後的內容不想也不需要續了。

(無奈~~~)

函數裝飾器用於在源碼中“標記”函數,以某種方式增強函數的行爲。想掌握裝飾器,必須理解閉包。除了在裝飾器中有用處之外,閉包函數回調式異步編程和函數式編程風格的基礎。

這篇文章是爲了解釋清楚函數裝飾器的工作原理,包括最簡單的註冊裝飾器和較複雜的參數化裝飾器。

1 裝飾器基礎知識

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

假如有個名爲decorate的裝飾器:

@decorate
def target():
    print('running target()')

上述代碼的效果與下述寫法一樣:

def target():
    print('running target()')

target = decorate(target)

兩種寫法的最終結果一樣:上述兩個代碼片段執行完畢後得到的target不一定是原來那個target函數,而是decorate(target)返回的函數。

爲了確認被裝飾的函數會被替換,請看下面的控制檯對話。

# 裝飾器通常把函數替換成另一個函數

>>> def deco(func):
...     def inner():
...         print('running inner()')
...     return inner # deco返回inner函數對象
...
>>> @deco
... def target(): # 使用deco裝飾target
...     print('running target()')
...
>>> target() # 調用被裝飾的target其實會運行inner
running inner()
>>> target # 審查對象,發現target現在是inner的引用
<function deco.<locals>.inner at 0x000001FB4640A550>

嚴格來說,裝飾器只是語法糖。如上所示,裝飾器可以像常規的可調用對象那樣調用,其參數是另一個函數。綜上,裝飾器的一大特性是,能把被裝飾的函數替換成其他函數。第二個特性是,裝飾器在加載模塊時立即執行。

2 Python何時執行裝飾器

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

registry = []

def register(func):
    print('running register(%s)' % func)
    registry.append(func)
    return func

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

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

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

def main():
    print('running main()')
    print('registry ->', registry)
    f1()
    f2()
    f3()

if __name__ == '__main__':
    main()

把registration.py當做腳本得到的輸出如下:

running register(<function f1 at 0x000002B32DA25430>)
running register(<function f2 at 0x000002B32DA254C0>)
running main()
registry -> [<function f1 at 0x000002B32DA25430>, <function f2 at 0x000002B32DA254C0>]
running f1()
running f2()
running f3()

注意,register在模塊中其他函數之前運行(兩次)。調用register時,傳給它的參數是被裝飾的函數,例如<function f1 at 0x000002B32DA25430>。加載模塊後,registry中有兩個被裝飾函數的引用:f1和f2。f1、f2、f3這三個函數都是隻有main明確調用它們時才執行。

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

>>> import registration
running register(<function f1 at 0x00000238FDF2A550>)
running register(<function f2 at 0x00000238FDF2A5E0>)

查看registry的值如下:

>>> registration.registry
[<function f1 at 0x00000238FDF2A550>, <function f2 at 0x00000238FDF2A5E0>]

上面的registration.py想強調,函數裝飾器在導入模塊時立即執行,而被裝飾的函數只在明確調用時運行。這突出了Python程序員所說的導入時和運行時之間的區別。

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

1、裝飾器函數與被裝飾的函數在同一模塊中定義。事實上,裝飾器通常在一個模塊中定義,然後應用到其他模塊中的函數上。

2、register裝飾器返回的函數與通過參數傳入的函數是相同的。事實上,大多數裝飾器會在內部定義一個函數,然後將這個函數返回。

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

使用註冊裝飾器可以改進《流暢的Python》書中6.1節中的電商促銷折扣示例。

示例6-6的主要問題是,定義體中有函數的名稱,但是best_promo用來判斷那個折扣幅度最大的promos列表中也有函數名稱。這種重複是個問題,因爲新增策略函數後可能會忘記把他添加到promos列表中,導致best_promo忽略新策略,而且不報錯,爲系統引入了不易察覺的缺陷。下面的代碼使用註冊裝飾器解決了這個問題:

# promos列表中的值使用promotion裝飾器填充

promos = [] # 存放策略的列表

def promotion(promo_func): # promotion將promo_func添加到promos列表中,然後原封不動地將函數返回
    promos.append(promo_func)
    return promo_func

@promotion
def fidelity(order):
    """爲積分爲1000或以上的顧客提供5%折扣"""
    return order.total() * .05 if order.customer.fidelity >= 1000 else 0

@promotion
def bulk_item(order):
    """單個商品爲20個或以上時提供10%折扣"""
    discount = 0
    for item in order.cart: # 遍歷購物車的商品
        if item.quantity >= 20:
            discount += item.total() * .1
    return discount

@promotion
def large_order(order):
    """訂單中的不同商品達到10個或以上時提供7%折扣"""
    distinct_items= {item.product for item in order.cart}
    if len(distinct_items) >= 10:
        return order.total() * .07
    return 0

def best_promo(order): # best_promo無需修改,因爲它依賴promos列表
    """選擇可用的最佳折扣"""
    return max(promo(order) for promo in promos)

上面的代碼有幾個優點:

  • 促銷策略函數無需使用特殊的名稱(不需要以_promo結尾)
  • @promotion裝飾器突出了被裝飾的函數的作用,還便於臨時禁用某個策略函數——只需把裝飾器註釋掉
  • 促銷折扣策略可以在其他模塊中定義,只要使用@promotion裝飾即可。

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

4 變量作用域規則

在下面的代碼中,定義並測試了一個函數,此函數讀取兩個變量的值:局部變量a——函數的參數;變量b——這個函數沒定義它。

>>> def f1(a):
...     print(a)
...     print(b)
...
>>> f1(3)
3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in f1
NameError: name 'b' is not defined

出現錯誤很正常。在代碼中,若先給全局變量b賦值,再調用f1,則不會出錯:

>>> b = 6
>>> f1(3)
3
6

下面看一個特殊的例子。下面代碼中的f2函數的兩行代碼和上面的f1一樣,然後爲b賦值,再打印b的值。可是在賦值前,第二個print失敗了:

>>> # b是局部變量,因爲在函數的定義體中給它賦值了
>>> b = 6
>>> def f2(a):
...     print(a)
...     print(b)
...     b = 9
...
>>> f2(3)
3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in f2
UnboundLocalError: local variable 'b' referenced before assignment

注意,首先輸出了3,這表明print(a)語句執行了。但是第二個語句print(b)執行不了。感覺會打印6,因爲有個全局變量b,而且是在print(b)之後爲局部變量b賦值的。

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

這不是缺陷,而是設計選擇:Python不要求聲明變量,但是假定在函數定義體中賦值的變量是局部變量

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

>>> b = 6
>>> def f3(a):
...     global b
...     print(a)
...     print(b)
...     b = 9
...
>>> f3(3)
3
6
>>> b
9
>>> f3(3)
3
9

5 閉包

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

概念難以掌握,下面通過示例理解:假如有個名爲avg的函數,它的作用是計算不斷增加的序列值的均值;例如,整個歷史中某個商品的平均收盤價。每天都會增加新價格,因此平均值要考慮至目前爲止所有的價格。

起初,avg是這樣用的:

>>> avg(10) # 事實上還沒有定義avg函數,但是下面的結果符合預期
10.0
>>> avg(11)
10.5
>>> avg(10)
11

下面是一個【計算運動平均值的】average_oo.py:

class Averager():
    def __init__(self):
        self.series = []
        
    def __call__(self, new_value):
        self.series.append(new_value)
        total = sum(self.series)
        return total/len(self.series)

Average的實例是可調用對象:

>>> avg = Averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

下面是函數式實現計算移動平均值,使用高階函數make_averager。averager.py:

def make_averager(): # # 計算移動平均值的高階函數
    series = []
    def averager(new_value):
        series.append(new_value)
        total = sum(series)
        return total/len(series)
    return averager

調用make_averager時,返回一個averager函數對象。每次調用averager時,它會把參數添加到系列中,然後計算當前平均值。下面的代碼爲了測試averager.py:

>>> avg = make_averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

注意,averager.py與average_oo.py有共通之處:調用Averager()或make_averager()得到一個可調用對象avg,他會更新歷史值,再計算當前均值。在average_oo.py中,avg是Averager的實例;在averager.py中,avg是內部函數averager。不管怎樣,只需調用avg(n),把n放入序列值中,然後重新計算均值。

Averager類的實例avg在self.series裏存儲歷史值。但是第二個示例中的avg函數在哪裏查找series呢?

注意series是make_averager函數的局部變量,因爲在函數的定義體中初始化了series:series = []。但是,調用avg(10)時,make_averager函數已經返回了,而它的本地作用域也一去不復返了。

在averager函數中,series是自由變量(free variable),指未在本地作用域中綁定的變量。

審查返回的make_averager對象,發現Python在__code__屬性(表示編譯後的函數定義體)中保存局部變量和自由變量的名稱。下面是【審查make_averager創建的函數】的代碼:

>>> avg.__code__.co_varnames
('new_value', 'total')
>>> avg.__code__.co_freevars
('series',)

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

>>> avg.__code__.co_freevars
('series',)
>>> avg.__closure__
(<cell at 0x000001E467125B80: list object at 0x000001E467141400>,)
>>> avg.__closure__[0].cell_contents
[10, 11, 12]

綜上,閉包是一種函數,它會保留定義函數時存在的自由變量的綁定,這樣調用函數時,雖然定義作用域不可用了,但是仍能使用那些綁定。

注意,只有嵌套在其他函數中的函數纔可能需要處理不在全局作用域中的外部變量

6 nonlocal聲明

在上一節的averager.py中,程序將所有值存儲在歷史序列中,然後在每次調用averager時使用sum求和。更好的方式是,只存儲目前的總值和元素個數,然後使用這兩個數計算均值。

下面的代碼有缺陷,只是爲了闡明觀點:

def make_averager(): # averager1.py 計算移動平均值的高級函數,不保存所有歷史值,但是有缺陷
    count = 0
    total = 0

    def averager(new_value):
        count += 1
        total += new_value
        return total/count
    return averager

嘗試使用上面的函數,會得到如下結果:

>>> avg = make_averager()
>>> avg(10)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "D:\其他\Pycharm學習\averager1.py", line 6, in averager
    count += 1
UnboundLocalError: local variable 'count' referenced before assignment

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

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

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

Python引入nonlocal聲明,作用是把變量標記爲自由變量,即使在函數中爲變量賦予新值,也會變爲自由變量。若爲nonlocal聲明的變量賦予新值,閉包中保存的綁定會更新。make_averager的最終正確實現版如下:

def make_averager():
    count = 0
    total = 0

    def averager(new_value):
        nonlocal count, total
        count += 1
        total += new_value
        return total / count
    return averager

7 實現一個簡單的裝飾器

下面的代碼定義了一個裝飾器,功能是在每次調用被裝飾的函數時計時,然後把經過的時間、傳入的參數和調用的結果打印出來(clockdeco.py):

import time

def clock(func):
    def clocked(*args): # 定義內部函數clocked,它接受任意個定位參數
        t0 = time.perf_counter()
        result = fun(*args)
        elapsed = time.perf_counter() - t0
        name = func.__name__
        arg_str = ', '.join(repr(arg) for arg in args)
        print('[%0.8fs] %s(%s) -> %r' % (elapsed, name, arg_str, result))
        return result
    return clocked # 返回內部函數,取代被裝飾的函數

下面的代碼演示了clock裝飾器的用法(clockdeco_demo.py):

# clockdeco_demo.py 使用clock裝飾器

import time
from clockdeco import clock

@clock
def snooze(seconds):
    time.sleep(seconds)

@clock
def factorial(n):
    return 1 if n < 2 else n * factorial(n - 1)

if __name__ == '__main__':
    print('*' * 40, 'Calling snooze(.123)')
    snooze(.123)
    print('*' * 40, 'Calling factorial(6)')
    print('6! =', factorial(6))

輸出結果爲:

**************************************** Calling snooze(.123)
[0.12318430s] snooze(0.123) -> None
**************************************** Calling factorial(6)
[0.00000200s] factorial(1) -> 1
[0.00004970s] factorial(2) -> 2
[0.00008730s] factorial(3) -> 6
[0.00011450s] factorial(4) -> 24
[0.00014130s] factorial(5) -> 120
[0.00017300s] factorial(6) -> 720
6! = 720

裝飾器的典型行爲是:把被裝飾的函數替換爲新函數,二者接受相同的參數,而且(通常)返回被裝飾函數本該返回的值,同時還會做些額外操作。

clockdeco.py實現的裝飾器有以下缺點:不支持關鍵字參數,而且覆蓋了被裝飾函數的__name__和__doc__屬性。下面的代碼(clockdeco2.py)使用functools.wraps裝飾器把相關的屬性從func複製到clocked中,而且代碼還可以處理關鍵字參數:

import time
import functools
def clock(func):
    @functools.wraps(func) # 裝飾器將相關的屬性從func複製到clocked中
    def clocked(*args, **kwargs):
        t0 = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - t0
        name = func.__name__
        arg_lst = []
        if args:
            arg_lst.append(', '.join(repr(arg) for arg in args))
        if kwargs:
            pairs = ['%s=%r' % (k, w) for k, w in sorted(kwargs.items())]
            arg_lst.append(', '.join(pairs))
        arg_str = ', '.join(arg_lst)
        print('[%0.8fs] %s(%s) -> %r' % (elapsed, name, arg_str, result))
        return result
    return clocked

8 標準庫中的裝飾器

下面介紹標準庫中的兩個值得關注的裝飾器。

8.1 使用functools.lru_cache做備忘

functools.lru_cache是非常實用的裝飾器,它實現了備忘功能。這是一項優化技術,它把耗時的函數的結果保存起來,避免傳入相同的參數時重複計算。lru三個字母是“Least Recently Used”的縮寫,表明緩存不會無限制增長,一段時間不用的緩存條目會被丟棄。

生成第n個斐波那契數這種慢速遞歸函數適合使用lru_cache。下面的代碼(fibo_demo.py)未使用lru_cache裝飾器,遞歸方式非常耗時。代碼如下:

from clockdeco import clock

@clock
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-2) + fibonacci(n-1)
if __name__ == '__main__':
    print(fibonacci(6))

運行fibo_demo.py,結果如下,除了最後一行,其餘輸出都是clock裝飾器生成的:

[0.00000040s] fibonacci(0) -> 0
[0.00000050s] fibonacci(1) -> 1
[0.00005350s] fibonacci(2) -> 1
[0.00000030s] fibonacci(1) -> 1
[0.00000030s] fibonacci(0) -> 0
[0.00000020s] fibonacci(1) -> 1
[0.00001230s] fibonacci(2) -> 1
[0.00002400s] fibonacci(3) -> 2
[0.00008970s] fibonacci(4) -> 3
[0.00000020s] fibonacci(1) -> 1
[0.00000020s] fibonacci(0) -> 0
[0.00000020s] fibonacci(1) -> 1
[0.00001140s] fibonacci(2) -> 1
[0.00002280s] fibonacci(3) -> 2
[0.00000020s] fibonacci(0) -> 0
[0.00000030s] fibonacci(1) -> 1
[0.00001150s] fibonacci(2) -> 1
[0.00000020s] fibonacci(1) -> 1
[0.00000030s] fibonacci(0) -> 0
[0.00000030s] fibonacci(1) -> 1
[0.00001210s] fibonacci(2) -> 1
[0.00002340s] fibonacci(3) -> 2
[0.00004610s] fibonacci(4) -> 3
[0.00008010s] fibonacci(5) -> 5
[0.00018170s] fibonacci(6) -> 8
8

上述代碼浪費時間的地方很明顯:fibonacci(1)調用了8次,fibonacci(2)調用了5次。下面的代碼(fibo_demo2.py)使用lru_cache裝飾器,性能會顯著改善:

import functools
from clockdeco import clock

@functools.lru_cache() # 必須像常規函數那樣調用lru_cache。有括號(),這樣做使得lru_cache可接受配置參數,稍後會詳細說明
@clock # 疊放了裝飾器:@lru_cache()應用到@clock返回的函數上
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-2) + fibonacci(n-1)

if __name__ == '__main__':
    print(fibonacci(6))

這樣一來,執行時間減半了,而且n的每個值只調用一次函數:

[0.00000090s] fibonacci(0) -> 0
[0.00000140s] fibonacci(1) -> 1
[0.00641200s] fibonacci(2) -> 1
[0.00000090s] fibonacci(3) -> 2
[0.00643280s] fibonacci(4) -> 3
[0.00000070s] fibonacci(5) -> 5
[0.00645350s] fibonacci(6) -> 8
8

除優化遞歸算法外,lru_cache在從web中獲取信息的應用中也能發揮巨大作用。

lru_cache可以使用兩個可選參數來配置。它的簽名(簽名是表示調用函數的方式,即定義了函數的輸入和輸出)是:

functools.lru_cache(maxsize=128, typed=False)

maxsize參數指定存儲多少個調用的結果。緩存滿了以後,舊的結果會被扔掉,以騰出空間。爲得到最佳性能,maxsize應設爲2的冪。typed參數如果設爲True,就會把不同參數類型得到的結果分開保存,即把通常認爲相等的浮點數和整數參數(如1和1.0)區分開。因爲lru_cache使用字典存儲結果,而且鍵根據調用時傳入的定位參數和關鍵字參數創建,所以被lru_cache裝飾的函數,它的所有參數都必須是可散列(不懂就去百度)的。

下面介紹functools.singledispatch

8.2 單分配泛函數

假設在開發一個調試web應用的工具,我們像生成HTML,顯示不同類型的Python對象。我們可能編寫這樣的函數:

import html

def htmlize(obj):
    content = html.escape(repr(obj))
    return '<pre>pre</pre>'.format(content)

上面的函數適用於任何Python類型。現在,想做個拓展,讓函數使用特別的方式顯示某些類型的數據:

  • str:把內部的換行符替換爲’<br>\n‘;不使用<pre>,而是使用<p>。
  • int:以十進制和十六進制顯示數字。
  • list:輸出一個HTML列表,根據各個元素的類型進行格式化。

我們想要的行爲(生成HTML的htmlize函數,調整了幾種對象的輸出)如下:

>>> # 本段所有代碼的輸出結果都是理想輸出,後面會列出真正實現了這些功能的代碼
>>> htmlize({1, 2, 3}) # 1
'<pre>{1, 2, 3}</pre>'
>>> htmlize(abs)
'<pre>&lt;built-in function abs&gt;</pre>'
>>> htmlize('Heimlich & Co.\n- a game') # 2
'<p>Heimlich &amp; Co.<br>\n- a game</p>'
>>> htmlize(42) # 3
'<pre>42 (0x2a)</pre>'
>>> print(htmlize(['alpha', 66, {3, 2, 1}])) # 4
<ul>
<li><p>alpha</p></li>
<li><pre>66 (0x42)</pre></li>
<li><pre>{1, 2, 3}</pre></li>
</ul>

註釋:
1.默認情況下,在<pre><\pre>中顯示HTML轉義後的對象字符串表示形式。
2.爲str對象顯示的也是HTML轉義後的字符串表示形式,不過放在<p></p>中,而且使用<br>表示換行。
3.int顯示爲十進制和十六進制兩種形式,放在<pre></pre>中。
4.各個列表項目根據各自的類型格式化,整個列表則渲染成HTML列表。

此文章的內容來自《流暢的Python(第一版)》的第七章:函數裝飾器和閉包。因爲自己初學Python,做筆記可以說完全是抄書,到後面越來越沒心勁兒了,所以不再抄書(做筆記)了,,從頁數p171,節數爲7.8.2之後的內容不想也不需要續了。

(無奈~~~)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章