Comportamento estranho e inesperado (desaparecimento / alteração de valores) ao usar o valor padrão de Hash, por exemplo, Hash.new ([])

107

Considere este código:

h = Hash.new(0)  # New hash pairs will by default have 0 as values
h[1] += 1  #=> {1=>1}
h[2] += 2  #=> {2=>2}

Tudo bem, mas:

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`?

Neste ponto, espero que o hash seja:

{1=>[1], 2=>[2], 3=>[3]}

mas está longe disso. O que está acontecendo e como posso obter o comportamento que espero?

Valentin Vasilyev
fonte

Respostas:

164

Primeiro, observe que esse comportamento se aplica a qualquer valor padrão que sofra mutações subsequentes (por exemplo, hashes e strings), não apenas arrays.

TL; DR : use Hash.new { |h, k| h[k] = [] }se quiser a solução mais idiomática e não se importa por quê.


O que não funciona

Porque Hash.new([])não funciona

Vamos analisar mais detalhadamente por Hash.new([])que não funciona:

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  #=> {}

Podemos ver que nosso objeto padrão está sendo reutilizado e modificado (isso porque ele é passado como o único valor padrão, o hash não tem como obter um novo valor padrão novo), mas por que não há chaves ou valores na matriz, apesar de h[1]ainda nos dar um valor? Aqui está uma dica:

h[42]  #=> ["a", "b"]

A matriz retornada por cada []chamada é apenas o valor padrão, que estamos alterando todo esse tempo, então agora contém nossos novos valores. Já <<que não atribui ao hash (nunca pode haver atribuição em Ruby sem um =presente ), nunca colocamos nada em nosso hash real. Em vez disso, temos que usar <<=(que é para <<como +=é +):

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

É o mesmo que:

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

Porque Hash.new { [] }não funciona

Usar Hash.new { [] }resolve o problema de reutilizar e modificar o valor padrão original (como o bloco dado é chamado a cada vez, retornando uma nova matriz), mas não o problema de atribuição:

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

O que funciona

A forma de atribuição

Se nos lembrarmos de sempre usar <<=, então Hash.new { [] } é uma solução viável, mas é um pouco estranha e não idiomática (nunca vi ser <<=usado em estado selvagem). Também está sujeito a erros sutis se <<for usado inadvertidamente.

O caminho mutável

A documentação paraHash.new estados (ênfase minha):

Se um bloco for especificado, ele será chamado com o objeto hash e a chave e deve retornar o valor padrão. É responsabilidade do bloco armazenar o valor no hash, se necessário .

Portanto, devemos armazenar o valor padrão no hash de dentro do bloco se quisermos usar em <<vez de <<=:

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

Isso move efetivamente a atribuição de nossas chamadas individuais (que usariam <<=) para o bloco passado Hash.new, removendo o fardo do comportamento inesperado durante o uso <<.

Observe que existe uma diferença funcional entre este método e os outros: desta forma atribui o valor padrão na leitura (já que a atribuição sempre acontece dentro do bloco). Por exemplo:

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

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

O caminho imutável

Você pode estar se perguntando por Hash.new([])que não funciona enquanto Hash.new(0)funciona bem. A chave é que os Numéricos em Ruby são imutáveis, então, naturalmente, nunca acabamos por transformá-los no local. Se tratássemos nosso valor padrão como imutável, poderíamos usar Hash.new([])muito bem também:

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

No entanto, observe isso ([].freeze + [].freeze).frozen? == false. Portanto, se você quiser garantir que a imutabilidade seja preservada do começo ao fim, deve tomar cuidado para congelar novamente o novo objeto.


Conclusão

De todos os caminhos, pessoalmente prefiro “o caminho imutável” - a imutabilidade geralmente torna o raciocínio sobre as coisas muito mais simples. Afinal, é o único método que não tem possibilidade de comportamento inesperado oculto ou sutil. No entanto, a forma mais comum e idiomática é “a forma mutável”.

Por fim, esse comportamento dos valores padrão do Hash é observado no Ruby Koans .


Isso não é estritamente verdadeiro, métodos como instance_variable_setignorar isso, mas devem existir para metaprogramação, pois o valor l em =não pode ser dinâmico.

Andrew Marshall
fonte
1
É importante mencionar que usar "a forma mutável" também tem o efeito de fazer com que cada pesquisa de hash armazene um par de valores-chave (já que há uma atribuição acontecendo no bloco), o que pode nem sempre ser desejado.
johncip
@johncip Nem todas as pesquisas, apenas a primeira de cada chave. Mas entendo o que você quer dizer, acrescentarei isso à resposta mais tarde; obrigado!.
Andrew Marshall
Opa, sendo desleixado. Você está certo, é claro, é a primeira pesquisa de uma chave desconhecida. Eu quase sinto que { [] }com <<=o menor número de surpresas, não fosse pelo fato de que o esquecimento acidental do =pode levar a uma sessão de depuração muito confusa.
johncip
explicações muito claras sobre as diferenças ao inicializar o hash com valores padrão
cisolarix
23

Você está especificando que o valor padrão para o hash é uma referência a esse array específico (inicialmente vazio).

Eu acho que você quer:

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

Isso define o valor padrão de cada chave para uma nova matriz.

Matthew Flaschen
fonte
Como posso usar instâncias de array separadas para cada novo hash?
Valentin Vasilyev
5
Essa versão de bloco fornece novas Arrayinstâncias em cada invocação. A saber: h = Hash.new { |hash, key| hash[key] = []; puts hash[key].object_id }; h[1] # => 16348490; h[2] # => 16346570. Além disso: se você usar a versão de bloco que define o valor ( {|hash,key| hash[key] = []}) em vez daquela que simplesmente gera o valor ( { [] }), então você só precisa <<, não <<=ao adicionar elementos.
James A. Rosen
3

O operador +=quando aplicado a esses hashes funciona conforme o esperado.

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

Isso pode ser porque foo[bar]+=bazé um açúcar sintático, pois foo[bar]=foo[bar]+bazquando foo[bar]à direita de =é avaliado ele retorna o objeto de valor padrão e o +operador não o altera. A mão esquerda é o açúcar sintático para o []=método, que não altera o valor padrão .

Note que isto não se aplica para foo[bar]<<=bazcomo ele vai ser equivalente a foo[bar]=foo[bar]<<baze << vai mudar o valor padrão .

Além disso, não encontrei diferença entre Hash.new{[]}e Hash.new{|hash, key| hash[key]=[];}. Pelo menos em ruby ​​2.1.2.

Daniel Ribeiro Moreira
fonte
Bela explicação. Parece que no Ruby 2.1.1 Hash.new{[]}é o mesmo que Hash.new([])para mim com a falta do <<comportamento esperado (embora, é claro, Hash.new{|hash, key| hash[key]=[];}funcione). Coisas pequenas estranhas quebrando todas as coisas: /
butterywombat
1

Quando você escreve,

h = Hash.new([])

você passa a referência padrão de array para todos os elementos em hash. por causa disso, todos os elementos em hash referem-se ao mesmo array.

se você quiser que cada elemento em hash se refira a uma matriz separada, você deve usar

h = Hash.new{[]} 

para obter mais detalhes sobre como funciona em ruby, consulte: http://ruby-doc.org/core-2.2.0/Array.html#method-c-new

Ganesh Sagare
fonte
Isso é errado, Hash.new { [] }se não trabalhar. Veja minha resposta para detalhes. Também já é a solução proposta em outra resposta.
Andrew Marshall