💎

Rubyでインスタンス変数によるキャッシュの間違いやすい実装とその改善例

2023/01/14に公開約2,700字

やりたい事

やりたい事として、次の状況を仮定します。

  • Ruby でインスタンスメソッドを実行する際に、コストのかかる処理がある
  • その処理は複数回実行されるが、1回実行して取得した結果を2回目以降も使いまわしたい(キャッシュしたい)

具体的なコードの例は次になります。

class User
  def sample_value
    # コストのかかる処理
  end
end

user = User.new

p user.sample_value
p user.sample_value

間違いやすい実装例

次は不十分な実装例です。cache_sample1 および cache_sample2 メソッドの2パターンで、インスタンス変数を使ってキャッシュを実装しようとしています。

class User
  def cache_sample1
    @cache_sample1 ||= sample_value
  end

  def cache_sample2
    if @cache_sample2
      @cache_sample2
    else
      @cache_sample2 = sample_value
    end
  end

  def sample_value
    # コストのかかる処理
    
    p "キャッシュを利用していない"

    # 値を返す
  end
end

user = User.new

p user.cache_sample1
p user.cache_sample1

p user.cache_sample2
p user.cache_sample2

例えば sample_value メソッドが文字列 Value を返す場合、実行結果は次になります。

"キャッシュを利用していない"
"Value"
"Value"
"キャッシュを利用していない"
"Value"
"Value"

それぞれ、1回目の呼び出しでは「キャッシュを利用していない と値 Value」が出力され、2回目には「値 Value」のみが出力されているため、キャッシュが実装できています。ただし、これは sample_value メソッドに依存しています。

問題となるのは、次の様に sample_value メソッドが偽を返す場合です。

class User
  def cache_sample1
    @cache_sample1 ||= sample_value
  end

  def cache_sample2
    if @cache_sample2
      @cache_sample2
    else
      @cache_sample2 = sample_value
    end
  end

  def sample_value
    p "キャッシュを利用していない"

    nil
  end
end

user = User.new

p user.cache_sample1
p user.cache_sample1

p user.cache_sample2
p user.cache_sample2

実行結果は次になります。

"キャッシュを利用していない"
nil
"キャッシュを利用していない"
nil
"キャッシュを利用していない"
nil
"キャッシュを利用していない"
nil

呼び出しの都度「キャッシュを利用していない と値 Value」が出力されている事から、キャッシュが実現できていない事が確認できます。

これは、1回目の実行で sample_value の戻り値が @cache_sample1@cache_sample2 に代入されるのですが、2回目の実行でキャッシュ済みかを比較した時に、キャッシュされていないと判定されるためです。なぜなら、インスタンス変数の値が偽のためです。

改善例

例えば次のように Object#instance_variable_defined? メソッドを使うと改善します。

class User
  def cache_sample3
    if instance_variable_defined?(:@cache_sample3)
      @cache_sample3
    else
      @cache_sample3 = sample_nil_value
    end
  end

  def cache_sample4
    if instance_variable_defined?(:@cache_sample4)
      @cache_sample4
    else
      @cache_sample4 = sample_present_value
    end
  end

  def sample_nil_value
    p "キャッシュを利用していない"

    nil
  end

  def sample_present_value
    p "キャッシュを利用していない"

    "Value"
  end
end

user = User.new

p user.cache_sample3
p user.cache_sample3

p user.cache_sample4
p user.cache_sample4

実行結果は次になります。

"キャッシュを利用していない"
nil
nil
"キャッシュを利用していない"
"Value"
"Value"

これは、1回目の実行で sample_value の戻り値が @cache_sample1@cache_sample2 に代入されると同時にそれらのインスタンス変数が定義されるためです。2回目の実行でキャッシュ済みかを比較した時に、インスタンス変数が定義済みなので、キャッシュされていると判定されます。

Discussion

ログインするとコメントできます