How to append to an Array that is a default Value of a Hash?

I’m new to Crystal and using v0.34.

I expected that if an array is my default, that I could append to it as in test2. However the array doesn’t ‘stick’? Could someone explain the correct way to do this / why I’m seeing the results I see?

test = Hash(String, Array(Int32)).new(default_value: [] of Int32)
test["cats"] = test["cats"] << 3
test["cats"] << 4
p test 

test2 = Hash(String, Array(Int32)).new(default_value: [] of Int32)
test2["dogs"] << 1
test2["dogs"] << 3
p test2

Output in crystal play:

{}Hash(String, Array(Int32))
[3]Array(Int32)
[3, 4]Array(Int32)
{"cats" => [3, 4]}Hash(String, Array(Int32))

{}Hash(String, Array(Int32))
[1]Array(Int32)
[1, 3]Array(Int32)
{}Hash(String, Array(Int32))
1 Like

I think it’s because you have not initialized “dogs” array.

test2 = Hash(String, Array(Int32)).new(default_value: [] of Int32)
test2["dogs"] = [] of Int32 # initialized
test2["dogs"] << 1
test2["dogs"] << 3
p test2

# {"dogs" => [1, 3]}

https://play.crystal-lang.org/#/r/948v

3 Likes

@krthr beat me to it, I was just working on the same thing.

https://play.crystal-lang.org/#/r/948z

Welcome to Crystal, @ducktape! Yeah, so the default_value doesn’t automatically instantiate that array unless asked to, and because the Hash’s V is the same type, it’s not complaining about the << method.

The api docs under Hash(K, V).new say:

Creates a new empty Hash where the default_value is returned if a key is missing.

Which is to say, the result of test2["dog"] << 4 is basically [] << 4 instead of the intended test2["dog"] << 4 with the initialized array.
It is rather strange behavior though, and I’m not sure if that’s conveyed well in the docs.

2 Likes

hahaha sorry. Nice explanation btw…

Welcome @ducktape! Here is the url of Hash’s API: https://crystal-lang.org/api/0.34.0/Hash.html#new(default_value:V,initial_capacity=nil)-class-method

2 Likes

Beware of the defualt_value semantics. You will end up with a shared array.

Hash.new can a block to lazy initialize entries. That might be more helpful.

Also, relevant old issue: https://github.com/crystal-lang/crystal/issues/4376

2 Likes

Here’s something fun (a demonstration of @bcardiff’s point above) :

test2 = Hash(String, Array(Int32)).new(default_value: [] of Int32)
test2["dogs"] << 1
test2["dogs"] << 3
p test2["dogs"] # => [1, 3]
p test2["cats"] # => [1, 3]
p test2 # => {}

playground link

The thing is, you are actually appending to an array that is kept. However, you’re appending to the “default value” array that is returned when the key is not in the hash, not to the array “at” a particular key. If you want a new array that isn’t appended to, you can use the block constructor:

test2 = Hash(String, Array(Int32)).new() { [] of Int32 }
# in the next two lines, Hash#[] returns a new array, which is appended to and then never assigned to anything
test2["dogs"] << 1
test2["dogs"] << 3
p test2["dogs"] # => []
p test2["cats"] # => []
p test2 # => {}

playground link

However, that doesn’t really get you what you want. What you’re trying to do with default_value isn’t really what those constructors are meant for. It’s more like if you want to count the number of occurrences of a particular word in a manuscript; if you use default_value: 0, then you can just query the hash about any string at all, and it’ll tell you 0 occurrences, unless you’ve actually given it an entry.

2 Likes

Thanks for the replies! I only saw the constructor docs at the top of the api page, I didn’t know there was more at the bottom!

I think I get it. I ended up just testing if a key was there and if not initializing an array for it, but I think that https://crystal-lang.org/api/0.34.0/Hash.html#new(block:Hash(K,V),K->V?=nil,*,initial_capacity=nil)-class-method would actually get me what I originally wanted.

1 Like

Actually, as @RespiteSage points out, the block method also doesn’t do what I want in this case.

While I’m here, are there any more complete docs for the entry api / is it like the rust hashmap entry api? Because that could also do what I want.

1 Like

You can do something like:

test2 = Hash(String, Array(Int32)).new { |h, k| h[k] = [] of Int32 }

test2["dogs"] << 1
test2["dogs"] << 3
p test2["dogs"] # => [1, 3]
p test2["cats"] # => []
p test2 # => {"dogs" => [1, 3], "cats" => []}

https://play.crystal-lang.org/#/r/949j

On missing value, it would invoke the block which would set that key to a new empty array.

4 Likes

@Blacksmoke16 that works! So to sum up, the block method does indeed work.

1 Like

Well to be clear, this specific block version works because it provides a reference the the hash itself, and the missing key. So you’re updating a reference to the actual hash, not just returning some arbitrary value.

2 Likes

small snippet, to break down these various approaches:

https://play.crystal-lang.org/#/r/949t

# this does NOT work because in that block, [] is just an array... somewhere
test0 = Hash(String, Array(Int32)).new() { [] of Int32 }
test0["cats"] << 55 # this becomes [] << 55
p test0
 
# proof:
 
aux = test0["cats"] << 55
p aux
 
# the block is passed h (in our case, Hash(String, Array(Int32))) and k, (String)
# this works because h is our hash, so its updating itself here, whereas before, it's
# just returning a value and leaving the hash unchanged
test3 = Hash(String, Array(Int32)).new() { |h, k| h[k] = [] of Int32 }
test3["dogs"] << 55
p test3

output:

{}
[55]
{"dogs" => [55]}
1 Like

Good summary @wrq, thank you all for the speedy replies!

2 Likes