🪤

RubyのHashでハマる共通参照の罠とその解決方法

に公開

こんにちは!普段Ruby on Railsを使って開発をしているourly株式会社の@ourly_nobuoです。

今回はイテレーション内で“共通のオブジェクトを参照してしまう問題”を掘り下げていきます。

特にHashオブジェクトを参照・書き換えることの多いRubyでは、比較的陥りやすい落とし穴かと思います。

私も今回の実装中にこの落とし穴にハマったので、学びをまとめつつ共有させていただきます。

1. each_with_object内で起きた予期せぬ挙動

では早速問題です!
次のようなコードをご覧ください。このコードの出力結果はどんな値になるでしょうか?

users_info = [
  {name: 'Alice', age: 25},
  {name: 'Bob', age: 30}
]
template = { profile: { age: 0 } }
accumulator = {
  'Alice' => template,
  'Bob'   => template
}

result =
  users_info.each_with_object(accumulator) do |user, acc|
    user_name = user[:name]
    user_age = user[:age]
    acc[user_name][:profile][:age] = user_age
  end

pp result
正解はこちら!
pp result
# {"Alice"=>{:profile=>{:age=>30}}, "Bob"=>{:profile=>{:age=>30}}}

Aliceは25歳、Bobは30歳になることを期待して作成したはずが実行結果は両方とも30歳になってしまいます。

もし期待していたものと違っていた人は、ぜひ引き続き本記事をお読みください!

「こんなの簡単すぎるぜ😎」って方も、一応考え方が間違っていないか最後までお付き合いいただけると幸いです。

2. 原因を探る ― 同じオブジェクトを共有していた

each_with_object は引数に渡した accumulator を全イテレーションで使い回しますが、今回バグを引き起こした本質的な原因はAliceとBobが同じ template のHashインスタンスを参照していたことです。

acc['Alice'].object_id == acc['Bob'].object_id
=> true

外側のキーが違っても内側の{:profile=>{age: 0}} は1つだけ。
そのため最後のループで30を書き込んだ瞬間、同じオブジェクトを指していたAlice側の年齢も30に上書きされてしまいました。

3. 解消策 — オブジェクトを複製する

dup はレシーバ本体だけを複製する(浅いコピー)

直感的には template.dup でテンプレートを複製すれば問題が解決しそうですが、dup が作るのは 「レシーバ自身のコピー」 だけです。ハッシュ内部で参照している :profile などのネストしたオブジェクトは複製されず、元のインスタンスをそのまま共有します。このような一部だけの複製を 浅いコピー(shallow copy) と呼びます。

その結果、AliceBob が指す :profile は同一オブジェクトのまま。後のループで 30 を書き込むと両者に波及し、先ほどと同じ “30 歳 × 2” という結果になります。

⭕️deep_dup で再帰的にネストしたものを複製していく

内部まで再帰的に複製できる deep_dup を使えば問題は解消します(こちらは ActiveSupport に依存したメソッドとなるのでご注意)。

...

accumulator = {
  'Alice' => template.deep_dup, # ← ここがポイント
  'Bob'   => template.deep_dup
}

result =
  users_info.each_with_object(accumulator) do |user, acc|
    user_name = user[:name]
    user_age = user[:age]
    acc[user_name][:profile][:age] = user_age
  end

pp result
# => {
#      "Alice"=>{:profile=>{:age=>25}},
#      "Bob"  =>{:profile=>{:age=>30}}
#    }

deep_dupすべてのネストに対して再帰的にコピーを行うため、AliceBob がそれぞれ独立した profile ハッシュを所持し、互いに干渉しません。

🔺定数やテンプレートは freeze してしまう

上記2つとは少し角度が別ですが、 共通参照を未然に防ぐという点では templatefreeze することも有効です。
その後の処理で破壊的な変更がされると FrozenError を引き起こしてくれます。そのため複製して別のオブジェクトにするか、また別の方法で実装するしかなくなります。

template = { profile: { age: 0 } }.freeze

4. まとめ

変数に格納したり、Hash内の1つのkeyとvalueを扱う際には、「同じオブジェクトを参照していないか?」を考えることが大事です。
この視点を持つだけで、多くの参照バグは未然に防げるのと、もし共通参照が起きてしまう場合にも dupdeep_dup をうまく活用してみましょう!

それでは、快適な Ruby ライフを!

参考文献

https://railsguides.jp/active_support_core_extensions.html#deep-dup
https://k-koh.hatenablog.com/entry/2022/07/03/182620
https://docs.ruby-lang.org/ja/latest/method/Object/i/freeze.html
https://tatsuki-ju.hatenablog.com/entry/2023/09/02/002822

ourly tech blog

Discussion