Странное, неожиданное поведение (исчезновение / изменение значений) при использовании значения Hash по умолчанию, например Hash.new ([])

107

Рассмотрим этот код:

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]}

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

Валентин Васильев
источник

Ответы:

164

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

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, снимая бремя неожиданного поведения при использовании <<.

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

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

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

Неизменный путь

Вам может быть интересно, почему Hash.new([])не работает, а Hash.new(0)работает нормально. Ключ в том, что числовые значения в 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? == false. Итак, если вы хотите гарантировать, что неизменность сохраняется повсюду, вы должны позаботиться о повторном замораживании нового объекта.


Вывод

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

И наконец, такое поведение значений хэша по умолчанию отмечено в Ruby Koans .


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

Эндрю Маршалл
источник
1
Следует отметить, что использование «изменяемого способа» также приводит к тому, что каждый поиск по хешу сохраняет пару значений ключа (поскольку в блоке происходит присвоение), что не всегда может быть желательным.
johncip
@johncip Не каждый поиск, только первый для каждого ключа. Но я понимаю, что вы имеете в виду, я добавлю это к ответу позже; Спасибо!.
Эндрю Маршалл
Упс, неряшливо. Вы правы, конечно, это первый поиск неизвестного ключа. Я почти чувствую, что у { [] }with <<=меньше всего сюрпризов, если бы не тот факт, что случайное забывание =могло бы привести к очень запутанному сеансу отладки.
johncip
довольно четкие объяснения различий при инициализации хэша значениями по умолчанию
cisolarix
23

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

Я думаю, ты хочешь:

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

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

Мэтью Флашен
источник
Как я могу использовать отдельные экземпляры массива для каждого нового хеша?
Валентин Васильев
5
Эта блочная версия дает вам новые Arrayэкземпляры при каждом вызове. А именно: h = Hash.new { |hash, key| hash[key] = []; puts hash[key].object_id }; h[1] # => 16348490; h[2] # => 16346570. Также: если вы используете версию блока, которая устанавливает значение ( {|hash,key| hash[key] = []}), а не ту, которая просто генерирует значение ( { [] }), тогда вам нужно только <<, а не <<=при добавлении элементов.
Джеймс А. Розен,
3

Оператор, +=примененный к этим хешам, работает должным образом.

[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.

Даниэль Рибейро Морейра
источник
Хорошее объяснение. Похоже, что на ruby ​​2.1.1 Hash.new{[]}то же самое, что и Hash.new([])у меня, с отсутствием ожидаемого <<поведения (хотя, конечно, Hash.new{|hash, key| hash[key]=[];}работает). Странные мелочи, ломающие все: /
butterywombat
1

Когда пишешь,

h = Hash.new([])

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

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

h = Hash.new{[]} 

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

Ганеш Сагаре
источник
Это неправильно, Hash.new { [] }это не работает. Подробности смотрите в моем ответе . Это также уже решение, предложенное в другом ответе.
Эндрю Маршалл