Python 超程式設計 - 裝飾器

來份鍋包肉發表於2021-04-30

Python 中提供了一個叫裝飾器的特性,用於在不改變原始物件的情況下,增加新功能或行為。

這也屬於 Python "超程式設計" 的一部分,在編譯時一個物件去試圖修改另一個物件的資訊,實現 "控制一切" 目的。

本篇文章作為裝飾器的基礎篇,在閱讀後應該瞭解如下內容:

  • 裝飾器的原理?
  • 裝飾器如何包裹有引數的函式?
  • 裝飾器本身需要引數怎麼辦?
  • 被裝飾器修飾的函式還是原函式嗎,怎麼解決?
  • 裝飾器巢狀時的順序?
  • 裝飾器常見的應用場景?

裝飾器原理

在具體裝飾器的內容前,先來回顧下 Python 中的基本概念:

1. Python 中,一切都是物件,函式自然也不例外

python 中的物件都會在記憶體中用於屬於自己的一塊區域。在操作具體的物件時,需要通過 “變數” ,變數本身僅是一個指標,指向物件的記憶體地址。

函式作為物件的一種,自然也可以被變數引用。

def hello(name: str):
    print('hello', name)
hello('Ethan')

alias_func_name = hello
alias_func_name('Michael')
# hello Ethan
# hello Michael

alias_func_name 作為函式的引用,當然也可以作為函式被使用。

2. 函式接受的引數和返回值都可以是函式

def inc(x):
    return x + 1

def dec(x):
    return x - 1

def operate(func, x):
    result = func(x)
    return result
    
operate(inc,3)
# 4
operate(dec,3)
# 2

這裡 operate 中接受函式作為引數,並在其內部進行呼叫。

3. 巢狀函式

def increment():
    def inner_increment(number):
        return 1 + number
    return inner_increment()

print(increment(100)) # 101

在 increment 內部,實現對 number add 1 的操作。

回頭再來看下裝飾器的實現:

# def decorator
def decorator_func(func):
    print('enter decorator..')
    def wrapper():
        print('Step1: enter wrapper func.')
        return func()
    return wrapper

# def target func
def normal_func():
    print("Step2: I'm a normal function.")

# use decorator 
normal_func = decorator_func(normal_func)
normal_func()

decorator_func(func) 中,引數 func 表示想要呼叫的函式,wrapper 為巢狀函式,作為裝飾器的返回值。

wrapper 內部會呼叫目標函式 func 並附加自己的行為,最後將 func 執行結果作為返回值。

究其根本,是在目標函式外部套上了一層 wrapper 函式,達到在不改變原始函式本身的情況下,增加一些功能或者行為。

通常使用時,使用 @decorator_func 來簡化呼叫過程的兩行程式碼。

將自定義呼叫裝飾器的兩行程式碼刪掉,使用常規裝飾器的寫法加在 normal_func 的定義處,但卻不呼叫 normal_func,可以發現一個有趣的現象:

# def decorator
def decorator_func(func):
    print('enter decorator..')
    def wrapper():
        print('Step1: enter wrapper func.')
        return func()
    return wrapper

# def target func
@decorator_func
def normal_func():
    print("Step2: I'm a normal function.")

發現 enter decorator.. 在沒有呼叫的情況下被列印到控制檯。

這就說明,此時 normal_func 已經變成了 wrapper 函式。

@decorator_func 其實隱含了 normal_func = decorator_func(normal_func) 這一行程式碼。

對帶有引數的函式使用裝飾器

假設這裡 normal_func 需要接受引數怎麼辦?

很簡單,由於是通過巢狀函式來呼叫目標函式,直接在 wrapper 中增加引數就可以了。

# def decorator
def decorator_func(func):
    def wrapper(*args, **kwargs):
        print('Step1: enter wrapper func.')
        return func(*args, **kwargs)
    return wrapper


# def target func
def normal_func(*args, **kwargs):
    print("Step2: I'm a normal function.")
    print(args)
    print(kwargs)

# use decorator
normal_func = decorator_func(normal_func)
normal_func(1, 2, 3, name='zhang', sex='boy')

使用 *args, **kwargs 是考慮到該 decorator 可以被多個不同的函式使用,而每個函式的引數可能不同。

裝飾器本身需要引數

在裝飾器本身也需要引數時,可以將其巢狀在另一個函式中,實現引數的傳遞。

# def decorator
def decorator_with_args(*args, **kwargs):
    print('Step1: enter wrapper with args func.')
    print(args)
    print(kwargs)

    def decorator_func(func):
        def wrapper(*args, **kwargs):
            print('Step2: enter wrapper func.')
            return func(*args, **kwargs)
        return wrapper
    return decorator_func


# def target func
def normal_func(*args, **kwargs):
    print("Step3: I'm a normal function.")
    print(args)
    print(kwargs)

normal_func = decorator_with_args('first args')(normal_func)
normal_func('hello')

# use @ to replace the above three lines of code
@decorator_with_args('first args')
def normal_func(*args, **kwargs):
    print("Step3: I'm a normal function.")
    print(args)
    print(kwargs)

來分析下 decorator_with_args 函式:

  • 由於 decorator_with_args 接受了任意數量的引數,同時由於 decorator_funcwrapper 作為其內部巢狀函式,自然可以訪問其內部的作用域的變數。這樣就實現了裝飾器引數的自定義。
  • decorator_func 是正常的裝飾器,對目標函式的行為進行包裝。進而需要傳遞目標函式作為引數。

在使用時:

@decorator_with_args('first args') 實際上做的內容,就是 normal_func = decorator_with_args('first args')(normal_func) 的內容:

  1. decorator_with_args('first args') 返回 decorator_func 裝飾器。
  2. decorator_func 接受的正常函式物件作為引數,返回包裝的 wrapper 物件。
  3. 最後將 wrapper 函式重新命名至原來的函式,使其在呼叫時保持一致。

保留原函式資訊

在使用裝飾器時,看起來原函式並沒有被改變,但它的元資訊卻改變了 - 此時的原函式實際是包裹後的 wrapper 函式。

help(normal_func)
print(normal_func.__name__)

# wrapper(*args, **kwargs)
# wrapper

如果想要保留原函式的元資訊,可通過內建的 @functools.wraps(func) 實現:

@functools.wraps(func) 的作用是通過 update_wrapperpartial 將目標函式的元資訊拷貝至 wrapper 函式。

# def decorator
def decorator_with_args(*args, **kwargs):
    print('Step1: enter wrapper with args func.')
    print(args)
    print(kwargs)

    def decorator_func(func):
    	@functools.wraps(func)
        def wrapper(*args, **kwargs):
            print('Step2: enter wrapper func.')
            return func(*args, **kwargs)
        return wrapper
    return decorator_func

裝飾器巢狀

Python 支援對一個函式同時增加多個裝飾器,那麼新增的順序是怎樣的呢?

# def decorator
def decorator_func_1(func):
    print('Step1: enter decorator_func_1..')

    def wrapper():
        print('Step2: enter wrapper1 func.')
        return func()
    return wrapper


def decorator_func_2(func):
    print('Step1: enter decorator_func_2..')

    def wrapper():
        print('Step2: enter wrapper2 func.')
        return func()
    return wrapper

@decorator_func_2
@decorator_func_1
def noraml_func():
    pass

看一下 console 的結果:

Step1: enter decorator_func_1..
Step1: enter decorator_func_2..

fun_1 在前說明, 在對原函式包裝時,採用就近原則,從下到上。

接著,呼叫 noraml_func 函式:

Step1: enter decorator_func_1..
Step1: enter decorator_func_2..
Step2: enter wrapper2 func.
Step2: enter wrapper1 func.

可以發現,wrapper2 內容在前,說明在呼叫過程中由上到下。

上面巢狀的寫法,等價於 normal_func = decorator_func_2(decorator_func_1(normal_func)),就是正常函式的呼叫過程。

對應執行順序:

  1. 在定義時,先 decorator_func_1 後 decorator_func_2.
  2. 在呼叫時,先 decorator_func_2 後 decorator_func_1.

應用場景

日誌記錄

在一些情況下,需要對函式執行的效率進行統計或者記錄一些內容,但又不想改變函式本身的內容,這時裝飾器是一個很好的手段。

import timeit
def timer(func):
    def wrapper(n):
        start = timeit.default_timer()
        result = func(n)
        stop = timeit.default_timer()
        print('Time: ', stop - start)
        return result
    return wrappe

作為快取

裝飾器另外很好的應用場景是充當快取,如 lru 會將函式入參和返回值作為當快取,以計算斐波那契數列為例, 當 n 值大小為 30,執行效率已經有很大差別。

def fib(n):
    if n < 2:
        return 1
    else:
        return fib(n - 1) + fib(n - 2)
        
@functools.lru_cache(128)
def fib_cache(n):
    if n < 2:
        return 1
    else:
        return fib_cache(n - 1) + fib_cache(n - 2)
        
Time:  0.2855725
Time:  3.899999999995574e-05

總結

在這一篇中,我們知道:

裝飾器的本質,就是利用 Python 中的巢狀函式的特點,將目標函式包裹在內嵌函式中,然後將巢狀函式 wrapper作為返回值返回,從而達到修飾原函式的目的。

而且由於返回的是 wrapper 函式,自然函式的元資訊肯定不再是原函式的內容。

對於一個函式被多個裝飾器修飾的情況:

  • 在包裝時,採用就近原則,從近點開始包裝。
  • 在被呼叫時,採用就遠原則,從遠點開始執行。

這自然也符合棧的呼叫過程。

參考

https://www.programiz.com/python-programming/decorator

相關文章