同じ引数で何度も呼び出す関数が遅くて困った経験はありませんか。
そんな時に便利なのが、関数の戻り値を自動で覚えて再利用するメモ化(キャッシュ)です。
Python標準ライブラリのfunctools.lru_cache
を使えば、わずか1行のデコレータでコードを変えずに高速化できます。
計算結果を保存して再計算を避ける、それがlru_cache
の本質です。
Pythonのfunctools.lru_cacheとは?(メモ化で高速化)
メモ化(memoization)の基本
メモ化とは、関数に同じ引数が渡されたときに前回の結果を再利用して、不要な再計算を避ける最適化手法です。
Pythonでは@functools.lru_cache
というデコレータを付けるだけで、この仕組みを簡単に導入できます。
メモ化の最大の利点は、計算コストをかけずに結果を即座に返せる点にあります。
内部的には、引数をキー、戻り値を値とする辞書のようなキャッシュに結果を保持します。
LRU(Least Recently Used)の仕組み
lru_cache
の「LRU」はLeast Recently Usedの略で、直近で使われていないデータから順に捨てるアルゴリズムです。
キャッシュサイズ(maxsize
)に上限がある場合、古く使われた項目が追い出され、新しい計算結果が入ります。
「最近よく使う結果ほど残す」という現実的な戦略で、メモリ消費とヒット率のバランスを取ります。
効果が出る場面(重い計算・同じ引数が多い)
メモ化は、関数が高コストで、かつ同じ入力が繰り返される時に最大の効果を発揮します。
具体例としては、再帰的な動的計画法(例: フィボナッチ数列)、複雑なパースや正規表現、外部APIを呼ばずに済む一時的な計算結果の再利用などが挙げられます。
計算が重い、または呼び出し回数が多い関数は、まずlru_cache
の候補だと考えてください。
使い方: デコレータで関数をメモ化
基本の書き方とシンプルな例
関数の定義に@lru_cache
を付けるだけで導入できます。
次の例は、あえて待機時間を入れて「重い処理」を模倣し、2回目以降が高速になる様子を示します。
# basic_lru_cache.py
from functools import lru_cache
import time
@lru_cache(maxsize=128) # キャッシュの上限を128件に設定
def slow_square(n: int) -> int:
"""重い処理を模したn*n"""
time.sleep(0.5) # 500msかかる重い計算の代わり
return n * n
def main():
start = time.perf_counter()
print("1回目:", slow_square(12))
t1 = time.perf_counter() - start
start = time.perf_counter()
print("2回目:", slow_square(12)) # 同じ引数なのでキャッシュを使用
t2 = time.perf_counter() - start
print(f"1回目の所要時間: {t1:.3f}s")
print(f"2回目の所要時間: {t2:.6f}s (キャッシュ)")
if __name__ == "__main__":
main()
1回目: 144
2回目: 144
1回目の所要時間: 0.501s
2回目の所要時間: 0.000010s (キャッシュ)
2回目の呼び出しがほぼ0秒で返っているのは、結果がキャッシュから取り出されているためです。
- time.sleep — Python 3.13 ドキュメント
- time.perf_counter — Python 3.13 ドキュメント
- 関連記事:if name == “main”:とは?実行時だけ動かす処理について解説
再帰関数の高速化(例: フィボナッチ)
フィボナッチ数列のような指数的に呼び出しが増える再帰関数は、メモ化で劇的に高速化できます。
次の例では、素の再帰とlru_cache
付きの違いを比べます。
# fib_lru_cache.py
from functools import lru_cache
import time
def fib_naive(n: int) -> int:
"""メモ化なしの再帰(指数時間)"""
if n < 2:
return n
return fib_naive(n - 1) + fib_naive(n - 2)
@lru_cache(maxsize=None) # 無制限キャッシュ(後述の注意も参照)
def fib_cached(n: int) -> int:
"""メモ化ありの再帰(線形時間)"""
if n < 2:
return n
return fib_cached(n - 1) + fib_cached(n - 2)
def timed(label, func, n):
start = time.perf_counter()
result = func(n)
dt = time.perf_counter() - start
print(f"{label}: fib({n}) = {result} / {dt:.3f}s")
if __name__ == "__main__":
N = 35
timed("naive", fib_naive, N)
timed("cached", fib_cached, N)
# ヒット率を確認
print("cache_info:", fib_cached.cache_info())
naive: fib(35) = 9227465 / 0.710s
cached: fib(35) = 9227465 / 0.000s
cache_info: CacheInfo(hits=33, misses=36, maxsize=None, currsize=36)
メモ化により指数時間の計算が線形時間に短縮され、ヒット数(hits)が増えていることがcache_info
で確認できます。
引数の条件(ハッシュ可能なimmutableが必要)
lru_cache
は引数を「辞書のキー」のように扱うため、引数はハッシュ可能(immutable)でなければいけません。
よく使う型の目安は次の通りです。
- ハッシュ可能: int、str、float、bool、None、tuple(中身がハッシュ可能)、frozenset
- 非ハッシュ可能: list、dict、set、可変のユーザ定義オブジェクトなど
リストや辞書をそのまま渡すとTypeErrorになるため、タプルやfrozensetに変換して渡します。
# hashable_args.py
from functools import lru_cache
@lru_cache(None)
def sum_tuple(xs: tuple[int, ...]) -> int:
"""タプルを受け取り合計を計算(キャッシュ可能)"""
return sum(xs)
def sum_list(xs: list[int]) -> int:
"""外側でタプル化してからキャッシュ関数へ委譲"""
# list -> tuple に正規化
return sum_tuple(tuple(xs))
@lru_cache(None)
def sum_dict_items(items: frozenset[tuple[str, int]]) -> int:
"""(キー, 値)の集合をfrozensetで受け取ると順不同でも同じキー扱いにできる"""
return sum(value for _key, value in items)
def sum_dict(d: dict[str, int]) -> int:
# dict -> frozenset(dict.items()) に正規化
return sum_dict_items(frozenset(d.items()))
if __name__ == "__main__":
print(sum_list([1, 2, 3]))
print(sum_list([1, 2, 3])) # キャッシュ
print(sum_dict({"a": 1, "b": 2}))
print(sum_dict({"b": 2, "a": 1})) # 順不同でも同じキー扱い
6
6
3
3
「渡す前に正規化して不変にする」というパターンを覚えておくと安全です。
- 関連記事:タプル(tuple)とは?変更できないリストの使い方
- 関連記事:辞書(dict)とは?キーと値で管理する基本と使い方
- 関連記事:setでリストの重複を一瞬で削除する方法と注意点
- 関連記事:型ヒントの書き方と使い方: typingとmypyまで
パラメータと便利なAPI(maxsize/typed, cache_info, cache_clear)
maxsizeの意味と選び方
maxsize
はキャッシュに保持できる件数の上限です。
アクセス頻度の高い引数のバリエーション数に見合ったサイズを選ぶとヒット率が上がります。
無制限(None
)だとヒット率は高まりますが、メモリ使用量が増える点に注意してください。
# maxsize_demo.py
from functools import lru_cache
@lru_cache(maxsize=2) # 最新2件だけ保持
def compute(n: int) -> int:
print(f"compute({n}) 実行") # ミス時のみ表示される
return n * 10
if __name__ == "__main__":
compute(1) # miss
compute(2) # miss
compute(1) # hit
compute(3) # miss -> 2がLRUとして追い出される可能性
compute(2) # miss (追い出されていれば再計算)
compute(1) 実行
compute(2) 実行
compute(3) 実行
compute(2) 実行
小さすぎるmaxsize
は追い出しが多くなり、想定よりヒットしません。
一方で大きすぎるとメモリ圧迫につながります。
typed=Trueで型の違いを区別
typed=False
(デフォルト)では、1
と1.0
が同一キー扱いになります。
数値型の違いを区別したい場合はtyped=True
を設定します。
# typed_demo.py
from functools import lru_cache
@lru_cache(maxsize=None, typed=False)
def f_default(x):
print("f_default 計算:", type(x).__name__)
return 42
@lru_cache(maxsize=None, typed=True)
def f_typed(x):
print("f_typed 計算:", type(x).__name__)
return 42
if __name__ == "__main__":
f_default(1) # miss
f_default(1.0) # hit(同一キー扱い)
f_typed(1) # miss
f_typed(1.0) # miss(型が違うので別キー)
f_default 計算: int
f_typed 計算: int
f_typed 計算: float
金融計算や型で意味が変わる文脈ではtyped=True
を検討しましょう。
cache_info()でヒット率を確認
.cache_info()
はヒット/ミスや現在のキャッシュサイズを確認できるAPIです。
効果測定に使い、必要に応じてmaxsize
を調整します。
# cache_info_demo.py
from functools import lru_cache
@lru_cache(maxsize=3)
def f(n):
return n * n
if __name__ == "__main__":
for x in [1, 2, 3, 1, 2, 4, 1]:
f(x)
print(f.cache_info())
CacheInfo(hits=3, misses=4, maxsize=3, currsize=3)
cache_clear()でキャッシュをリセット
.cache_clear()
を呼ぶとキャッシュを空にできます。
外部データのバージョンが変わった時など、「一斉に更新したい」場面で使います。
# cache_clear_demo.py
from functools import lru_cache
calls = 0
@lru_cache(None)
def heavy(n):
global calls
calls += 1
return n * 2
if __name__ == "__main__":
heavy(10); heavy(10) # 2回目はヒット
print("before clear:", heavy.cache_info(), "calls:", calls)
heavy.cache_clear()
heavy(10) # ミスして再計算
print("after clear:", heavy.cache_info(), "calls:", calls)
before clear: CacheInfo(hits=1, misses=1, maxsize=None, currsize=1) calls: 1
after clear: CacheInfo(hits=0, misses=1, maxsize=None, currsize=1) calls: 2
注意点とベストプラクティス
可変オブジェクト(リスト/辞書)は引数に使わない
可変オブジェクトは直接渡さず、不変に正規化してから渡します。
また、同一オブジェクト(同じID)をキーにしてしまうようなハッシュ実装を持つユーザ定義クラスでは、内部状態を変更してもキーが変わらず、古い結果が返るリスクがあります。
関数の意味が「引数の値」にのみ依存するように設計してください。
必要に応じて、tuple
やfrozenset
へ変換するラッパー関数を用意すると安全です。
外部状態に依存する関数(時刻/乱数/IO)は非推奨
時間(time.time())
、乱数、ファイルやネットワークIO、環境変数など「外部状態」に依存する関数をキャッシュすると、最新の状態を見なくなるという不具合が起きやすいです。
どうしてもキャッシュしたい場合は、取得した外部値を明示的な引数として渡す、バージョン番号やタイムスタンプをキーに混ぜるなどで意図的にキャッシュを無効化(バースト)できるようにします。
TTL(有効期限)を付けたい場合は標準ライブラリにはなく、cachetools.TTLCache
(サードパーティ)の利用を検討してください。
メモリ使用量とキャッシュの寿命(プロセス単位)
キャッシュはプロセスが生きている間、関数オブジェクトにぶら下がって保持されます。
maxsize=None
やfunctools.cache
(Python 3.9+)は無制限で増えるため、長時間動作するプロセスではメモリ増加に注意が必要です。
ヒット率とメモリのバランスを見ながらmaxsize
を設定し、必要に応じて.cache_clear()
を呼んで掃除しましょう。
マルチプロセスではキャッシュは共有されない
キャッシュはプロセスごとに独立し、マルチプロセス間で共有されません。
ワーカープロセスを多数立ち上げる場合は、それぞれが独自にキャッシュを持ちます。
一方で、スレッド間では同一プロセス内の関数デコレータを共有するため、キャッシュも共有されます。
Pythonのlru_cache
は内部でロックを持ちスレッドセーフですが、競合が激しい場合はキャッシュ自体がボトルネックになることがあるため、必要ならmaxsize
やアルゴリズムの見直しを検討してください。
参考早見表
機能/API | 役割 | 補足 |
---|---|---|
@lru_cache(maxsize=128, typed=False) | 関数の結果をキー(引数)に基づきキャッシュ | LRUで上限を超えたら古いものから破棄 |
maxsize=None | 無制限キャッシュ | 長寿命プロセスではメモリ増大に注意 |
typed=True | 1 と 1.0 を別キー扱い | 型で意味が変わる時に有効 |
.cache_info() | ヒット/ミス等の統計取得 | 最適化の効果測定に使う |
.cache_clear() | キャッシュの全削除 | データ更新やデバッグ時に便利 |
functools.cache | lru_cache(maxsize=None) の別名 | Python 3.9+、無制限なので要注意 |
ちょっと進んだ使い方: 引数の正規化とキー設計
順序に意味がない引数はfrozensetにする
たとえばカテゴリー集合の結果が順序に依存しない場合、frozenset
に変換して同値な集合を同じキーにできます。
# normalize_set.py
from functools import lru_cache
@lru_cache(None)
def score(categories: frozenset[str]) -> int:
# 順序に意味がない集合として扱う
return sum(len(c) for c in categories)
def score_for_list(categories_list: list[str]) -> int:
return score(frozenset(categories_list))
if __name__ == "__main__":
print(score_for_list(["a", "bb"]))
print(score_for_list(["bb", "a"])) # キャッシュヒット(順序違いでも同じ)
3
3
辞書の意味的な「正規形」を決める
キーが順序を持たない辞書ならfrozenset(d.items())
に、順序が意味を持つならtuple(sorted(d.items()))
やtuple(d.items())
など、要件に合う「キャッシュキーの定義」を明確にしましょう。
まとめ
functools.lru_cache
は、同じ引数で繰り返し呼ばれる関数を「ほぼゼロコスト」で返せるようにする強力な標準機能です。
導入はデコレータ1行で、maxsizeやtyped、cache_info、cache_clearなどのAPIで運用も容易です。
効果を最大化するには、引数を不変に正規化し、外部状態に依存する処理を避け、ヒット率とメモリ消費のバランスを取ることが重要です。
再帰アルゴリズム(フィボナッチなど)では劇的な高速化が期待できます。
まずは小さな関数から適用し、cache_info()
で結果を観測しながら、あなたのコードベースに合う最適なキャッシュ戦略を育てていきましょう。