ブログ

Pythonの「過剰設計」を解消する:SQLiteを使わずに functools.cache で実装をシンプルにした話

今回は、I Rewrote My Python Script Using functools.cache Instead of a Database. It Was Faster, Simpler, and I Felt Stupid for Not Doing It Sooner. という記事を参考に、Pythonでの効率的なキャッシュ実装と、陥りがちな「過剰設計」の罠について整理してみたいと思います。

functools.cache のお話しですね。


プログラムを組んでいると、重い計算や頻繁なデータ取得を効率化するために「キャッシュ」を導入したくなる場面があります。しかし、その実現方法としてすぐにデータベースを持ち出すのが正解とは限りません。


陥りがちな「データベースによるキャッシュ」の罠

ある程度の規模のプロジェクトを経験していると、「データを保存する = データベース」という思考が反射的に働いてしまうことがあります。元記事の筆者も、同じ入力に対して同じ結果を返す重い計算ロジックを最適化する際、真っ先にSQLiteを採用しました。

その結果、以下のような実装が必要になりました。

  1. SQLiteのセットアップとテーブルスキーマの定義
  2. 計算結果が既に存在するか確認するクエリの作成
  3. 新しい結果を挿入するロジック
  4. DB接続の管理(オープン/クローズ)
  5. 入力をキーとして保存するためのシリアライズ処理

わずか5行程度の計算ロジックを動かすために、60行を超える「データベース関連の定型コード(ボイラープレート)」が積み上がってしまったのです。これは典型的な「過剰設計(Over-engineering)」の例といえるかもしれません。

functools.cache による劇的な簡素化

Python 3.9 から導入された functools.cache を使えば、これら全ての処理をたった1行のデコレータで置き換えることができます。

from functools import cache

@cache
def expensive_thing(x):
    # ここに重い計算処理を書く
    return do_the_expensive_work(x)

このデコレータを付与するだけで、Pythonは関数の引数と戻り値を内部的な辞書に自動的に保存してくれます。次に同じ引数で呼び出されたときは、関数の中身を実行せず、保存された結果を即座に返します。

処理フローの比較

データベースを使った場合と、@cache を使った場合の処理の流れを可視化してみます。

flowchart TD
    subgraph "データベース方式 (過剰設計)"
        A1[関数呼び出し] --> B1["DB接続の確認"]
        B1 --> C1{"結果がDBにあるか?"}
        C1 -- No --> D1["重い計算を実行"]
        D1 --> E1["結果をシリアライズ"]
        E1 --> F1["DBへ保存・コミット"]
        C1 -- Yes --> G1["DBから取得・デシリアライズ"]
        F1 --> H1[結果を返す]
        G1 --> H1
    end

    subgraph "functools.cache 方式"
        A2[関数呼び出し] --> B2["@cache デコレータ"]
        B2 --> C2{"メモリ内に記録あり?"}
        C2 -- No --> D2["計算実行 & 自動記録"]
        C2 -- Yes --> G2["メモリから即座に返却"]
        D2 --> H2[結果を返す]
        G2 --> H2
    end

このように、@cache を使うことで実装の複雑さが大幅に軽減されることがわかります。

実際のパフォーマンスと利便性

実際にどれほどの差が出るのか、元記事でも紹介されていた例を見てみましょう。0.1秒の遅延が発生する処理をシミュレートした場合の結果です。

呼び出し回数 処理内容 実行時間(目安)
初回 実際の計算を実行 0.100000 秒
2回目以降 キャッシュから取得 0.000004 秒

2回目以降のアクセスは、計算をスキップしてメモリから値を読み出すだけなので、約25,000倍も高速化されています。

さらに便利な点として、キャッシュの利用状況を確認できる cache_info() メソッドが自動的に追加されます。

# キャッシュの統計情報を表示
print(expensive_thing.cache_info())
# 出力例: CacheInfo(hits=1, misses=1, maxsize=None, currsize=1)

「どれくらいキャッシュが効いているか(hits)」や「実際に計算が必要だった回数(misses)」が、コードを追加することなく把握できるのは、デバッグやチューニングにおいて非常に助かる機能です。

注意点と使い分け

もちろん、functools.cache が常に最適というわけではありません。以下の点には注意が必要です。

  1. メモリ消費量: @cache は制限なくキャッシュを保存し続けます。メモリ不足が懸念される場合は、最大サイズを指定できる @lru_cache(maxsize=128) などの使用を検討してください。
  2. 永続性: メモリ上に保存されるため、プログラムを終了するとキャッシュは消えてしまいます。実行をまたいでデータを保持したい場合は、依然としてデータベースやファイル保存が必要です。
  3. 引数の型: 引数が辞書やリストなどの「変更可能な(mutable)型」の場合、そのままではキャッシュのキーとして使えません(タプルなどにする必要があります)。

まとめ

「データをどこかに保存して再利用する」という要件に対し、すぐにデータベースを導入するのは、時に「牛刀をもって鶏を割る」ような状況を招きます。

まずは Python の標準ライブラリに目を向け、@cache のようなシンプルで洗練された解決策がないか確認してみるのが良いかと思います。そうすることで、コードの可読性を保ちつつ、開発効率と実行速度の両方を向上させることができるはずです。

参照記事