1. ホーム
  2. ruby

[解決済み] Hash.new([]などのHashのデフォルト値を使用すると、予期せぬ動作(値が消える/変わる)が発生します。)

2022-11-07 22:21:35

質問

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

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([]) は機能しません。

もっと詳しく見てみましょう。 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では = がなければ代入できません)。 のように)、実際のハッシュには何も入れていません。代わりに、私たちは <<= (これは << として +=+ ):

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

と同じである。

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

なぜ Hash.new { [] } は機能しません。

使用方法 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([]) は機能しないのに Hash.new(0) はうまく動作します。重要なのは、RubyのNumericsはimmutableなので、当然ながらインプレースで変異させることはない、ということです。もしデフォルト値をimmutableとして扱えば Hash.new([]) も問題なく使えます。

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

ただし ([].freeze + [].freeze).frozen? == false . ですから、もし不変性がずっと保たれるようにしたいのであれば、新しいオブジェクトを再凍結するように注意する必要があります。


結論

すべての方法の中で、私は個人的に「不変の方法」を好みます。不変であることは、一般的に物事の推論をよりシンプルにします。結局のところ、隠れた、あるいは微妙に予期しない動作の可能性がない唯一の方法なのです。しかし、最も一般的で慣用的な方法は、「ミュータブルな方法」です。

最後に余談ですが、このHashのデフォルト値の挙動は、以下のように記されています。 Ruby の公案 .


これは厳密には正しくなく、以下のようなメソッドがあります。 instance_variable_set のようなメソッドはこれをバイパスしますが、これらはメタプログラミングのために存在しなければなりません。 = のl値は動的であってはならないからです。