Странное, неожиданное поведение (исчезающие/изменяющиеся значения) при использовании значения по умолчанию Hash, например. Hash.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]}

но это далеко не так. Что происходит и как я могу получить поведение, которое я ожидаю?

Ответ 1

Во-первых, обратите внимание, что это поведение относится к любому по умолчанию значению, которое впоследствии мутируется (например, хэши и строки), а не только массивы.

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 не может быть присвоения без = present ), мы никогда ничего не помещаем в наш фактический хеш. Вместо этого мы должны использовать <<= (который равен <<, поскольку += равен +):

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 { [] } является жизнеспособным решением, но его бит нечетным и неидиоматическим (Ive никогда не видел <<=, используемого в дикой природе). Его также подвержены тонким ошибкам, если << используется непреднамеренно.

Изменчивый способ

Документация для Hash.new заявляет (акцент мой):

Если задан блок, он будет вызываться с хэш-объектом и ключом и должен возвращать значение по умолчанию. Обязанности блоков хранить значение в хеше, если требуется.

Поэтому мы должны сохранить значение по умолчанию в хэше изнутри блока, если мы хотим использовать << вместо <<=:

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

Это эффективно перемещает назначение из наших индивидуальных вызовов (которые будут использовать <<=) в блок, переданный в Hash.new, устраняя бремя неожиданного поведения при использовании <<.

Обратите внимание, что существует один функциональный различие между этим методом и другими: этот способ присваивает значение по умолчанию при чтении (поскольку назначение всегда происходит внутри блока). Например:

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

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

Неизменный способ

Вам может быть интересно, почему Hash.new([]) не работает, а Hash.new(0) работает нормально. Ключ в том, что Numerics в Ruby неизменяемы, поэтому мы, естественно, никогда не будем мутировать их на месте. Если мы применили наше значение по умолчанию как неизменяемое, мы могли бы использовать Hash.new([]) тоже отлично:

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

Из всех способов, я лично предпочитаю этот путь - неизменность обычно делает рассуждения о вещах намного проще (это, в конце концов, единственный метод, который не имеет возможности скрытого или тонкого неожиданного поведения).


Это не совсем верно, методы вроде instance_variable_set обходят это, но они должны существовать для метапрограммирования, поскольку l-значение в = не может быть динамическим.

Ответ 2

Когда вы вызываете Hash.new([]), значение по умолчанию для любого ключа - это не просто пустой массив, а тот же пустой массив.

Чтобы создать новый массив для каждого значения по умолчанию, используйте структурную форму конструктора:

Hash.new { [] }

Ответ 3

Вы указываете, что значение по умолчанию для хэша является ссылкой на этот конкретный (первоначально пустой) массив.

Я думаю, вы хотите:

h = Hash.new { |hash, key| hash[key] = []; }
h[1]<<=1 
h[2]<<=2 

Это устанавливает значение по умолчанию для каждой клавиши в новый массив.

Ответ 4

Оператор +=, когда применяется к этим хэшам, работает как ожидалось.

[1] pry(main)> foo = Hash.new( [] )
=> {}
[2] pry(main)> foo[1]+=[1]
=> [1]
[3] pry(main)> foo[2]+=[2]
=> [2]
[4] pry(main)> foo
=> {1=>[1], 2=>[2]}
[5] pry(main)> bar = Hash.new { [] }
=> {}
[6] pry(main)> bar[1]+=[1]
=> [1]
[7] pry(main)> bar[2]+=[2]
=> [2]
[8] pry(main)> bar
=> {1=>[1], 2=>[2]}

Это может быть связано с тем, что foo[bar]+=baz является синтаксическим сахаром для foo[bar]=foo[bar]+baz, когда оценивается foo[bar] в правой части =, он возвращает объект значения по умолчанию, а оператор + не будет изменять его. Левая рука представляет собой синтаксический сахар для метода []=, который не изменит значение по умолчанию.

Обратите внимание, что это не относится к foo[bar]<<=baz, поскольку оно будет эквивалентно foo[bar]=foo[bar]<<baz, а << будет изменять значение по умолчанию.

Кроме того, я не нашел разницы между Hash.new{[]} и Hash.new{|hash, key| hash[key]=[];}. По крайней мере, на рубине 2.1.2.

Ответ 5

Когда вы пишете,

h = Hash.new([])

вы передаете ссылку по умолчанию для массива всем элементам хэша. из-за этого все элементы хэша ссылаются на один и тот же массив.

если вы хотите, чтобы каждый элемент хэша ссылался на отдельный массив, вы должны использовать

h = Hash.new{[]} 

для более подробной информации о том, как это работает в ruby, пройдите через это: http://ruby-doc.org/core-2.2.0/Array.html#method-c-new