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

1. 一個簡單的裝飾器

定義了一個裝飾器,它會在每次調用被裝飾的函數時計時,然後把經過的時間、傳入的參數和調用的結果打印出來。

# a3_4_decorate.py
import time

def clock(func):
    def clocked(*args):  # 定義內部函數 clocked
        t0 = time.perf_counter()
        res = func(*args)   # 這行代碼可用,是因爲 clocked 的閉包中包含自由變量 func
        elapsed = time.perf_counter() - t0
        name = func.__name__
        args_str = ', '.join(repr(arg) for arg in args)
        print(f'[{elapsed:>2.8}s], {name}({args_str})--> {res}')
        return res
    return clocked  # 返回內部函數,取代被裝飾的函數。
# a3_4_decorate_func.py
import time
from a3_4_decorate import clock

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

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

if __name__ == '__main__':
    test_sleep(1.2)
    test_factorial(6)

# [1.2008935s], test_sleep(1.2)--> None
# [2e-06s], test_factorial(1)--> 1
# [0.0001048s], test_factorial(2)--> 2
# [0.0001478s], test_factorial(3)--> 6
# [0.0001863s], test_factorial(4)--> 24
# [0.0002236s], test_factorial(5)--> 120
# [0.0002705s], test_factorial(6)--> 720

在示例中,test_factorial 會作爲 func 參數傳給 clock。然後,clock 函數會返回 clocked 函數,Python 解釋器在背後會把 clocked 賦值給 test_factorial 。其實,導入 clockdeco_demo 模塊後查看 test_factorial 的 __name__ 屬性:

# a3_4_decorate_func_import.py
import a3_4_decorate_func

print(a3_4_decorate_func.test_factorial.__name__)
print(a3_4_decorate_func.test_sleep.__name__)
# clocked
# clocked

所以,現在 test_factorial 保存的是 clocked 函數的引用。自此之後,每次調用 test_factorial(n),執行的都是 clocked(n)。

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

本例中實現的 clock 裝飾器有幾個缺點:不支持關鍵字參數,而且遮蓋了被裝飾函數的__name____doc__屬性。下面將使用 functools.wraps 裝飾器把相關的屬性從 func複製到 clocked 中。

# a3_4_decorate_wraps.py
import time
from functools import wraps

def clock(func):
    @wraps(func)
    def clocked(*args, **kwargs):
        t0 = time.perf_counter()
        res = func(*args, **kwargs)
        elapsed = time.perf_counter() - t0
        name = func.__name__
        args_list = []
        if args:
            args_list.append(', '.join(repr(arg) for arg in args))
        if kwargs:
            pairs = [f'{k}={v}' for k, v in sorted(kwargs.items())]
            args_list.append(', '.join(pairs))
        args_str = ', '.join(args_list)
        print(f'[{elapsed:>2.8}s], {name}({args_str})--> {res}')
        return res

    return clocked
# a3_4_decorate_wraps_run.py
import time
from a3_4_decorate_wraps import clock

@clock
def test_sleep(seconds, name=None):
    time.sleep(seconds)

@clock
def test_factorial(n, name="fi"):
    return 1 if n < 2 else n * test_factorial(n - 1)

if __name__ == '__main__':
    test_sleep(1.2, name='xiaoming')
    test_factorial(6, name='fi')

# [1.200119s], test_sleep(1.2, name=xiaoming)--> None
# [1.7e-06s], test_factorial(1)--> 1
# [6.01e-05s], test_factorial(2)--> 2
# [8.98e-05s], test_factorial(3)--> 6
# [0.0001158s], test_factorial(4)--> 24
# [0.0001468s], test_factorial(5, name=fi)--> 120

2. 標準庫中的裝飾器

Python 內置了三個用於裝飾方法的函數:property、classmethod 和 staticmethod。

另一個常見的裝飾器是 functools.wraps,它的作用是協助構建行爲良好的裝飾器。

標 準 庫 中 最 值 得 關 注 的 兩 個 裝 飾 器 是 lru_cache 和全新的singledispatch(Python 3.4 新增)。這兩個裝飾器都在 functools 模塊中定義。

2.1 使用functools.lru_cache做備忘

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

生成第 n 個斐波納契數這種慢速遞歸函數適合使用 lru_cache:

from a3_4_decorate import clock

@clock
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-2) + fibonacci(n-1)

if __name__=='__main__':
    print(fibonacci(6))
'''
[9e-07s], fibonacci(0)--> 0
[1.2e-06s], fibonacci(1)--> 1
[9.52e-05s], fibonacci(2)--> 1
[8e-07s], fibonacci(1)--> 1
[1e-06s], fibonacci(0)--> 0
[9e-07s], fibonacci(1)--> 1
[4.64e-05s], fibonacci(2)--> 1
[9.12e-05s], fibonacci(3)--> 2
[0.0002324s], fibonacci(4)--> 3
3
'''

這裏生成第 n 個斐波納契數,遞歸方式非常耗時,fibonacci(0) 調用了 2 次,fibonacci(1) 調用了 3 次……但是,

如果增加兩行代碼,使用 lru_cache,使用緩存實現,速度更快,性能會顯著改善。

from functools import lru_cache
from a3_4_decorate import clock

@lru_cache()
@clock
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-2) + fibonacci(n-1)

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

'''
[1.6e-06s], fibonacci(0)--> 0
[2e-06s], fibonacci(1)--> 1
[0.0001693s], fibonacci(2)--> 1
[3.3e-06s], fibonacci(3)--> 2
[0.0002637s], fibonacci(4)--> 3
3
'''

需要注意的是:必須像常規函數那樣調用 lru_cache。這一行中有一對括號:@functools.lru_cache()。這麼做的原因是,lru_cache 可以接受配置參數。另外,這裏疊放了裝飾器:@lru_cache() 應用到 @clock 返回的函數上。

特別要注意,lru_cache 可以使用兩個可選的參數來配置。它的簽名是:functools.lru_cache(maxsize=128, typed=False):maxsize 參數指定存儲多少個調用的結果。緩存滿了之後,舊的結果會被扔掉,騰出空間。爲了得到最佳性能,maxsize 應該設爲 2 的冪。typed 參數如果設爲 True,把不同參數類型得到的結果分開保存,即把通常認爲相等的浮點數和整數參數(如 1 和 1.0)區分開。順便說一下,因爲 lru_cache 使用字典存儲結果,而且鍵根據調用時傳入的定位參數和關鍵字參數創建,所以被 lru_cache 裝飾的函數,它的所有參數都必須是可散列的。


2.2 單分派泛函數

假設我們在開發一個調試 Web 應用的工具,我們想生成 HTML,顯示不同類型的 Python對象。

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

這個函數適用於任何 Python 類型,但是現在我們想做個擴展,讓它使用特別的方式顯示某些類型。

  • str:把內部的換行符替換爲'<br>\n';不使用<pre>,而是使用<p>

  • int:以十進制和十六進制顯示數字。

  • list:輸出一個 HTML 列表,根據各個元素的類型進行格式化。

因爲 Python 不支持重載方法或函數,所以我們不能使用不同的簽名定義 htmlize 的變體,也無法使用不同的方式處理不同的數據類型。在 Python 中,一種常見的做法是把 htmlize變成一個分派函數,使用一串 if/elif/elif,調用專門的函數,如 htmlize_str、htmlize_int,等等。這樣不便於模塊的用戶擴展,還顯得笨拙:時間一長,分派函數 htmlize 會變得很大,而且它與各個專門函數之間的耦合也很緊密。

Python 3.4 新增的 functools.singledispatch 裝飾器可以把整體方案拆分成多個模塊,甚至可以爲你無法修改的類提供專門的函數。使用 @singledispatch 裝飾的普通函數會變成泛函數(generic function):根據第一個參數的類型,以不同方式執行相同操作的一組函數。

# singledispatch 創建一個自定義的 htmlize.register 裝飾器,
# 把多個函數綁在一起組成一個泛函數
from functools import singledispatch

import numbers
import html
from collections import abc

@singledispatch  # @singledispatch 標記處理 object 類型的基函數
def htmlize(obj):
    content = html.escape(repr(obj))
    return '<pre>{}</pre>'.format(content)

@htmlize.register(str)
def _(text):   # 專門函數的名稱無關緊要;_ 是個不錯的選擇,簡單明瞭。
    content = html.escape(text).replace('\n', '<br>\n')
    return '<p>{0}</p>'.format(content)

@htmlize.register(numbers.Integral)
def _(n):
    return '<pre>{0} (0x{0:x})</pre>'.format(n)

@htmlize.register(tuple)   # 可以疊放多個register裝飾器,讓同一個函數支持不同類型
@htmlize.register(abc.MutableSequence)
def _(seq):
    inner = '</li>\n<li>'.join(htmlize(item) for item in seq)
    return '<ul>\n<li>' + inner + '</li>\n</ul>'

print(htmlize({1, 2, 3}))  # <pre>{1, 2, 3}</pre>
print(htmlize(abs))  # <pre>&lt;built-in function abs&gt;</pre>
print(htmlize('Heimlich & Co.\n- a game'))
# <p>Heimlich &amp; Co.<br>
# - a game</p>
print(htmlize(42))  # <pre>42 (0x2a)</pre>
print(htmlize(['alpha', 66, {3, 2, 1}]))
# <ul>
# <li><p>alpha</p></li>
# <li><pre>66 (0x42)</pre></li>
# <li><pre>{1, 2, 3}</pre></li>
# </ul>

只要可能,註冊的專門函數應該處理抽象基類(如 numbers.Integralabc.MutableSequence),不要處理具體實現(如 int 和 list)。這樣,代碼支持的兼容類型更廣泛。例如,Python擴展可以子類化numbers.Integral,使用固定的位數實現 int 類型。

singledispatch 機制的一個顯著特徵是,你可以在系統的任何地方和任何模塊中註冊專門函數。如果後來在新的模塊中定義了新的類型,可以輕鬆地添加一個新的專門函數來處理那個類型。此外,你還可以爲不是自己編寫的或者不能修改的類添加自定義函數。


3. 疊放裝飾器

把 @d1 和 @d2 兩個裝飾器按順序應用到 f 函數上,作用相當於 f = d1(d2(f))。

def d1(func):
    def decorate():
        pass
    return decorate

def d2(func):
    def decorate():
        pass
    return decorate

@d1 
@d2 
def f(): 
    print('f')
 
# 等同於:
def f(): 
    print('f') 
f = d1(d2(f))

4. 參數化裝飾器

解析源碼中的裝飾器時,Python 把被裝飾的函數作爲第一個參數傳給裝飾器函數。那怎麼讓裝飾器接受其他參數呢?答案是:創建一個裝飾器工廠函數,把參數傳給它,返回一個裝飾器,然後再把它應用到要裝飾的函數上

registry = set()

def register(active=True):
    def decorate(func):  # decorate 這個內部函數是真正的裝飾器;它的參數是一個函數。
        print(f'running register(active={active})->decorate({func})')
        if active:
            registry.add(func)
        else:
            registry.discard(func)
        return func  # decorate 是裝飾器,必須返回一個函數。
    return decorate  # register 是裝飾器工廠函數,因此返回 decorate。


@register(active=False)  # 爲了接受參數,新的register裝飾器必須作爲函數調用。
def f1():
    print('running f1()')

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

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

if __name__ == '__main__':
    print(f'registry:{registry}')
    f1()
    f2()
    f3()
    print(f'registry:{registry}')
    
'''
running register(active=False)->decorate(<function f1 at 0x000001EDB675B378>)
running register(active=True)->decorate(<function f2 at 0x000001EDB675B400>)
registry:{<function f2 at 0x000001EDB675B400>}
running f1()
running f2()
running f3()
registry:{<function f2 at 0x000001EDB675B400>}
'''

@register 工廠函數必須作爲函數調用,並且傳入所需的參數。即使不傳入參數,register 也必須作爲函數調用(@register()),即要返回真正的裝飾器 decorate。

關鍵是,register() 要返回 decorate,然後把它應用到被裝飾的函數上。

如果不使用 @ 句法,那就要像常規函數那樣使用 register;若想把 f 添加到 registry中,則裝飾 f 函數的句法是 register()(f);不想添加(或把它刪除)的話,句法是register(active=False)(f)。

from a3_5_decorate_parameter import register, registry, f1, f2, f3

# running register(active=False)->decorate(<function f1 at 0x000002183A36B400>)
# running register(active=True)->decorate(<function f2 at 0x000002183A36B488>)

print(registry)
# {<function f2 at 0x000002183A36B488>}

register()(f3)
# running register(active=True)->decorate(<function f3 at 0x000002183A36B378>)
print(registry)
# {<function f2 at 0x000002183A36B488>, <function f3 at 0x000002183A36B378>}

register(active=False)(f2)
# running register(active=False)->decorate(<function f2 at 0x000002183A36B488>)
print(registry)
# {<function f3 at 0x000002183A36B378>}

5. 參數化clock裝飾器

爲clock添加一個功能:讓用戶傳入一個格式字符串,控制被裝飾函數的輸出。

import time

DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}'

def clock(fmt=DEFAULT_FMT):  # clock 是參數化裝飾器工廠函數。
    def decorate(func):			# decorate 是真正的裝飾器
        def clocked(*args):		# clocked 包裝被裝飾的函數
            t0 = time.time()
            _res = func(*args)
            result = repr(_res)
            elapsed = time.time() - t0
            name = func.__name__
            args_str = ', '.join(repr(arg) for arg in args)
            print(fmt.format(**locals()))  # 使用 **locals() 是爲了在 fmt 中引用 clocked 的局部變量
            return _res
        return clocked
    return decorate

if __name__ == '__main__':
    @clock()
    def snooze(seconds):
        time.sleep(seconds)

    for i in range(3):
        snooze(.123)
'''
[0.12307549s] snooze((0.123,)) -> None
[0.12305403s] snooze((0.123,)) -> None
[0.12399721s] snooze((0.123,)) -> None
'''
    @clock('{name}: {elapsed}s')
    def snooze(seconds):
        time.sleep(seconds)
    # snooze: 0.12392497062683105s

    @clock('{name}({args}) dt={elapsed:0.3f}s')
    def snooze(seconds):
        time.sleep(seconds)
    # snooze((0.123,)) dt=0.123s
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章