Ruby: 私がメモ化を暗黙で使わない3つの理由(翻訳)
この間Bikeshed podcastのエピソード#237を聴いていると、ホスト役の人がRubyの||=
というイディオムで値をメモ化(memoization)するのが良いのはどんな場合かについて議論していました。これはRubyのベテラン開発者もよく疑問に思うことなので、私なりの見解を本記事にまとめることにしました。私に言わせれば「使っていい場合などない」の一言です。
🔗 メモ化のどこが問題なのか
以下のコード例を見てみましょう。ここではデータベースにクエリをかけてユーザーをidで検索し、続いてユーザーのメールアドレスを用いてAPI呼び出しを行うことで、プロファイルをダウンロードして名前を取得しています。以下のコード例はいかにもわざとらしいのですが、これに似たコードを実際に目にすることはかなりよくあります。
def name
@name ||= @api.fetch_profile(User.find(@id).email).name
end
ちょっと話がそれますが、このコードには他にも問題が山盛りです。しかし本記事ではメモ化という切り口に注目したいので、それ以外の点には目をつぶることにしましょう。さて、「メモ化を暗黙で使わない3つの理由」とは何でしょうか?
🔗 理由1: 呼び出しによる影響の大きさが呼び出し側で軽視されてしまう
一般に、この種のメモ化は名詞形のメソッド名と深く関連しがちです。
name
というメソッド名はまったく特徴がないので、コードを読む開発者に「呼び出し側はこのメソッドの背後で行われていることを気にする必要はありませんよ」というシグナルが伝わります。要件を満たすのに必要な相互依存的ネットワーク操作が順序によっては不安定になることがまったく考慮されないまま、このメソッドを何も考えずに呼び出す習慣が定着してしまいます。
背後にあるものをカプセル化したい気持ちはわかりますが、呼び出し側に誤解を与えないようにできないものでしょうか?
🔗 理由2: 呼び出し側はキャッシュを無効化したくてもできない
このメモ化スタイルでは、呼び出し側が決して新しい値を欲しがらないことが前提になっています。Webアプリでは常にWebリクエストを扱うものなので、データを2回もフェッチするはずがないという前提があるからなのでしょう。
しかし残念ながら、アプリケーション内のデータフローに関する理解は、このメモ化によってゆっくりと崩れていき、問題をデバッグしようとしたり、同じコードベースの上に別のものを実装したりするのがずっと難しくなってしまいます。
🔗 理由3: 冗長な作業を呼び出し側で止める方法がない
上のコード例では、既にuser
が呼び出し側で利用可能な状態であっても、このメソッドはお構いなしにフェッチを再実行します。十分設計されたシステムなら、依存性を注入できるようにしておくべきでしょう。特に、ネットワークアクセスやデータベースアクセスのように取得時にエラーが発生しやすい操作については、なおさらです。
🔗 解決方法
上の3つの問題をすべて回避するにはどうすればよいでしょうか?
回避するのはさほど難しくありませんが、既存のアーキテクチャにもっと重大な設計ミスが潜んでいないかどうかに注意しておく必要があります。しかしながら、事態の悪化を食い止めるのに遅すぎるということはありません。前置きはこれぐらいにして、上述の問題をすべて消し去ったコードを以下に示します。
def retrieve_name email: User.find(@id).email, api: @api
api.fetch_profile(email).name
end
もしかすると「え、これってキャッシュを取り除いて、余分な引数をいくつか追加しただけでは?」と二度見したかもしれませんね。手短に説明しますのでご辛抱ください。
このメソッドの引数はどちらも省略可能なので、これまで通り何も渡さずに呼び出せるのがポイントです。それでは元のコードの問題が本当に解決されたかどうかを見ていきましょう。
🔗 1. この呼び出しによる影響の大きさを呼び出し側が軽視してしまいそうか?
いいえ。メソッド名がretrieve_name
のように動詞形になったので、このメソッドを呼び出すと何が行われるかが明確になりました。これだけで、開発者に正しいシグナルが伝わるようになります。
🔗 2. 呼び出し側がキャッシュを無効化できるか?
はい。
name = retrieve_name
# nameはキャッシュ済みなので、自由に再利用可能
do_something_with(name)
do_something_else_with(name)
# 必要ならいつでも新しいnameを取り出せる
fresh_name = retrieve_name
🔗 3. 冗長な作業を呼び出し側で止められるか?
もちろんです。
my_user = User.find(123)
name = retrieve_name(email: my_user.email) # データベース呼び出しを抑制する
冗長な呼び出しが行われなくなったことが明示されているわけではありませんが、元のコードではこれと同じ形で引数を渡しても無効でした(元のコードでは単に1個の値をキャッシュしただけで、別のユーザーを渡しても最初にキャッシュされた値が引き続き返されていました)。
このように、ほんのわずか手間をかけただけで、コードの「メンテナンス性」「再利用性」「パフォーマンス」で大きなメリットを得られるのです。
🔗 FAQ
🔗 このメソッドを別の場所で呼び出したいのですが、再利用する変数がありません
つらいお気持ちお察しします。恐縮ですが、変数に代入して使い回す方法が利用できないという理由でこのメモ化によるキャッシュ技法を手放せない方には、いくつか残念なお知らせがあります。
現在の抽象化は見直す必要があります。現在のコードのトップレベルには、特定のトランザクションに関するストーリーを伝えるルーチンがあるはずです。再利用する値をそのコンテキストで浮かび上がらせて、必要なすべての場所に渡す必要があります。素のRailsなら、コントローラのアクションが該当するでしょう。
このように変更するとアクションのコードが増えすぎてしまうのであれば、このルーチンを書くためのクリーンな抽象化を与えてくれる中間オブジェクトが必要になります。しかしこれはかなり大きなトピックなので、今後の記事に譲ることにします。
概要
原著者の許諾を得て翻訳・公開いたします。
以下の記事もどうぞ。