Pythonのlru_cacheとcache—メモ化の使い分けとメソッドの落とし穴

Pythonのlru_cacheとcache—メモ化の使い分けとメソッドの落とし穴 | mohablog

同じ引数で何度も呼ぶ関数を、自前の辞書でメモ化していないでしょうか。functools なら1行のデコレータで済みます。ただし lru_cachecache の違い、メソッドに付けたときの挙動でつまずきやすい。動作は Python 3.14.6 で確認しています。数字はすべて cache_info() の実測値です。

目次

lru_cacheとcacheはどちらを使うか

functools のメモ化デコレータは2つ。lru_cachePython 3.2 から、cache3.9 からあります。公式ドキュメントは cache を「Simple lightweight unbounded function cache」と説明し、lru_cache(maxsize=None) と同じ値を返すと明記しています。上限を持たないぶん、追い出し判定がなく速い。

cacheはmaxsize=Noneのlru_cache

再帰フィボナッチをメモ化してみます。

from functools import cache


@cache
def fib(n: int) -> int:
    return n if n < 2 else fib(n - 1) + fib(n - 2)


print(fib(30))
print(fib.cache_info())

実行結果。

832040
CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)

本来なら指数的に膨らむ呼び出しが、misses=31 の実計算だけで終わっています。残る hits=28 がキャッシュ命中。cachelru_cache(maxsize=None) に書き換えても、この数字は1つも変わりません。

cache_infoでヒット率を測る

cache_info() が返すのは hits / misses / maxsize / currsize の4つ。ヒット率が低いなら、そもそもメモ化が効かない引数分布ということ。キャッシュを空にするなら cache_clear()、設定値を見るなら cache_parameters()(3.9で追加)を使います。

上限を切るか、切らないか

キーが有限で増えないなら cache。キーが際限なく増える、あるいはメモリ上限を設けたいなら lru_cache(maxsize=N)

@cache@lru_cache(maxsize=N)
上限なし(無制限)N件を超えたら古い順に破棄
追加バージョン3.93.2
速度追い出し判定がなく速い上限管理のぶん重い
向く場面キーが有限で増えないキーが増え続ける / 上限を切りたい

引数の種類が増える関数では maxsize を明示します。無制限キャッシュはメモリを食い続けます。

メソッドに付けたlru_cacheがインスタンスを解放しない

関数で便利な lru_cache を、そのままメソッドに付けると事故ります。キャッシュのキーに self が入り、処理済みのインスタンスがメモリに残り続けるからです。

なぜselfがキャッシュに残るのか

from functools import lru_cache
import gc
import weakref


class ReportBuilder:
    def __init__(self, rows):
        self.rows = rows

    @lru_cache(maxsize=None)
    def total(self):
        return sum(self.rows)


b = ReportBuilder([1, 2, 3])
ref = weakref.ref(b)
b.total()
del b
gc.collect()
print(ref() is None)

実行結果。

False

del bgc.collect() の後でも弱参照が生きています。lru_cache のキャッシュはデコレートされた関数オブジェクト(クラス側の属性)に紐づき、キー (self,) として b を握ったまま。maxsize=None なら解放される機会がありません。実際に日次バッチのレポート生成クラスでこれをやり、処理のたびにメモリが右肩上がりに増え続けました。原因にたどり着くまで半日。

cached_propertyに置き換える

引数がなく、インスタンスごとに一度計算すれば足りる値なら cached_property が本命です。

from functools import cached_property


class ReportBuilder:
    def __init__(self, rows):
        self.rows = rows

    @cached_property
    def total(self):
        return sum(self.rows)


b = ReportBuilder([1, 2, 3])
print(b.total)
print(b.total)
print(b.__dict__)

実行結果。

6
6
{'rows': [1, 2, 3], 'total': 6}

結果はインスタンスの __dict__total として書き込まれます。次回アクセスはそこを読むだけ。インスタンスが消えればキャッシュも一緒に消えます。呼び出しが b.total() から b.total に変わる点だけ注意。

それでもメソッド単位でキャッシュしたいとき

引数を取るメソッドをキャッシュしたいなら、__init__ でインスタンスごとのキャッシュを作ります。

from functools import lru_cache


class PriceTable:
    def __init__(self, rates):
        self.rates = rates
        self.convert = lru_cache(maxsize=None)(self._convert)

    def _convert(self, currency):
        return self.rates[currency] * 100


t = PriceTable({"USD": 1.5})
print(t.convert("USD"))
print(t.convert.cache_info())

実行結果。

150.0
CacheInfo(hits=0, misses=1, maxsize=None, currsize=1)

キャッシュが self の属性なので、インスタンスと寿命をともにします。クラス属性にぶら下がる先ほどのパターンと、ここが決定的に違います。

cached_propertyが使えない場面

__slots__とは併用できない

cached_property は結果を __dict__ に書きます。__slots____dict__ を削ったクラスには、書き込む先がありません。

from functools import cached_property


class Vector:
    __slots__ = ("x", "y")

    def __init__(self, x, y):
        self.x, self.y = x, y

    @cached_property
    def norm(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5


print(Vector(3, 4).norm)

実行結果。

TypeError: No '__dict__' attribute on 'Vector' instance to cache 'norm' property.

__slots__"__dict__" を足せば動きますが、それでは __slots__ でメモリを削った意味が薄れます。値が固定なら __init__ で普通の属性に代入するほうが素直です。

3.12で消えたロック

公式ドキュメントは3.12の変更をこう記します。

Prior to Python 3.12, cached_property included an undocumented lock … In Python 3.12+ this locking is removed.

3.11以前はプロパティ単位のロックがあり、別インスタンスに触れても待たされることがありました。3.12以降はそのロックが撤去。待ちが消えた代わりに、競合時はゲッターが複数回走りえます。副作用のある処理は cached_property に入れず、値の計算だけに使います。

引数がハッシュ化できないとlru_cacheは落ちる

dict/listを渡すとTypeError

from functools import lru_cache


@lru_cache
def dedup(items):
    return set(items)


print(dedup([1, 2, 2, 3]))

実行結果。

TypeError: unhashable type: 'list'

公式ドキュメントは「the positional and keyword arguments to the function must be hashable」と明記しています。理由はキャッシュが辞書だから。listtuple に、setfrozenset に変換してから渡します。

print(dedup((1, 2, 2, 3)))

実行結果。

{1, 2, 3}

typed=Trueで型を区別する

複数引数の関数に 11.0 のように値が等しく型が違う引数を渡すと、既定ではまとめてキャッシュされます。型で分けたいなら typed=True

from functools import lru_cache


def dist(x, y):
    return (x ** 2 + y ** 2) ** 0.5


loose = lru_cache(typed=False)(dist)
loose(1, 2.0)
loose(1.0, 2.0)
print("typed=False", loose.cache_info())

strict = lru_cache(typed=True)(dist)
strict(1, 2.0)
strict(1.0, 2.0)
print("typed=True ", strict.cache_info())

実行結果。

typed=False CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)
typed=True  CacheInfo(hits=0, misses=2, maxsize=128, currsize=2)

ただし1引数が int のときは別。33.0typed の値に関わらず別々にキャッシュされます。公式ドキュメントも「Some types such as str and int may be cached separately even when typed is false」と注記。型でキャッシュを分ける意図があるなら、実装依存に頼らず typed=True を明示します。

singledispatchで型ごとに関数を分ける

functools はキャッシュだけではありません。型ごとに関数を切り替える singledispatch もあります。

isinstanceの分岐を畳む

引数の型で処理を変える関数は、isinstance の連鎖になりがちです。

def describe(value):
    if isinstance(value, int):
        return f"整数: {value}"
    elif isinstance(value, list):
        return f"要素{len(value)}個のリスト"
    raise TypeError(f"未対応: {type(value)}")

対応型が増えるたびに elif が伸びます。singledispatch(3.4で追加)なら、型ごとに実装を分けて登録できます。

from functools import singledispatch


@singledispatch
def describe(value):
    raise TypeError(f"未対応: {type(value)}")


@describe.register
def _(value: int):
    return f"整数: {value}"


@describe.register
def _(value: list):
    return f"要素{len(value)}個のリスト"


print(describe(3))
print(describe([1, 2]))

実行結果。

整数: 3
要素2個のリスト

registerと型アノテーション

@describe.register は第1引数の型アノテーションからディスパッチ先を推論します。int | float のような複数型も 3.11 から登録可能。分岐が1か所に集約されるので、対応型の追加で既存の関数本体に触れずに済みます。

メソッドにはsingledispatchmethod

メソッドで同じことをするなら singledispatchmethod(3.8で追加)。第1引数の self ではなく、2番目の引数の型でディスパッチします。

from functools import singledispatchmethod


class Formatter:
    @singledispatchmethod
    def render(self, value):
        raise NotImplementedError

    @render.register
    def _(self, value: int):
        return f"{value:,}"


print(Formatter().render(1000000))

実行結果。

1,000,000

partialで引数を固定する

関数の一部の引数を先に埋めて、新しい呼び出し可能オブジェクトを作るのが partial。コールバックやマップ処理に、引数を1つ減らして渡したいときに使います。

from functools import partial


def log(level, msg):
    print(f"[{level}] {msg}")


warn = partial(log, "WARN")
warn("ディスク残量が少ない")

実行結果。

[WARN] ディスク残量が少ない

partial(log, "WARN")level"WARN" に固定した log。メソッド版の partialmethod もあります。

まとめ

メモ化もディスパッチも、外部ライブラリなしで functools だけで足ります。

  • cachelru_cache(maxsize=None) の読みやすい別名。上限が要るなら lru_cache(maxsize=N)
  • メソッドに lru_cache を直付けすると self がキャッシュに残り、インスタンスが解放されない。引数なしなら cached_property、引数ありなら __init__ でインスタンス単位のキャッシュ
  • cached_property__dict__ に書くため __slots__ と併用できない。3.12でロックが外れ、一度きりの保証はなくなった
  • キャッシュのキーはハッシュ可能でなければならない。listtuple に変換して渡す
  • 型で処理を分けるなら singledispatch、引数の部分適用は partial

外部ストアを挟むキャッシュ設計は Pythonでredis-pyを使うキャッシュ実装 に、functools.wraps を含むデコレータの自作は Pythonデコレータの仕組みと自作 にまとめています。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次