Ответ 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-значение в =
не может быть динамическим.