同じ引数で何度も呼ぶ関数を、自前の辞書でメモ化していないでしょうか。functools なら1行のデコレータで済みます。ただし lru_cache と cache の違い、メソッドに付けたときの挙動でつまずきやすい。動作は Python 3.14.6 で確認しています。数字はすべて cache_info() の実測値です。
lru_cacheとcacheはどちらを使うか
functools のメモ化デコレータは2つ。lru_cache は Python 3.2 から、cache は 3.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 がキャッシュ命中。cache を lru_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.9 | 3.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 b と gc.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」と明記しています。理由はキャッシュが辞書だから。list は tuple に、set は frozenset に変換してから渡します。
print(dedup((1, 2, 2, 3)))
実行結果。
{1, 2, 3}
typed=Trueで型を区別する
複数引数の関数に 1 と 1.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 のときは別。3 と 3.0 は typed の値に関わらず別々にキャッシュされます。公式ドキュメントも「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 だけで足ります。
cacheはlru_cache(maxsize=None)の読みやすい別名。上限が要るならlru_cache(maxsize=N)- メソッドに
lru_cacheを直付けするとselfがキャッシュに残り、インスタンスが解放されない。引数なしならcached_property、引数ありなら__init__でインスタンス単位のキャッシュ cached_propertyは__dict__に書くため__slots__と併用できない。3.12でロックが外れ、一度きりの保証はなくなった- キャッシュのキーはハッシュ可能でなければならない。
listはtupleに変換して渡す - 型で処理を分けるなら
singledispatch、引数の部分適用はpartial
外部ストアを挟むキャッシュ設計は Pythonでredis-pyを使うキャッシュ実装 に、functools.wraps を含むデコレータの自作は Pythonデコレータの仕組みと自作 にまとめています。

