Python 中級知識之裝飾器,滾雪球學 Python

2021-03-10 12:00:16

橡皮擦,一個逗趣的網際網路高階網蟲,新的系列,讓我們一起 Be More Pythonic

已完成的文章清單

  1. 滾雪球學 Python 第二輪開啟,進階之路,列表與元組那些事兒
  2. 說完列表說字典,說完字典說集合,滾雪球學 Python
  3. 關於 Python 中的字串,我在補充兩點,滾雪球學 Python
  4. 列表推導式與字典推導式,滾雪球學 Python
  5. 滾雪球學 Python 之 lambda 表示式
  6. 滾雪球學 Python 之內建函數:filter、map、reduce、zip、enumerate

七、函數裝飾器

裝飾器(Decorators)在 Python 中,主要作用是修改函數的功能,而且修改前提是不變動原函數程式碼,裝飾器會返回一個函數物件,所以有的地方會把裝飾器叫做 「函數的函數」。
還存在一種設計模式叫做 「裝飾器模式」,這個後續的課程會有所涉及。

裝飾器呼叫的時候,使用 @,它是 Python 提供的一種程式設計語法糖,使用了之後會讓你的程式碼看起來更加 Pythonic

7.1 裝飾器基本使用

在學習裝飾器的時候,最常見的一個案例,就是統計某個函數的執行時間,接下來就為你分享一下。
計算函數執行時間:

import time

def fun():
    i = 0
    while i < 1000:
        i += 1
def fun1():
    i = 0
    while i < 10000:
        i += 1
s_time = time.perf_counter()
fun()
e_time = time.perf_counter()
print(f"函數{fun.__name__}執行時間是:{e_time-s_time}")

如果你希望給每個函授都加上呼叫時間,那工作量是巨大的,你需要重複的修改函數內部程式碼,或者修改函數呼叫位置的程式碼。在這種需求下,裝飾器語法出現了。

先看一下第一種修改方法,這種方法沒有增加裝飾器,但是編寫了一個通用的函數,利用 Python 中函數可以作為引數這一特性,完成了程式碼的可複用性。

import time
def fun():
    i = 0
    while i < 1000:
        i += 1

def fun1():
    i = 0
    while i < 10000:
        i += 1

def go(fun):
    s_time = time.perf_counter()
    fun()
    e_time = time.perf_counter()
    print(f"函數{fun.__name__}執行時間是:{e_time-s_time}")

if __name__ == "__main__":
    go(fun1)

接下來這種技巧擴充套件到 Python 中的裝飾器語法,具體修改如下:

import time

def go(func):
	# 這裡的 wrapper 函數名可以為任意名稱
    def wrapper():
        s_time = time.perf_counter()
        func()
        e_time = time.perf_counter()
        print(f"函數{func.__name__}執行時間是:{e_time-s_time}")
    return wrapper

@go
def func():
    i = 0
    while i < 1000:
        i += 1
@go
def func1():
    i = 0
    while i < 10000:
        i += 1

if __name__ == '__main__':
    func()

在上述程式碼中,注意看 go 函數部分,它的引數 func 是一個函數,返回值是一個內部函數,執行程式碼之後相當於給原函數注入了計算時間的程式碼。在程式碼呼叫部分,你沒有做任何修改,函數 func 就具備了更多的功能(計算執行時間的功能)。

裝飾器函數成功拓展了原函數的功能,又不需要修改原函數程式碼,這個案例學會之後,你就已經初步瞭解了裝飾器。

7.2 對帶引數的函數進行裝飾

直接看程式碼,瞭解如何對帶引數的函數進行裝飾:

import time

def go(func):
    def wrapper(x, y):
        s_time = time.perf_counter()
        func(x, y)
        e_time = time.perf_counter()
        print(f"函數{func.__name__}執行時間是:{e_time-s_time}")
    return wrapper

@go
def func(x, y):
    i = 0
    while i < 1000:
        i += 1
    print(f"x={x},y={y}")

if __name__ == '__main__':
    func(33, 55)

如果你看著暈乎了,我給你標記一下引數的重點傳遞過程。

在這裡插入圖片描述
還有一種情況是裝飾器本身帶有引數,例如下述程式碼:

def log(text):
    def decorator(func):
        def wrapper(x):
            print('%s %s():' % (text, func.__name__))
            func(x)
        return wrapper
    return decorator

@log('執行')
def my_fun(x):
    print(f"我是 my_fun 函數,我的引數 {x}")

my_fun(123)

上述程式碼在編寫裝飾器函數的時候,在裝飾器函數外層又巢狀了一層函數,最終程式碼的執行順序如下所示:

my_fun = log('執行')(my_fun)

此時如果我們總結一下,就能得到結論了:使用帶有引數的裝飾器,是在裝飾器外面又包裹了一個函數,使用該函數接收引數,並且返回一個裝飾器函數。
還有一點要注意的是裝飾器只能接收一個引數,而且必須是函數型別。
Python 中級知識之裝飾器,滾雪球學 Python

7.3 多個裝飾器

先臨摹一下下述程式碼,再進行學習與研究。

import time

def go(func):
    def wrapper(x, y):
        s_time = time.perf_counter()
        func(x, y)
        e_time = time.perf_counter()
        print(f"函數{func.__name__}執行時間是:{e_time-s_time}")
    return wrapper

def gogo(func):
    def wrapper(x, y):
        print("我是第二個裝飾器")
    return wrapper

@go
@gogo
def func(x, y):
    i = 0
    while i < 1000:
        i += 1
    print(f"x={x},y={y}")

if __name__ == '__main__':
    func(33, 55)

程式碼執行之後,輸出結果為:

我是第二個裝飾器
函數wrapper執行時間是:0.0034401339999999975

雖說多個裝飾器使用起來非常簡單,但是問題也出現了,print(f"x={x},y={y}") 這段程式碼執行結果丟失了,這裡就涉及多個裝飾器執行順序問題了。

先解釋一下裝飾器的裝飾順序。

import time
def d1(func):
    def wrapper1():
        print("裝飾器1開始裝飾")
        func()
        print("裝飾器1結束裝飾")
    return wrapper1

def d2(func):
    def wrapper2():
        print("裝飾器2開始裝飾")
        func()
        print("裝飾器2結束裝飾")
    return wrapper2

@d1
@d2
def func():
    print("被裝飾的函數")

if __name__ == '__main__':
    func()

上述程式碼執行的結果為:

裝飾器1開始裝飾
裝飾器2開始裝飾
被裝飾的函數
裝飾器2結束裝飾
裝飾器1結束裝飾

可以看到非常對稱的輸出,同時證明被裝飾的函數在最內層,轉換成函數呼叫的程式碼如下:

d1(d2(func))

你在這部分需要注意的是,裝飾器的外函數內函數之間的語句,是沒有裝飾到目標函數上的,而是在裝載裝飾器時的附加操作。
在對函數進行裝飾的時候,外函數與內函數之間的程式碼會被執行。

測試效果如下:

import time

def d1(func):
    print("我是 d1 內外函數之間的程式碼")
    def wrapper1():
        print("裝飾器1開始裝飾")
        func()
        print("裝飾器1結束裝飾")
    return wrapper1

def d2(func):
    print("我是 d2 內外函數之間的程式碼")
    def wrapper2():
        print("裝飾器2開始裝飾")
        func()
        print("裝飾器2結束裝飾")
    return wrapper2

@d1
@d2
def func():
    print("被裝飾的函數")

執行之後,你就能發現輸出結果如下:

我是 d2 內外函數之間的程式碼
我是 d1 內外函數之間的程式碼

d2 函數早於 d1 函數執行。

接下來在回顧一下裝飾器的概念:
被裝飾的函數的名字會被當作引數傳遞給裝飾函數。
裝飾函數執行它自己內部的程式碼後,會將它的返回值賦值給被裝飾的函數。

這樣看上文中的程式碼執行過程是這樣的,d1(d2(func)) 執行 d2(func) 之後,原來的 func 這個函數名會指向 wrapper2 函數,執行 d1(wrapper2) 函數之後,wrapper2 這個函數名又會指向 wrapper1。因此最後的 func 被呼叫的時候,相當於程式碼已經切換成如下內容了。

# 第一步
def wrapper2():
     print("裝飾器2開始裝飾")
     print("被裝飾的函數")
     print("裝飾器2結束裝飾")

# 第二步
print("裝飾器1開始裝飾")
wrapper2()
print("裝飾器1結束裝飾")

# 第三步
def wrapper1():
	print("裝飾器1開始裝飾")
	print("裝飾器2開始裝飾")
    print("被裝飾的函數")
    print("裝飾器2結束裝飾")
	print("裝飾器1結束裝飾")

上述第三步執行之後的程式碼,恰好與我們的程式碼輸出一致。

那現在再回到本小節一開始的案例,為何輸出資料丟失掉了。

import time

def go(func):
    def wrapper(x, y):
        s_time = time.perf_counter()
        func(x, y)
        e_time = time.perf_counter()
        print(f"函數{func.__name__}執行時間是:{e_time-s_time}")
    return wrapper

def gogo(func):
    def wrapper(x, y):
        print("我是第二個裝飾器")
    return wrapper

@go
@gogo
def func(x, y):
    i = 0
    while i < 1000:
        i += 1
    print(f"x={x},y={y}")

if __name__ == '__main__':
    func(33, 55)

在執行裝飾器程式碼裝飾之後,呼叫 func(33,55) 已經切換為 go(gogo(func)),執行 gogo(func) 程式碼轉換為下述內容:

def wrapper(x, y):
	print("我是第二個裝飾器")

在執行 go(wrapper),程式碼轉換為:

s_time = time.perf_counter()
print("我是第二個裝飾器")
e_time = time.perf_counter()
print(f"函數{func.__name__}執行時間是:{e_time-s_time}")

此時,你會發現引數在執行過程被丟掉了。

7.4 functools.wraps

使用裝飾器可以大幅度提高程式碼的複用性,但是缺點就是原函數的元資訊丟失了,比如函數的 __doc____name__

# 裝飾器
def logged(func):
    def logging(*args, **kwargs):
        print(func.__name__)
        print(func.__doc__)
        func(*args, **kwargs)
    return logging

# 函數
@logged
def f(x):
    """函數檔案,說明"""
    return x * x

print(f.__name__) # 輸出 logging
print(f.__doc__) # 輸出 None

解決辦法非常簡單,匯入 from functools import wraps ,修改程式碼為下述內容:

from functools import wraps
# 裝飾器
def logged(func):
    @wraps(func)
    def logging(*args, **kwargs):
        print(func.__name__)
        print(func.__doc__)
        func(*args, **kwargs)
    return logging

# 函數
@logged
def f(x):
    """函數檔案,說明"""
    return x * x

print(f.__name__) # 輸出 f
print(f.__doc__)  # 輸出 函數檔案,說明

7.5 基於類的裝飾器

在實際編碼中 一般 「函數裝飾器」 最為常見,「類裝飾器」 出現的頻率要少很多。

基於類的裝飾器與基於函數的基本用法一致,先看一段程式碼:

class H1(object):
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        return '<h1>' + self.func(*args, **kwargs) + '</h1>'

@H1
def text(name):
    return f'text {name}'

s = text('class')
print(s)

H1 有兩個方法:

  • __init__:接收一個函數作為引數,就是待被裝飾的函數;
  • __call__:讓類物件可以呼叫,類似函數呼叫,觸發點是被裝飾的函數呼叫時觸發。

最後在附錄一篇寫的不錯的 部落格,可以去學習。

在這裡類裝飾器的細節就不在展開了,等到後面滾雪球相關專案實操環節再說。

裝飾器為類和類的裝飾器在細節上是不同的,上文提及的是裝飾器為類,你可以在思考一下如何給類新增裝飾器。

7.6 內建裝飾器

常見的內建裝飾器有 @property@staticmethod@classmethod。該部分內容在細化物件導向部分進行說明,本文只做簡單的備註。

7.6.1 @property

把類內方法當成屬性來使用,必須要有返回值,相當於 getter,如果沒有定義 @func.setter 修飾方法,是唯讀屬性。

7.6.2 @staticmethod

靜態方法,不需要表示自身物件的 self 和自身類的 cls 引數,就跟使用函數一樣。

7.6.3 @classmethod

類方法,不需要 self 引數,但第一個引數需要是表示自身類的 cls 引數。

7.7 這篇部落格的總結

關於 Python 裝飾器,網上的文章實在太太多了,學習起來並不是很難,真正難的是恰到好處的應用在專案中,希望本篇部落格能對你理解裝飾器有所幫助。
其他內容也可以查閱 官方手冊

相關閱讀

  1. Python 爬蟲 100 例教學,超棒的爬蟲教學,立即訂閱吧
  2. Python 爬蟲小課,精彩 9 講

今天是持續寫作的第 103 / 200 天。
如果你想跟博主建立親密關係,可以關注同名公眾號 夢想橡皮擦,近距離接觸一個逗趣的網際網路高階網蟲。
博主 ID:夢想橡皮擦,希望大家點贊評論收藏

夢想橡皮擦 CSDN認證部落格專家 高階產品經理 網際網路從業者 業餘程式設計愛好者
10 年網際網路從業經驗,Python 爬蟲 100 例作者,藍橋簽約作者,同名公眾號【夢想橡皮擦】