functools.py が革命的に便利そうなのでまとめてみた

こんにちは、ぷりんです。いつものようにネットサーフィン(死語)をしていると、functools.py という Python の標準モジュールがあることを知りました↓↓
docs.python.org
関数やクラスを定義するときに補助的に使うものっぽいですが、これがなかなかに「かゆいところに手が届く」感じで感動しました。公式ドキュメントを読みながら、勉強がてら自分なりにまとめてみたいと思います。

前提

基本的な関数やクラスの定義の仕方は既知とします。また簡単なデコレータの使い方も知っているものとします。この記事は、自分がいざ使う段になったときに参照するための要約を目指し、必ずしも網羅的であることは目指しません。この記事に含まれていない機能や詳細の設定が知りたい場合は、冒頭で紹介した公式ドキュメントを参照してください。
またなるべく正確な情報を書くように努力しますが、究極的な正しさは保証できませんので、この記事に書いてある内容を利用して損害を生じた場合の責任は負いかねます。

cache:同じ計算を二度しない

@functools.lru_cache(user_function), @functools.lru_cache(maxsize=128, typed=False) (ver3.2~; ver3.3~(typed); ver3.8~(user_function); ver3.9~(cache_parameters()))

このデコレータをつけておくと、複数回同じ引数を入れたときに、最初の計算結果を即座に返すようになります。

from functools import lru_cache
import time

@lru_cache
def f(x):
    time.sleep(3)
    return x

この例では f(1) を実行すると3秒待った後に 1 が返却されます。このあと再度 f(1) を実行すると、待たずに(内部の処理を実行せずに)1が返ってくることになります。内部に結果を保存する辞書を持っており、引数 1 に対しては 1 を返すという風に実装されているようです。このため渡す引数は hashable なものである必要があります(整数やタプルや集合はOK, リストや辞書はNG)。

パラメータの maxsize を変更すると、結果を保存する個数を変えることができます(デフォルトは128)。

from functools import lru_cache

@lru_cache(maxsize=5)
def f(x):
    time.sleep(3)
    return x

この状態で f(1), f(2), f(3), f(4), f(5), f(6) と実行すると, f(1) の結果が忘却されてしまいます。lru は least recently used の頭文字だそうで、maxsize 個の最近呼ばれた結果を保存するような仕様になっているようです。保存する数の上限を取り払うには maxsize=None と指定すればOKです。なおキャッシュをすべて消去するには f.cache_clear() を実行してください。

この機能は同じ計算結果を再利用したい場合には便利ですが、実行のたびに値が変わってほしい場合や、実行途中になにか別の変数に影響を及ぼすことを期待している場合は不向きです。

再帰的に計算する関数を定義するときなんかも便利かと思います。例えば↓

from functools import lru_cache

@lru_cache(maxsize=None)
def factorial(n):
    return n * factorial(n - 1) if n > 0 else 1

とすれば何度も再計算することを避けられそうです。なおキャッシュの状態は factorial.cache_property() や factorial.cache_info() で確認できるようです(詳しくは公式ドキュメントを参照)。

@functools.cache(user_function) (ver3.9~)

これは @functools.lru_cache(maxsize=None) の短縮バージョンです。つまり

from functools import cache
import time

@cache
def f(x):
    time.sleep(3)
    return x

from functools import lru_cache
import time

@lru_cache(maxsize=None)
def f(x):
    time.sleep(3)
    return x

は等価です。過去の消去を行わない分、キャッシュ個数上限がある場合より小さくて速いそうです。

@functools.cached_property(func) (ver3.8~)

クラスのメソッドに使います。これをつけるとプロパティを呼び出したタイミングで、本当にその名前のインスタンス変数が準備されます。例えば以下の例を見てください。

from functools import cached_property
import time

class Box:
    def __init__(self, number_list):
        self.number_list = number_list

    @cached_property
    def sum(self):
        time.sleep(3)
        return sum(self.number_list)

これが定義された状態で

b = Box([1, 2, 3])
print(b.sum)
print(b.sum)

と実行すると、一度目の b.sum は3秒待ってから表示され、二度目の b.sum は即座に表示されます。これは sum が呼び出されたタイミングで本当に self.sum という変数が準備されて結果が格納されるため、二度目は処理が実行されず値が返されるという仕組みです。このあと del b.sum などで一旦消去すれば再度 sum メソッドを使って計算してくれますが、消去しない限りは格納された値が返されるようになります。通常の @property と違って本当に b.sum の値が定まるので、setter 属性を持つメソッドを定義しない場合でも自由に値を代入・変更できます。また b.sum に値が入っている限りは再計算されないため、たとえば b.number_list = [2, 3, 4] と上書きしても b.sum は 6 と返ってきます。

あまり使いどころが思いつきませんが、プロパティの計算が重めで、呼び出すときは何度でも呼び出すけれども、使わないときは全然使わないというようなケースなら有用かもしれません。

partial:部分的に引数を固定する

functools.partial(func, /, *args, **keywords)

先に例を示します。

from functools import partial

def add(x, y):
    return x + y

plus_one = partial(add, y=1)
print(plus_one(15)) # 16

関数 add の y に 1 を代入してできる一変数関数が plus_one (partial オブジェクト)になります。実際には add(15, y=1) という形で呼ばれるようになるっぽいです。多変数にも対応しています。

from functools import partial

def add5(a, b, c, d, e):
    return a + b + c + d + e

add_ten = partial(add5, 3, 3, d=4)

このとき add_ten は add5 のうち a に 3, b に 3, d に 4 が代入された状態で c, e を引数にとる関数となります。ただしこのとき例えば add_ten(1, 2) としてしまうと add5(3, 3, 1, d=4, 2) と呼ぶ格好になり、エラーになります。add_ten(1, e=2) であればちゃんと 13 と返ってきます。

よく使う引数があって、それだけで独立して関数名を割り当てたいときなんかに有用かもしれません。

from functools import partial

def print_message(message):
    print("--------------------")
    print(message)
    print("--------------------")

say_hello = partial(print_message, message="Hello")

class functools.partialmethod(func, /, *args, **keywords) (ver3.4~)

partial のクラスのメソッドに使うバージョンです。公式の例がわかりやすいので引用します。

from functools import partialmethod

class Cell:
    def __init__(self):
        self._alive = False

    @property
    def alive(self):
        return self._alive

    def set_state(self, state):
        self._alive = bool(state)

    set_alive = partialmethod(set_state, True)
    set_dead = partialmethod(set_state, False)

実行例は以下のとおりです。

c = Cell()
print(c.alive) # False
c.set_alive()
print(c.alive) # True

定義の際に「self.」を使わないというのは若干罠かもしれません(スタティックメソッドのように振る舞うようです)。こちらも、よく使う引数に関して関数名を割り当てたい場合に有用そうです。

partial.func, partial.args, partial.keywords

partial オブジェクトは上記のプロパティを持ちます。partial.func はもとの関数、partial.args は固定した引数、partial.keywords は「変数=値」の形式で固定した引数を返します。

single dispatch:一変数のオーバーロード

@functools.singledispatch (ver3.4~; ver3.7~(annotation))

「関数をシングルディスパッチジェネリック関数に変換する」そうです。要は最初の一変数に関してのみオーバーロードできる関数になるようです。Pythonオーバーロードできると思っていなかったので、個人的にかなりびっくりしました。簡単な使用例を示します。

from functools import singledispatch

@singledispatch
def echo_hello(x):
    print("Hello,", x)

@echo_hello.register
def _(x: int):
    print("Hello, integer", x)

@echo_hello.register
def _(x: str):
    print("Hello, string", x)

こう書くと、echo_hello 関数の引数に int 型, str 型, それ以外を入れた場合で異なる処理をさせることができます。オーバーロードを定義するときの関数名はなんでもよいので、ここでは _ にしています。上記ではアノテーションの表記を利用しましたが、以下のように書いても同じ挙動になります。

from functools import singledispatch

@singledispatch
def echo_hello(x):
    print("Hello,", x)

@echo_hello.register(int)
def _(x):
    print("Hello, integer", x)

@echo_hello.register(str)
def _(x):
    print("Hello, string", x)

同様に次のように書くことも可能です。

from functools import singledispatch

@singledispatch
def echo_hello(x):
    print("Hello,", x)

def say_int(x):
    print("Hello, integer", x)

def say_str(x):
    print("Hello, string", x)

echo_hello.register(int, say_int)
echo_hello.register(str, say_str)

また複数の型の振る舞いをまとめたい場合は、デコレータを縦に並べればOKです。

from functools import singledispatch

@singledispatch
def echo_hello(x):
    print("Hello,", x)

@echo_hello.register(float)
@echo_hello.register(int)
def _(x):
    print("Hello, numeric", x)

@echo_hello.register(str)
def _(x):
    print("Hello, string", x)

これは echo_hello.register が修飾前の関数を返すことを利用しています。なお登録済みのものを取得するには読み取り専用属性の registry を用いれば良いようです。

class functools.singledispatchmethod(func) (ver3.8~)

singledispatch のクラスで定義されたメソッドに使うバージョンです。使い方はほぼ同様で、@singledispatchmethod というデコレータを用いればよいようです。新しい処理の登録に @関数名.register を用いるところも同じです。

wrap:"透明" なラッパー関数を定義する

functools.update_wrapper(wrapper, wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES)

自作デコレータを使うためにラッパー関数を定義するとき、何もケアしなければラップされた関数の情報が覆い隠されてしまいます。この関数は、この内部情報を幾分か見れるように定義するための機能を与えます。実際は次の @wraps というデコレータでこの関数を使います。

@functools.wraps(wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES)

前述の update_wrapper に対応するデコレータです。公式の例を引用します。

from functools import wraps

def my_decorator(f):
    @wraps(f)
    def wrapper(*args, **kwds):
        print('Calling decorated function')
        return f(*args, **kwds)
    return wrapper

@my_decorator
def example():
    """Docstring"""
    print('Called example function')

このように定義すれば、ちゃんと example の関数名 (example.__name__) やドキュメンテーション文字列 (example.__doc__) が保持されます。

その他

Python2 系の比較関数を sort などの key にする:functools.cmp_to_key(func) (ver3.2~)

おそらく使うこともないので省略します。

二変数関数を累積的に適用:functools.reduce(function, iterable[, initializer])

公式ドキュメントの例ですが、reduce(lambda x, y: x + y, [1, 2, 3, 4, 5]) は ((((1+2)+3)+4)+5) と等価です。第一変数に二変数関数を入れて、第二変数に iterable を代入すると、順番に累積的に関数を適用した結果が返ってきます。なお initializer に変数を入れた場合はそれがスタートの引数になります。initializer=None かつ iterable の要素がひとつの場合はその数値を返却します。以下は実行例です。

from functools import reduce

reduce(lambda x, y: x + y, [1, 2, 3]) # 1+2+3=6
reduce(lambda x, y: x + y, [1, 2, 3], 4) # 4+1+2+3=10
reduce(lambda x, y: x + y, [1]) # 1
reduce(lambda x, y: x + y, [1], 2) # 2+1=3
reduce(lambda x, y: x + y, [], 3) # 3
reduce(lambda x, y: x + y, []) # TypeError

順序関係の定義を時短:@functools.total_ordering (ver3.2~)

クラスオブジェクト間で比較演算子を使えるようにするには __lt__(), __le__(), __gt__(), __ge__() を定義する必要があります。このデコレータを利用する場合は、上記の4つのうちどれか一つと __eq__() のみを定義すれば残りは自動的に実装されます。実装の労力を削減することができます。
ただし代償として実行速度が遅くなる上、派生した比較メソッドのスタックトレースが複雑になるようです。よほど複雑な処理の場合を除いて、自分でそれぞれ書いたほうが良いのではないかと思っています。