はじめに
Hash.new([])
を使用して処理を書く場面があったのですが、
その際の思わぬ挙動にハマった話とその対処方法について記事にしました
問題のコード
processed_ids = ::Hash.new {[]}
# user からは group_id と user_id を参照できる
users.each do |user|
next if processed_ids[user.group_id].include?(user.user_id)
processed_ids[user.group_id].push(user.user_id)
end
processed_ids にキーとして group_id を指定し、それに対して配列で user_id を追加していき、
既に group_id に対応する配列に user_id が追加されていたら、処理をスキップする、という処理を書いています。
上記のような記述で動作する...と思っていたのですが、
実際に動かしてみると想定した通りに動きません。
実際にコンソールで処理を細かく見ていくと、以下のような形になっていました。
irb(main):001:0> processed_ids = ::Hash.new([])
=> {}
irb(main):002:0> processed_ids[1].push(101)
=> [101]
irb(main):003:0> processed_ids[1]
=> [101]
irb(main):004:0> processed_ids[2]
=> [101] # !?
irb(main):005:0> processed_ids[2].include?(101)
=> true # !!?
Hash に対して新しくキーを指定し push で値を追加してから他のキーで参照すると、
そのキーでも同じ値が格納されていました。
そんなバカなと思い、ドキュメントを確認してみると...
- new(ifnone = nil) -> Hash
- 空の新しいハッシュを生成します。ifnone はキーに対応する値が存在しない時のデフォルト値です。設定したデフォルト値はHash#defaultで参照できます。
- ifnoneを省略した Hash.new は {} と同じです。
- デフォルト値として、毎回同一のオブジェクトifnoneを返します。それにより、一箇所のデフォルト値の変更が他の値のデフォルト値にも影響します。
一箇所のデフォルト値の変更が他の値のデフォルト値にも影響します。
!?
どうやらこの挙動は仕様して存在しているようです。
ドキュメントに例として以下のコードが書かれていましたが、
h = Hash.new([])
p h[1] #=> []
p h[1].object_id #=> 6127150
p h[1] << "bar" #=> ["bar"]
p h[1] #=> ["bar"]
p h[2] #=> ["bar"]
p h[2].object_id #=> 6127150
p h #=> {}
h[1]
に格納した値と、h[2]
に格納されている値が同じものになっています。
なるほど...
対処方法
以下のように Hash の宣言をすると、先に挙げた挙動を回避できました。
Hash.new { |hash, key| hash[key] = [] }
初期化の際にブロックを与えると、
まだ値が無いキーを参照した際にそのブロックが評価され別のオブジェクトになってくれるようです。
irb(main):001:0> processed_ids = Hash.new { |hash, key| hash[key] = [] }
=> {}
irb(main):002:0> processed_ids[1].push(101)
=> [101]
irb(main):003:0> processed_ids[1]
=> [101]
irb(main):004:0> processed_ids[2]
=> []
irb(main):005:0> processed_ids[2].include?(101)
=> false