Techioz Blog

ハッシュのデフォルト値を使用すると、奇妙な予期しない動作 (値の消失/変更) が発生します。ハッシュ.new([])

概要

次のコードを考えてみましょう。

h = Hash.new(0)  # New hash pairs will by default have 0 as values
h[1] += 1  #=> {1=>1}
h[2] += 2  #=> {2=>2}

それはそれでいいのですが、次のようになります。

h = Hash.new([])  # Empty array as default value
h[1] <<= 1  #=> {1=>[1]}                  ← Ok
h[2] <<= 2  #=> {1=>[1,2], 2=>[1,2]}      ← Why did `1` change?
h[3] << 3   #=> {1=>[1,2,3], 2=>[1,2,3]}  ← Where is `3`?

この時点で、ハッシュは次のようになると予想します。

{1=>[1], 2=>[2], 3=>[3]}

しかしそれは程遠いです。何が起こっているのでしょうか?どうすれば期待どおりの動作を得ることができますか?

解決策

まず、この動作は配列だけでなく、その後変更されるデフォルト値 (ハッシュや文字列など) にも適用されることに注意してください。これは、Array.new(3, []) 内の設定された要素にも同様に適用されます。

TL;DR: Hash.new { |h, k| を使用します。 h[k] = [] } 最も慣用的な解決策が必要で、理由は気にしない場合。

Hash.new([]) が機能しない理由をさらに詳しく見てみましょう。

h = Hash.new([])
h[0] << 'a'  #=> ["a"]
h[1] << 'b'  #=> ["a", "b"]
h[1]         #=> ["a", "b"]

h[0].object_id == h[1].object_id  #=> true
h  #=> {}

デフォルトのオブジェクトが再利用され、変更されていることがわかります (これは、オブジェクトが唯一のデフォルト値として渡され、ハッシュには新しいデフォルト値を取得する方法がないためです)。しかし、キーや値がないのはなぜですか? h[1] がまだ値を与えているにもかかわらず、配列内にあるのでしょうか?ヒントは次のとおりです。

h[42]  #=> ["a", "b"]

各 [] 呼び出しによって返される配列は単なるデフォルト値であり、これまでずっと変更してきたため、新しい値が含まれるようになりました。 << はハッシュに代入しないので (Ruby では = present† なしで代入することはできません)、実際のハッシュには何も入れていません。代わりに、<<= (+= が + であるのと同じように、<< になります) を使用する必要があります。

h[2] <<= 'c'  #=> ["a", "b", "c"]
h             #=> {2=>["a", "b", "c"]}

これは次と同じです。

h[2] = (h[2] << 'c')

Hash.new { [] } を使用すると、元のデフォルト値の再利用と変更の問題は解決されますが (指定されたブロックが毎回呼び出され、新しい配列が返されるため)、代入の問題は解決されません。

h = Hash.new { [] }
h[0] << 'a'   #=> ["a"]
h[1] <<= 'b'  #=> ["b"]
h             #=> {1=>["b"]}

常に <<= を使用することを忘れない場合、 Hash.new { [] } は実行可能な解決策ですが、少し奇妙で慣用的ではありません (<<= が実際に使用されているのを見たことがありません)。また、<< を誤って使用すると、微妙なバグが発生する可能性があります。

Hash.new のドキュメントには次のように書かれています (私自身の意見を強調します)。

したがって、<<= の代わりに << を使用したい場合は、ブロック内からハッシュにデフォルト値を保存する必要があります。

h = Hash.new { |h, k| h[k] = [] }
h[0] << 'a'  #=> ["a"]
h[1] << 'b'  #=> ["b"]
h            #=> {0=>["a"], 1=>["b"]}

これにより、割り当てが個々の呼び出し (<<= を使用する) から Hash.new に渡されるブロックに事実上移動され、<< 使用時の予期せぬ動作の負担が軽減されます。

このメソッドと他のメソッドの間には機能的な違いが 1 つあることに注意してください。この方法では、読み取り時にデフォルト値が割り当てられます (割り当ては常にブロック内で行われるため)。例えば:

h1 = Hash.new { |h, k| h[k] = [] }
h1[:x]
h1  #=> {:x=>[]}

h2 = Hash.new { [] }
h2[:x]
h2  #=> {}

Hash.new(0) は問題なく動作するのに、なぜ Hash.new([]) が動作しないのか疑問に思われるかもしれません。重要なのは、Ruby の数値は不変であるため、当然のことながら、数値をその場で変更することは決してないということです。デフォルト値を不変として扱う場合は、Hash.new([]) も問題なく使用できます。

h = Hash.new([].freeze)
h[0] += ['a']  #=> ["a"]
h[1] += ['b']  #=> ["b"]
h[2]           #=> []
h              #=> {0=>["a"], 1=>["b"]}

ただし、([].freeze + [].freeze).frozen? == 偽。したがって、不変性が全体にわたって確実に保持されるようにしたい場合は、新しいオブジェクトを再フリーズするように注意する必要があります。

すべての方法の中で、私は個人的に「不変の方法」を好みます。一般に、不変であると、物事についての推論がはるかに簡単になります。結局のところ、これは、隠れた、または微妙な予期せぬ動作が発生する可能性がない唯一の方法です。ただし、最も一般的で慣用的な方法は「可変的な方法」です。

最後に余談ですが、ハッシュのデフォルト値のこの動作は、Ruby Koans に記載されています。

† これは厳密には当てはまりません。instance_variable_set のようなメソッドはこれをバイパスしますが、= の左辺値は動的にできないため、メタプログラミングのために存在する必要があります。