Immutable な Value Object とメモ化の相性が良くない件→解決

2022/11/03に公開
2

問題提起

例がいまいちだけど、次のように URL を受け取って URL の引数を簡単に取得できるクラスがあったとする。

require "rack"

class FooUrl
  def initialize(url)
    @url = url
  end

  def xxx
    params["xxx"]
  end

  private

  def params
    @params ||= Rack::Utils.parse_query(URI(@url).query)
  end
end

foo_url = FooUrl.new("https://example.com/?xxx=1")
foo_url.xxx  # => "1"

これはインスタンスに対して get しかしてないので Value Object と考えられる。Value Object だったら Immutable にした方がいいだろう。というわけでコンストラクタの最後に freeze を入れてみる。

require "rack"

class FooUrl
  def initialize(url)
    @url = url
    freeze                      # ← 追加
  end

  def xxx
    params["xxx"]
  end

  private

  def params
    @params ||= Rack::Utils.parse_query(URI(@url).query)
  end
end

foo_url = FooUrl.new("https://example.com/?xxx=1")
foo_url.xxx  # =>

するとこれ。

# ~> -:16:in `params': can't modify frozen FooUrl (FrozenError)
# ~>    from -:10:in `xxx'
# ~>    from -:21:in `<main>'

freeze する前に params を実行しておくとかダサすぎるし、いったいどうしたらいいんだともう何年も悩んでいる。

というのを投稿したらコメントで良いアイデアを教えていただいた。

対処法

require "rack"

class FooUrl
  def initialize(url)
    @url = url
    @cache = {}
    freeze
  end

  def xxx
    params["xxx"]
  end

  private

  def params
    @cache[:params] ||= Rack::Utils.parse_query(URI(@url).query)
  end
end

foo_url = FooUrl.new("https://example.com/?xxx=1")
foo_url.xxx  # => "1"

freeze 前にメモ化用のハッシュを用意しておけばよかった。解決!

Data クラスを使うときも同様の方法で行ける

Vector = Data.define(:x, :y) do
  def initialize(...)
    @cache = {}
    super
  end

  def length
    @cache[:length] ||= Math.sqrt(x**2 + y**2)
  end
end

vector = Vector.new(1, 2)
vector.length  # => 2.23606797749979

親クラスの initialize で freeze されているっぽいので super を呼ぶ前に @cache を定義しておく。

同じ問題を取り扱った書籍

後日に読んだ『研鑽Rubyプログラミング ― 実践的なコードのための原則とトレードオフ』 の P.63 で「うまくいかないケース」としてこれと似た問題に言及されている。

Discussion

Kenta MurataKenta Murata

initialize で @params = [] しておいて、params の中では @params[0] ||= ... のようにすると、とりえあず動くようになりますね。

megetonmegeton

コメントありがとうございます
self を freeze しても既存のインスタンス変数は変更できるんですね