Объект Ruby Hash - это ассоциативная структура данных, используемая для хранения пар "ключ-значение". Во многих языках есть объекты, которые служат схожей цели: в Python есть словари, в JavaScript есть карты, в Java есть HashMaps и так далее. Объект Ruby Hash довольно прост в использовании.

my_hash = {}
my_hash[0] = 'Hello!'
puts my_hash[0]
#=> Hello!

Это прекрасно работает. Мы также можем изменить значение, связанное с ключом, который уже существует в хэше.

my_hash[0] << "I've been mutated!"
puts my_hash[0]
#=> Hello! I've been mutated!

Оператор [] - это просто синтаксический сахар для метода #[], определенного в классе Hash. Вот описание из Ruby Docs:

hsh [ключ] → значение

«Извлекает значение объекта, соответствующего ключевому объекту . Если не найден, возвращает значение по умолчанию. »

Последнее предложение в этом описании заслуживает более подробного пояснения.

my_hash = {}
my_hash[:exists] = 0
my_hash[:exists] += 1
puts my_hash[:exists]
#=> 1
my_hash[:does_not_exist] += 1
#=> NoMethodError (undefined method `+' for nil:NilClass)

У хэшей есть значение по умолчанию, которое является значением, возвращаемым при доступе к ключам, которых нет в хэше. В приведенном выше коде мы пытаемся получить доступ к ключу :does_not_exist, а затем вызвать метод #+ для значения, связанного с этим ключом. Сообщение об ошибке сообщает нам, что не существует #+ метода, определенного для значения, которое возвращается при доступе к этому ключу. Это связано с тем, что хэши, инициализированные с использованием кода hash_name = {}, будут иметь значение по умолчанию, равное nil.

p my_hash[:does_not_exist]
#=> nil

Мы можем сами установить значение по умолчанию, используя метод Hash::new:

my_hash = Hash.new(0)
my_hash[:does_not_exist] += 1
puts my_hash[:does_not_exist]
#=> 1

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

my_hash = Hash.new([])
my_hash[:does_not_exist] << 'Uh oh.'
p my_hash
#=> {}
p my_hash[:does_not_exist]
#=> ["Uh oh."]

Подождите минуту. Метод p использует метод #inspect для печати удобочитаемого представления переданного объекта. Так что вы, возможно, ожидали увидеть {:does_not_exist=>["Uh oh."]}, но вместо этого похоже, что хеш все еще пуст. Но если мы получим значение, связанное с символом :does_not_exist (который, как следует из его названия, не существует), мы получим ожидаемое значение.

p my_hash[:does_not_exist_either]
#=> ["Uh oh."]

Хорошо, это странно. Похоже, в хеше есть пары "ключ-значение", которые мы даже не указали.

На самом деле этому есть разумное объяснение, но оно требует внимательного отношения к описанию метода Hash::new в Ruby Docs.

new → new_hash

новый (объект) → новый_хэш

Возвращает новый пустой хеш. Если к этому хешу впоследствии обращается ключ, который не соответствует хеш-записи, возвращаемое значение зависит от стиля new, используемого для создания хеша. В первой форме доступ возвращает nil. Если указан obj , этот единственный объект будет использоваться для всех значений по умолчанию .

Последнее предложение является ключевым. Когда мы инициализируем хэш с помощью Hash.new([]), мы передаем объект массива. Этот одиночный объект будет использоваться как значение по умолчанию для хэша. Мы можем доказать это, напечатав несколько идентификаторов объектов.

my_hash = Hash.new([])
my_hash[0] << 1
puts my_hash[0].object_id
#=> 180
puts my_hash[:does_not_exist].object_id
#=> 180

Когда мы используем оператор << во второй строке этого кода, мы вообще не изменяем хэш. Фактически, мы изменяем значение по умолчанию, которое возвращает метод Hash#[], когда мы пытаемся получить доступ к несуществующему ключу. Внимательно подумайте, что происходит в этом коде:

Строка 1: мы вызываем метод Hash::new, передавая в качестве аргумента пустой объект массива. Это возвращает новый хэш-объект, который будет использовать пустой объект массива в качестве значения по умолчанию.

Строка 2: мы используем оператор [] в хэше, который является синтаксическим сахаром для метода Hash#[]. Мы передаем целое число 0 в качестве аргумента для этого метода. Поскольку в нашем хэше нет ключа со значением 0, метод возвращает значение по умолчанию (наш единственный пустой объект массива). Затем мы используем оператор << для вызова метода Array#<< для изменения этого пустого объекта массива, не хеша! Мы передаем целое число со значением 1 в метод #<<, который добавляет это целое число в конец нашего ранее пустого массива. Ключевым моментом здесь является то, что наш хеш остается неизменным этим кодом.

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

my_array = []
my_hash = Hash.new(my_array)
my_hash[0] << 1
p my_hash
#=> {}
p my_array
#=> [1]
p my_array.object_id
#=> 200
p my_hash[:does_not_exist].object_id
#=> 200

Этот код в основном такой же, как и предыдущий фрагмент кода, за исключением того, что он должен прояснить, что мутируется наше значение по умолчанию, а не хеш.

То, что мы здесь обнаружили, характерно не только для массивов как хеш-значений. Эта же концепция применима ко всем изменяемым объектам.

my_string = ''
my_hash = Hash.new(my_string)
my_hash[0] << 'nope.'
p my_hash
#=> {}
p my_string
#=> "nope."

Итак, есть ли другой способ заставить эту работу работать? Можем ли мы использовать изменяемый объект в качестве значения по умолчанию и заставить его работать так, как мы изначально ожидали? Чтобы ответить на этот вопрос, мы должны еще раз взглянуть на Документы Ruby для метода Hash::new.

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

Хорошо, мы можем передать блок в метод Hash::new. Это означает, что мы можем изменить наш код, чтобы он выглядел так:

my_hash = Hash.new { |hash, key| hash[key] = [] }
my_hash[0] << 'This works!'
my_hash[1] << 'This works too!'
p my_hash
#=> {0=>["This works!"], 1=>["This works too!"]}
p my_hash[0].object_id
#=> 220
p my_hash[1].object_id
#=> 240

Так что же здесь происходит? В строке 1 мы снова вызываем метод Hash::new. Но на этот раз мы передаем блок вместо объекта массива. Как указано в документации, когда мы пытаемся получить доступ к несуществующему ключу, будет вызываться этот блок. Хэш-объект и ключ, к которому мы пытаемся получить доступ, будут переданы в блок в качестве аргументов. Внутри блока мы свяжем новый уникальный объект массива с объектом, присвоенным key. Возвращаемое значение оператора Hash#[]= - это значение, используемое для присваивания, поэтому наш блок также вернет то же значение. Это выполняет обязательство по возврату значения по умолчанию из блока, как указано в описании метода Hash::new.

Возможно, есть более простое решение. Нам не нужно передавать блок в метод Hash::new, если мы просто не пытаемся изменить значение по умолчанию.

my_hash = Hash.new([])
my_hash[:does_not_exist] += ['Uh oh.']
p my_hash
#=> {:does_not_exist=>["Uh oh."]

Обратите особое внимание на операторы, используемые в строке 2. Вместо изменения массива с помощью оператора << мы используем оператор += для создания строки new, а затем назначаем ее ключу :does_not_exist. Наше значение по умолчанию остается в покое, а наш хеш правильно изменен.

Я надеюсь, что вы нашли это полезным или, по крайней мере, в некоторой степени интересным. Я столкнулся с этой проблемой и был очень смущен тем, что происходит. Выяснение того, почему код не выполняет то, что я ожидал, было отличной возможностью для обучения, а рассуждения на его основе были отличной возможностью подробно описать, что именно делает написанный мной код. Я хотел бы поблагодарить Эндрю Маршалла за очень полезное объяснение этой темы, опубликованное на Stack Overflow несколько лет назад.