閉じる

メモ化で処理を爆速化: Pythonのfunctools.lru_cacheについて解説

同じ引数で何度も呼び出す関数が遅くて困った経験はありませんか。

そんな時に便利なのが、関数の戻り値を自動で覚えて再利用するメモ化(キャッシュ)です。

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回目以降が高速になる様子を示します。

Python
# 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秒で返っているのは、結果がキャッシュから取り出されているためです

再帰関数の高速化(例: フィボナッチ)

フィボナッチ数列のような指数的に呼び出しが増える再帰関数は、メモ化で劇的に高速化できます。

次の例では、素の再帰とlru_cache付きの違いを比べます。

Python
# 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)でなければいけません

よく使う型の目安は次の通りです。

  • ハッシュ可能: intstrfloatboolNonetuple(中身がハッシュ可能)frozenset
  • 非ハッシュ可能: listdictset、可変のユーザ定義オブジェクトなど

リストや辞書をそのまま渡すとTypeErrorになるため、タプルやfrozensetに変換して渡します

Python
# 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

「渡す前に正規化して不変にする」というパターンを覚えておくと安全です

パラメータと便利なAPI(maxsize/typed, cache_info, cache_clear)

maxsizeの意味と選び方

maxsizeはキャッシュに保持できる件数の上限です。

アクセス頻度の高い引数のバリエーション数に見合ったサイズを選ぶとヒット率が上がります

無制限(None)だとヒット率は高まりますが、メモリ使用量が増える点に注意してください。

Python
# 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(デフォルト)では、11.0が同一キー扱いになります。

数値型の違いを区別したい場合はtyped=Trueを設定します

Python
# 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を調整します

Python
# 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()を呼ぶとキャッシュを空にできます。

外部データのバージョンが変わった時など、「一斉に更新したい」場面で使います

Python
# 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)をキーにしてしまうようなハッシュ実装を持つユーザ定義クラスでは、内部状態を変更してもキーが変わらず、古い結果が返るリスクがあります。

関数の意味が「引数の値」にのみ依存するように設計してください。

必要に応じて、tuplefrozensetへ変換するラッパー関数を用意すると安全です。

外部状態に依存する関数(時刻/乱数/IO)は非推奨

時間(time.time())、乱数、ファイルやネットワークIO、環境変数など「外部状態」に依存する関数をキャッシュすると、最新の状態を見なくなるという不具合が起きやすいです。

どうしてもキャッシュしたい場合は、取得した外部値を明示的な引数として渡すバージョン番号やタイムスタンプをキーに混ぜるなどで意図的にキャッシュを無効化(バースト)できるようにします。

TTL(有効期限)を付けたい場合は標準ライブラリにはなく、cachetools.TTLCache(サードパーティ)の利用を検討してください。

メモリ使用量とキャッシュの寿命(プロセス単位)

キャッシュはプロセスが生きている間、関数オブジェクトにぶら下がって保持されます

maxsize=Nonefunctools.cache(Python 3.9+)は無制限で増えるため、長時間動作するプロセスではメモリ増加に注意が必要です。

ヒット率とメモリのバランスを見ながらmaxsizeを設定し、必要に応じて.cache_clear()を呼んで掃除しましょう。

マルチプロセスではキャッシュは共有されない

キャッシュはプロセスごとに独立し、マルチプロセス間で共有されません

ワーカープロセスを多数立ち上げる場合は、それぞれが独自にキャッシュを持ちます。

一方で、スレッド間では同一プロセス内の関数デコレータを共有するため、キャッシュも共有されます。

Pythonのlru_cacheは内部でロックを持ちスレッドセーフですが、競合が激しい場合はキャッシュ自体がボトルネックになることがあるため、必要ならmaxsizeやアルゴリズムの見直しを検討してください。

参考早見表

機能/API役割補足
@lru_cache(maxsize=128, typed=False)関数の結果をキー(引数)に基づきキャッシュLRUで上限を超えたら古いものから破棄
maxsize=None無制限キャッシュ長寿命プロセスではメモリ増大に注意
typed=True1 と 1.0 を別キー扱い型で意味が変わる時に有効
.cache_info()ヒット/ミス等の統計取得最適化の効果測定に使う
.cache_clear()キャッシュの全削除データ更新やデバッグ時に便利
functools.cachelru_cache(maxsize=None) の別名Python 3.9+、無制限なので要注意

ちょっと進んだ使い方: 引数の正規化とキー設計

順序に意味がない引数はfrozensetにする

たとえばカテゴリー集合の結果が順序に依存しない場合、frozensetに変換して同値な集合を同じキーにできます

Python
# 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行で、maxsizetypedcache_infocache_clearなどのAPIで運用も容易です。

効果を最大化するには、引数を不変に正規化し、外部状態に依存する処理を避け、ヒット率とメモリ消費のバランスを取ることが重要です。

再帰アルゴリズム(フィボナッチなど)では劇的な高速化が期待できます。

まずは小さな関数から適用し、cache_info()で結果を観測しながら、あなたのコードベースに合う最適なキャッシュ戦略を育てていきましょう。

Python 実践TIPS - コーディング効率化・Pythonic
この記事を書いた人
エーテリア編集部
エーテリア編集部

人気のPythonを初めて学ぶ方向けに、文法の基本から小さな自動化まで、実際に手を動かして理解できる記事を書いています。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!