BACK
Featured image of post 【Python】裝飾子 (decorator) 教學

【Python】裝飾子 (decorator) 教學

Python 的 decorator (或稱: 裝飾子) 是一個非常有用的功能,它的重要程度可以說是沒用過或不會用 decorator 的人就等於沒學過 Python, 甚至在一些常見的框架(Framework),例如 Flask, FastAPI, Django 都提供各種方便的 decorator 供大家使用。

參考網站
參考網站
參考網站
參考網站
參考網站

Python 的 decorator (或稱: 裝飾子) 是一個非常有用的功能,它的重要程度可以說是沒用過或不會用 decorator 的人就等於沒學過 Python,甚至在一些常見的框架(Framework),例如 FlaskFastAPIDjango 都提供各種方便的 decorator 供大家使用。

這麼重要的東西,肯定是闖江湖都會用到的金創藥啊!

但如果你剛接觸 Python 就看到類似以下裝飾子的範例,絕對會腦袋打結,為什麼函式前面還要加個 @debug 還有 @cache,而且還很神奇能運作:

1
2
3
4
@debug
@cache
def sum(a, b):
  return a + b

本文就教大家如何理解 Python 的 decorator!


本文 python 環境

  • Python 3

什麼是 Decorator

簡單來說,Decorator 是程式語言的設計模式,也是一種特殊的 function(把被裝飾函式當做參數傳入裝飾器函式,再把被裝飾函式傳回),透過 Decorator 可以將加上 Decorator 的 function 加上更多能力,重複利用許多程式碼。而在 Python 中我們則是使用 @ 當做 Decorator 使用的語法糖符號(語法糖指的是簡化寫法)。


開始之前

剛學程式的人,如果想要除錯 1 個函式(function),大多都會選擇在函式內加上 print 吧,通常都會將傳入值與回傳值都列印出來,例如:

1
2
3
4
5
def sum(a, b):
  print('a =', a)
  print('b =', b)
  print('a + b =', a + b)
  return a + b

然而,這樣子只能針對 sum 這個函式除錯,而且每次都要做侵入式的修改,相當不便…。

我們都知道 Python 的傳入與回傳值可以是函式,那麼也許可以做一個函式叫 debug,然後接受任何函式傳入,並且把我們的 debug 功能加在裡面,這樣就能夠針對任何函式進行除錯,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def sum(a, b):
  return a + b

def debug(func):
  print('接到 func', func.__name__)
  # 我想在這裡 debug
  return func

debug_sum = debug(sum)
debug_sum(1, 2)

p.s. 每個 function 都有 1 個屬性 __name__ 可以取得函式名稱

執行結果:

1
> 接到 func sum

可是上述範例 debug 函式執行過程並沒有辦法呼叫傳入的 func,因為我們無法攔截到未來使用者怎麼呼叫傳進去的 func (也就是 sum 函式),也就是 debug_sum(1, 2) 沒攔截到傳進去的 1、2,所以根本無法完成除錯的目標,但好消息是我們可以攔截到 sum 被傳進去了。

怎麼解決攔截傳入參數的部分?

很簡單!做個中間人!

我們可以把傳進 debug 函式的 func 再用 def 包裝在 1 個新的函式裡面,這個新的函式要負責接受任何參數,然後轉一手再代入先前傳進來的函式,最後我們再將新包裝過的函式回傳回去,於是程式碼會變成這樣:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def sum(a, b):
  return a + b

def debug(func):
  print('接到 func', func.__name__)
  def wrapper(*args, **kwargs):
    print('幫忙代入 args', args)
    print('幫忙代入 kwargs', kwargs)
    func(*args, **kwargs)
  return wrapper

debug_sum = debug(sum)
debug_sum(1, 2)
print('debug_sum', debug_sum)

執行結果:

1
2
3
4
> 接到 func sum
> 幫忙代入 args (1, 2)
> 幫忙代入 kwargs {}
> debug_sum <function debug.<locals>.wrapper at 0x107966430>

耶!從上面執行結果看到我們透過再包裝 1 層函式,就能攔截傳入參數的部分。從執行結果也可以看到呼叫完 debug 函式,所得到的 debug_sum 其實就是那個新包裝好的函式,因此上述範例的呼叫 debug_sum 的方式可以進一步濃縮為 1 行:

1
debug(sum)(1, 2)

不過上述範例還是沒有列印 sum 執行結果的部分,所以可以再進一步改成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def sum(a, b):
  return a + b

def debug(func):
  print('接到 func', func.__name__)
  def wrapper(*args, **kwargs):
    print('幫忙代入 args', args)
    print('幫忙代入 kwargs', kwargs)
    result = func(*args, **kwargs)
    print(func.__name__, '執行結果', result)
  return wrapper

debug_sum = debug(sum)
debug_sum(1, 2)

執行結果:

1
2
3
4
> 接到 func sum
> 幫忙代入 args (1, 2)
> 幫忙代入 kwargs {}
> sum 執行結果 3

耶!完美!

完成 debug 函式之後,你就可以任意 debug 函式,而且不需要做到任何侵入性的修改,例如 debug print 函式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def debug(func):
  print('接到 func', func.__name__)
  def wrapper(*args, **kwargs):
    print('幫忙代入 args', args)
    print('幫忙代入 kwargs', kwargs)
    result = func(*args, **kwargs)
    print(func.__name__, '執行結果', result)
  return wrapper

debug_print = debug(print)
debug_print(1, 2, '3')

看到這邊,大家應該就會了解 Python decorator 在做什麼 - 接收 1 個函式後加工並包裝成 1 個新的函式


@ 語法糖(Syntactic sugar)

也由於這種 decorator 的模式很好用,所以 Python 提供 1 種特殊的語法 @ 可以讓我們將 debug_sum = debug(sum) 簡化為:

1
2
3
@debug
def sum(a, b):
  return a + b

這種語法就被稱為是 1 種語法糖,太好吃了,而且會上癮…。

所以這種接受接一個函式作為參數,然後返回一個新的函式的函式都可以在前面加上 @ 作為裝飾子使用,例如下列範例的 timeit (測量函式執行時間):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import time

def timeit(func):
  def wrapper():
    s = time.time()
    func()
    print(func.__name__, 'total time', time.time() - s)
  return wrapper

@timeit
def sleep_10s():
  time.sleep(10)

sleep_10s()

執行結果:

1
> sleep_10s total time 10.000677108764648

多個裝飾子的執行順序

由於裝飾子非常好用,所以你很有可能會看到類似的程式碼片段存在專案之中:

1
2
3
4
5
6
@timeit
@api
@auth_required
@cache
def get_profile():
  # ...(略)

這時候頭就大了,到底要怎麼理解多個裝飾子的執行順序?到底是 @timeit 會先執行?還是 @cache 會先執行呢?

為了解答這個問題,我們可以將 decorator 的數量簡化至 2 個,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def deco1(func):
  def wrapper():
    print('deco1')
    func()
    print('deco1 end')
  return wrapper

def deco2(func):
  def wrapper():
    print('deco2')
    func()
    print('deco2 end')
  return wrapper

@deco1
@deco2
def main():
  print('main')

main()

上述執行結果如下:

1
2
3
4
5
> deco1
> deco2
> main
> deco2 end
> deco1 end

其實從執行結果可以看出,最外層的 decorator 會最先執行,最晚結束

但其實也不難理解,因為 decorator 是一層包一層的形式,所以只要把一層包一層的圖畫出來,再畫一條直線從上往下貫穿,我們就可以理解其執行與結束順序了:


functools.wraps

先前章節提到 decorator 會接受接一個函式作為參數,然後返回一個新的函式的函式,這其實會有 1 個小小問題產生,就是被包裝的函式的名字與 doc string 都會消失,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import time

def timeit(func):
  def wrapper():
    s = time.time()
    func()
    print(func.__name__, 'total time', time.time() - s)
  return wrapper

@timeit
def sleep_10s():
  """sleep 10s"""
  time.sleep(10)

print('func', sleep_10s.__name__)
print('doc', sleep_10s.__doc__)

執行結果:

1
2
> func wrapper
> doc string None

從上述執行結果可以看到 sleep_10s 的名字與 doc string 都不是我們所預期的樣子,原因在於 decorator 其實回傳新的函式,所以這些非預期的值都數於新回傳的函式,這樣就對開發協作者比較不友善了…。

如果要修好這個問題,可以用 functools.wrap 再包裝一次回傳的函式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import time
from functools import wraps

def timeit(func):
  @wraps(func)
  def wrapper():
    s = time.time()
    func()
    print(func.__name__, 'total time', time.time() - s)
  return wrapper

@timeit
def sleep_10s():
  """sleep 10s"""
  time.sleep(10)

print('func', sleep_10s.__name__)
print('doc', sleep_10s.__doc__)

執行結果如下,可以看到 sleep_10s 的函式名稱與 doc string 又恢復正常了:

1
2
> func sleep_10s
> doc string sleep 10s

實際上 functool.wraps 可以視情況自行決定要不要加,如果是一定要保留 doc string 或原本的函式名稱的話,就可以用 functool.wraps,否則其實不加也不影響日常使用。


類別裝飾子(Class-based decorators)

其實,不只有函式(function)能夠當裝飾子,類別(class)也能改裝成裝飾子。

類別寫成的裝飾子就稱為 class-based decorator

實作 classed-based decorator 的方法雖然不如以 function 方式實作直覺,但也很簡單,只要實作 __init__() 方法接受傳入函式,並且實作 __call__ 方法呼叫被傳入的函式即可,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class ClsDeco:
  def __init__(self, func):
    self.func = func

  def __call__(self):
    print("before calling", self.func.__name__)
    self.func()
    print("after calling", self.func.__name__)

@ClsDeco
def say_hello():
  print("Hello!")

say_hello()

執行結果:

1
2
3
> before calling say_hello
> Hello!
> after calling say_hello

前述範例其實等同於下列形式, 也就是 ClsDeco(say_hello)() 的部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class ClsDeco:
  def __init__(self, func):
    self.func = func

  def __call__(self):
    print("before calling", self.func.__name__)
    self.func()
    print("after calling", self.func.__name__)

def say_hello():
  print("Hello!")

ClsDeco(say_hello)()

這就是關於 classed-based decorator 的簡單介紹。


更複雜的 decorator - 可設定參數的裝飾子

學會 decoratorclass-based decorator 之後,還可以進一步製造出更複雜的裝飾子,譬如接受參數設定的裝飾子,例如下列範例 @retry 裝飾子接受參數 max=3,改變裝飾子的行為:

1
2
3
@retry(max=3)
def get_stock_price():
  pass

要怎麼解讀 @retry(max=3) 呢?其實就是 1 個函數回傳另 1 個裝飾子:

1
2
3
4
5
r = retry(max=3)

@r
def get_stock_price():
  pass

實作上就類似下列的程式碼:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def retry(max=1):
  class Wrapper:
    def __init__(self, func):
      self.func = func

    def __call__(self):
      retried = 0
      while retried < max:
        try:
          self.func()
        except Exception:
          retried += 1
          print('Failed. Going to try again (', retried, ')')
        else:
          break
  return Wrapper

@retry(max=3)
def get_stock_price():
  raise ValueError

get_stock_price()

執行結果如下:

1
2
3
> Failed. Going to try again ( 1 )
> Failed. Going to try again ( 2 )
> Failed. Going to try again ( 3 )

從上述結果可以看到,我們藉著 1 個函數回傳另 1 個裝飾子的做法,成功讓裝飾子具有可設定的特性,不過相對也讓程式變了複雜一點。

以上就是關於更複雜的 decorator 的介紹。


總結

Decorator 是一個非常實用的模式/功能,它可以讓我們輕鬆地在既有基礎上疊加額外的功能,除了使程式碼更加簡潔、易讀之外,還可以增加複用性。

不過由於 Decorator 可以不斷疊加的特性,甚至是可以多重包裝一個函式,有時候會適得其反,造成程式碼閱讀困難,使用上還是建議盡量保持單純為佳。

如果你還沒有在 Python 專案中使用 decorator,現在是時候了!


comments powered by Disqus