データベースで検索した結果を、広告媒体ごとのハッシュでまとめて返す次のようなメソッドを書いていました。
DEFAULT = {medium1: [], medium2: []}.freeze
# 検索結果を広告媒体にまとめて返す
# @param [String] query 検索文字列
# @return [Hash] 広告媒体ごとの検索結果
def search_medium_data(query)
rows = request_to_database(query)
return DEFAULT if data.nil?
rows.each_with_object(DEFAULT.dup) do |row, hash|
hash[row[:account_type]] ||= []
hash[row[:account_type]] << row
end
end
ところが、呼び出すたびに、定数のつもりで定義したDEFAULT
の中の配列に、どんどん値が追加されていってしまっていました。
これは、
- freezeは配列の中身までイミュータブルにしない
- dupメソッドはハッシュの中身まで複製しない浅いコピー (shallow copy) である
ことが原因でした。
cloneとdupは「浅いコピー」を作ることに注意してください。上記のCatクラスの例では、@nameがoriginalからcopiedにコピーされますが、@nameが指しているオブジェクト(文字列)は同じものです。copiedの@nameに対して破壊的なメソッド(レシーバ自身を変更するメソッド)を呼び出すと、originalの@nameが指している文字列も変更されます。
Rubyのドキュメントは「浅いコピー」の説明がざっくりとしすぎているので、Pythonのドキュメントの同じような箇所を引っ張ってきました。
浅い (shallow) コピーと深い (deep) コピーの違いが関係するのは、複合オブジェクト (リストやクラスインスタンスのような他のオブジェクトを含むオブジェクト) だけです:
- 浅いコピー (shallow copy) は新たな複合オブジェクトを作成し、その後 (可能な限り) 元のオブジェクト中に見つかったオブジェクトに対する 参照 を挿入します。
- 深いコピー (deep copy) は新たな複合オブジェクトを作成し、その後元のオブジェクト中に見つかったオブジェクトの コピー を挿入します。
深いコピー操作には、しばしば浅いコピー操作の時には存在しない 2 つの問題がついてまわります:
- 再帰的なオブジェクト (直接、間接に関わらず、自分自身に対する参照を持つ複合オブジェクト) は再帰ループを引き起こします。
- 深いコピーでは、何もかも をコピーするため、例えば複数のコピー間で共有されるべき管理データ構造までも、余分にコピーしてしまいます。
「新人エンジニアがはまるかもしれないrubyの浅いコピーと深いコピーともろもろ背景」という記事で詳しく説明されていました。
対処法はMarshal
を使う方法がありますが、
DEFAULT = {medium1: [], medium2: []}.freeze
b = Marshal.load(Marshal.dump(DEFAULT))
b[:medium1] << 1
p b
# => {:medium1=>[1], :medium2=>[]}
p DEFAULT
# => {:medium1=>[], :medium2=>[]}
はっきり言って醜いので嫌な感じがします。
他にもこちらのstack overflowによるとruby_deep_cloneというgem使う方法もあるみたいです。
require "deep_clone"
object = SomeComplexClass.new()
cloned_object = DeepClone.clone(object)
ただし、これだけのことをするためにgemを入れるのはイケてないなという感じがします。
結局、メソッドの中で毎回ハッシュを新しく作り直すことにしました。
# コードの他の箇所でも使っているので残している
DEFAULT = {medium1: [], medium2: []}.freeze
# 検索結果を広告媒体にまとめて返す
# @param [String] query 検索文字列
# @return [Hash] 広告媒体ごとの検索結果
def search_medium_data(query)
rows = request_to_database(query)
default = {medium1: [], medium2: []}
return default if data.nil?
rows.each_with_object(default) do |row, hash|
hash[row[:account_type]] ||= []
hash[row[:account_type]] << row
end
end
これもこれで、「媒体名が増えてハッシュのキーが増える」ような場合があったら困りそうなのですが。