你應該要知道的 Python 實用模組 - functools 教學

Posted on  Apr 10, 2023  in  Python 程式設計 - 中階  by  Amo Chen  ‐ 6 min read

functools 模組是 Python 內建專門提供各種實用裝飾子(decorator)以及實用函式(function)的模組。

functools 模組最常被使用的功能主要為:

學會正確使用 functools 不僅可以提升效能,也可以有效地提升程式碼的簡潔性。

本文將以實際範例介紹 functools 模組中常用的功能。

本文環境

  • Python 3.7 以上
  • Google Colab

由於 functools 是內建模組,所以也可以直接用 Google Colab 執行。

@cache

對於一些經常被呼叫的函式,如果相同輸入值,會輸出相同結果的函式,相當適合使用 @cache 將結果快取起來,可以有效增加程式效能(如果需要消耗運算資源的函式會特別有感),其使用方法為:

from functools import cache


@cache
def the_function_you_want_to_cache():
    pass

以 Fibonacci 函式為例:

def fabonacci(n):
    if n < 1:
        return 0
    elif n == 1:
        return n

    return fabonacci(n - 1) + fabonacci(n - 2)

在不使用 @cache 的情況下, fabonacci(20) 耗時約 6.79 ms:

使用 @cache 之後,耗時降至僅 36 µs:

from functools import cache

@cache
def fabonacci(n):
    if n < 1:
        return 0
    elif n == 1:
        return n

    return fabonacci(n - 1) + fabonacci(n - 2)


fabonacci(20)

之所以這麼快的原因,是因為 fabonacci(19) 之前的執行結果都被快取起來了,所以呼叫 fabonacci(20) 時,直接從快取拿出 fabonacci(19)fabonacci(18) 的結果進行加總即可。

The cache is threadsafe so the wrapped function can be used in multiple threads.

另外, @cache 是 threadsafe 的裝飾子,所以多執行緒的環境也可以放心使用。

p.s. @cache 是 Python 3.9 之後新增的裝飾子

@lru_cache

@cache 的底層其實使用的就是 @lru_cache , lruLeast Recently Used 的縮寫,@cache 等同於 @lru_cache(maxsize=None) , 也就是沒有快取筆數限制的 @lru_cache

@lru_cache 預設大小為 128, 可以快取 128 筆的快取,同樣以 Fabonacci 函式為例,下列範例以參數 maxsize 設定大小 32 筆的 cache:

from functools import lru_cache


@lru_cache(maxsize=32)
def fabonacci(n):
    if n < 1:
        return 0
    elif n == 1:
        return n

    return fabonacci(n - 1) + fabonacci(n - 2)


fabonacci(20)

如果想知道 cache 的運作情況,可以呼叫 cache_info() 取得相關資訊,例如下列範例,可以看到 cache 目前有 21 筆資料, hit 快取 18 次,沒打中快取 21 次:

>>> fabonacci.cache_info()
CacheInfo(hits=18, misses=21, maxsize=32, currsize=21)

設定快取大小是 1 個好習慣,可以避免耗用記憶體無限制增加,建議使用 @lru_cache 時,要設定合理的 maxsize

使用 functools 快取相關裝飾子時 2 點注意事項

呼叫被快取的函式參數順序

快取的 key 值是由呼叫函式的參數組成, positional arguments 與 keyword arguments 2 種參數都會被組成 key 值,例如下列範例中的 word, times 以及 end 參數都會作為快取的 key 值使用:

from functools import lru_cache


@lru_cache()
def say(word, times=1, end='\n'):
    for _ in range(times):
        print(word, end=end)

由於使用參數組成快取 key 值,所以參數的順序也會影響快取 key 值的產生,例如下列 say('Hi', times=1, end=' ')say('Hi', end=' ', times=1) 2 次呼叫雖然結果相同,由於參數位置不一樣,所以導致 cache miss 2 次:

因此使用 cache 時要注意參數位置的一致性,避免沒擊中快取之外,也避免多儲存一份多餘的資料。

呼叫被快取的函式參數值必須是 hashable

由於 cache 底層是使用 dictionary 實作,所以呼叫被快取的函式參數值必須是 hashable, 例如下列範例使用 dataclass 定義 User, 並且使用 @lru_cache 裝飾 query_user_txs :

from functools import lru_cache
from dataclasses import dataclass


@dataclass
class User:
    id: int
    name: str


@lru_cache()
def query_user_txs(user):
    pass


txs = query_user_txs(User(1, 'Jimny'))

但是由於 User 類別不是 hashable, 因此發生以下錯誤:

TypeError Traceback (most recent call last)
<ipython-input-17-dbd843064974> in <cell line: 16>()
     14
     15
---> 16 txs = query_user_txs(User(1, 'Jimny'))

TypeError: unhashable type: 'User'

因此要特別注意使用的參數是否為 hashable, 或者實作 hash() 方法讓該類別成為 hashable 。

如果是要修好上述 dataclass 不是 hashable 的問題,則只要簡單將 dataclass 改為 frozen dataclass 即可:

from functools import lru_cache
from dataclasses import dataclass


@dataclass(frozen=True)
class User:
    id: int
    name: str


@lru_cache()
def query_user_txs(user):
    pass


txs = query_user_txs(User(1, 'Jimny'))

partial

Return a new partial object which when called will behave like func called with the positional arguments args and keyword arguments keywords.

partial 函式可以將 1 個函式(function)事先填好部分的參數(arguments args 以及 keyword arguments),並包裝成 1 個 partial object 回傳,這個 partial object 就像函式一樣可以被呼叫,只是某些參數已經被事先填好,我們只需要帶入剩下所需的參數即可,下列範例以 requests.get 為例,用 partial 函式是先填好 HTTP 標頭(headers), 使用 partial object 時只要填 URL 即可:

from pprint import pprint
import requests
import functools


get = functools.partial(
    requests.get,
    headers={'User-Agent': 'Chrome'}
)

resp = get('https://httpbin.org/headers')
pprint(resp.json())

上述範例執行結果如下,可以看到 User-Agent 標頭已被換成我們設定的 Chrome

partial 函式很適合對某些函式庫進行再包裝的情況,舉 requests 模組為例,我們可以將某些常用標頭(例如 User-Agent, Accept, Authorization 等)都先用 partial 函式包裝過一次,變成適合呼叫內部系統使用的函式庫,例如:

import requests
from functools import partial


INTERNAL_HEADERS = {
    'User-Agent': 'Internal System',
    'Accept': 'application/json',
    'Authorization': 'Basic thesecretekey',
}


internal_get = partial(
    requests.get,
    headers=INTERNAL_HEADERS,
)

internal_post = partial(
    requests.post,
    headers=INTERNAL_HEADERS,
)

internal_put = partial(
    requests.put,
    headers=INTERNAL_HEADERS,
)

internal_patch = partial(
    requests.patch,
    headers=INTERNAL_HEADERS,
)

internal_delete = partial(
    requests.delete,
    headers=INTERNAL_HEADERS,
)

如此一來,其他人只需要使用這些內部函式即可,不用每次都要產生呼叫內部系統 API 用的標頭,十分方便。

partial, partial 再 partial

官方文件提供的 partial 的程式碼樣貌大致如下,可以看到其實 partial 回傳的是經過包裝的新函式,是很經典的裝飾子(decorator)用法:

def partial(func, /, *args, **keywords):
    def newfunc(*fargs, **fkeywords):
        newkeywords = {**keywords, **fkeywords}
        return func(*args, *fargs, **newkeywords)
    newfunc.func = func
    newfunc.args = args
    newfunc.keywords = keywords
    return newfunc

If more arguments are supplied to the call, they are appended to args. If additional keyword arguments are supplied, they extend and override keywords.

也因爲 partial 回傳的是函式,所以我們可以對 partial 所回傳的函式再次使用 partial 函式再包裝一次,partial 會自動幫我們把新的參數附加到舊的參數上,也就是上述程式碼中的 newkeywords = {**keywords, **fkeywords}func(*args, *fargs, **newkeywords) 2 個部分。

以下是用 partial 函式多次包裝 requests.get 的例子,第一次先代入 headers 參數,第二次則帶入 params 參數:

get = partial(requests.get, headers={'User-Agent': 'Chrome'})
print(get)
get = partial(get, params={'param1': 'p1'})
print(get)

上述範例執行結果如下,可以看到 partial 確實會把多次呼叫的參數加在一起:

functools.partial(<function get at 0x7f40662a9820>, headers={'User-Agent': 'Chrome'})
functools.partial(<function get at 0x7f40662a9820>, headers={'User-Agent': 'Chrome'}, params={'param1': 'p1'})

如果再次使用 partial 時使用已經有的參數,舊參數則會被覆蓋,下列範例再額外代入 1 次 params 參數:

get = partial(get, params={'param2': 'p2'})
print(get)

從結果可以看到 params 參數已經被覆蓋為 params={'param2': 'p2'} , 而非原先的 params={'param1': 'p1'}

functools.partial(<function get at 0x7f40662a9820>, headers={'User-Agent': 'Chrome'}, params={'param2': 'p2'})

如果你的函式需要逐步代入參數的話,其實也可以考慮使用 partial 函式幫助你逐步代入。

reduce

reduce 函式不是裝飾子,而是用來將 iterable 運算縮減成 1 個值的函式,舉下列加總 0 到 4 為例,就是一個典型的 reduce 例子,數字 0 到 4 最後被加總成 1 個值 total :

nums = [i for i in range(5)]

total = 0
for x in nums:
    total += x

這種 pattern 就可以使用 reduce 函式改成 1 行,如下所示:

from functools import reduce

nums = [i for i in range(5)]
reduce(lambda x, y: x + y, nums)

reduce 的第 1 個函式需要接受 2 個值並回傳 1 個值,分別會是 iterable 的前 2 個值,回傳的值就是這 2 個值運算的結果,該運算結果會再被拿來與 iterable 的下一個值丟到同一個函式進行運算,同樣舉數字 0 - 4 總和為例:

第 1 次: (0 + 1) = 0
第 2 次: (1 + 2) = 3
第 3 次: (3 + 3) = 6
第 4 次: (6 + 4) = 10

reduce 做的事如果化為數學式 :

((((0 + 1) + 2) + 3) + 4)

值得注意的是,使用 reduce 加總的例子,執行速度並不會比直接使用 sum() 函式來的更有效率:

這原因在於 reduce 每次進行運算都要呼叫 1 次函式進行運算,呼叫函式的操作支出也會影響效能,因此不如使用 sum() 函式與直接使用 for 迴圈進行運算來的有效率,但就程式碼的簡潔性來說, reduce 函式確實會較為簡潔。

@singledispatch

如果你的函式需要依照傳入的參數型別有不同的行為,例如:

def func(args):
    if isinstance(args, int):
        print('Got an int', args)
    elif isinstance(args, list):
        print('Got a list' args)
    else:
        print('Got something', args)

這種模式的程式碼可以考慮使用 singledispatch 進行改寫,程式碼會變得更加簡潔。

singledispatch 是 Python 3.4 之後推出的裝飾子,到 3.7 之後變得更加實用,因為該裝飾子到了 3.7 之後支援 Python typing 模組,可以依照參數型別自動呼叫相對應的函式處理。

例如前述範例可以改為下列形式,使用 @singledispatch 註冊 1 個預設的函式,接著使用 register() 裝飾子搭配型別註記(type annotation)註冊相應型別的參數的處理函式:

from functools import singledispatch


@singledispatch
def func(args):
    print('Got something', args)


@func.register
def _int(args: int):
    print('Got an int', args)


@func.register
def _list(args: list):
    print('Got a list', args)


func(123)

func([1, 2, 3])

func("123")

上述範例執行結果如下,可以看到使用 singledispatch 搭配 type annotations 讓 Python 可以針對參數型別呼叫相對應的函式進行處理:

Got an int 123
Got a list [1, 2, 3]
Got something 123

singledispatch 不僅讓 Python 更容易達到 Single-responsibility principle - Wikipedia, 也增加程式碼的可維護性與簡潔性,是相當方便的裝飾子。

不過值得注意的是 Python 3.11 之後, register() 裝飾子才支援 types.UnionType 以及 typing.Union 2 種 type annotations, Python 3.11 之後才能使用類似以下的程式碼:

@func.register
def _int_or_float(args: int | float):
    print('Got an int/float', args)

這是使用 singledispatch 需要注意的地方。

另外 singledispatch 也有 class 版本,可以在 Python class 內使用類似的技巧,詳見 singledispatchmethod

@wraps

我們都知道裝飾子(decorator)會接受接一個函式作為參數,然後返回 1 個新的函式,這其實會有 1 個小小問題產生,就是被包裝的函式的名字與 doc string 都會消失,這個問題可以用 @wraps 裝飾子修正,詳見 看了肯定會的 Python 裝飾子(decorator)教學

結論

我們介紹了 functools 模組的主要功能和使用方法,這些功能不僅實用,也可以增加程式碼的可維護性與簡潔性,是相當實用的模組,是 1 個建議要學會的模組。

以上!

Happy Coding!

References

https://docs.python.org/3/library/functools.html

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!