How to append to Array that is the value of a Hash key?

Hi Crystal Pros–

I am pretty solid with Ruby, but this is my first Crystal attempt. Things were going pretty well until I hit this type system snafu that I can’t unravel.

Here’s a link to my ReplIt:
https://repl.it/@smprather/VigilantNoxiousAdministrators

Thanks!
–Myles

db = Hash(String, String | Array(Float64) | Array(Array(Float64)) | Float64 | Int32).new
db["frequencies"] = [] of Float64
db["frequencies"] << 1.0

 3 | db["frequencies"] << 1.0
                       ^-
Error: no overload matches 'Array(Array(Float64))#<<' with type Float64

Overloads are:
 - Array(T)#<<(value : T)
exit status 1

Because the values of that hash can be any of those types, you can only call methods on the values that all of those types can handle. Duck typing at compile time can be a little weird to get used to. :-)

It’s possible that you may want something other than a hash to store your values. Maybe you could use a NamedTuple, which is different from a hash in that the names of each keys the types of their values are known at compile time and are completely separate from one another. For example:

# Hash
{
  "frequencies" => [] of Float64,
  "names" => [] of String,
  "count" => 0,
}

# NamedTuple
{
  frequencies: [] of Float64,
  names: [] of String,
  count: 0,
}

In the above, the type of all of the hash’s values are Array(Float64) | Array(String) | Int32, but for the NamedTuple the type of :frequencies is Array(Float64), without any of the other types coming into play. Its fully qualified type is actually NamedTuple(frequencies: Array(Float64), names: Array(String), count: Int32).

The distinction is that a hash is an abstract data type that grows as more keys are added to it but a NamedTuple is not intended to be abstract. A given NamedTuple has a very specific use case and it does not grow — you can create new ones from others but you can’t add keys to an existing one. This constraint is actually pretty cool because it means, when accessing a key, the exact offset in memory is known at compile time. It basically becomes an ad hoc object. In fact, I often use them as stepping stones toward building a first-class object.

1 Like

Great answer! I think it’s starting to make sense now. Just goes to show how fast one can get dependent on much work dynamic languages are doing for us behind the scenes (and the additional runtime error vectors because of it). I think NamedTuple will work for me. I just needed to make sure that it worked well with .to_yaml, which it does (after I dl’d and compiled libyaml).

require "yaml"
db = {
  frequencies: [] of Float64,
  parameters: [] of Array(Float64),
}
db["frequencies"] << 1.0
db["parameters"] << [1.0, 2.0]
db["parameters"] << [3.0, 4.0]
puts db.to_yaml

---
frequencies:
- 1.0
parameters:
- - 1.0
  - 2.0
- - 3.0
  - 4.0
1 Like

I found that a NamedTuple with scalar values are totally immutable. If a value points to an Array, then it can be appended to, or course, but that’s different. So I went back to the drawing-board and found a better solution. Your answer did hint me in the right direction though. With the understanding that the compiler expects any key to respond to a method that must be found in the union of all types, I wondered if I could give it a helper. And it worked. It fails w/o either of the casts.

db = Hash(String, Array(Float64) | Float64 | Int32 | Nil).new

# Init to default values
db["frequencies"] = [] of Float64
db["counter"] = 0
db["nil_or_float_test"] = nil

# Processing code
db["frequencies"].as(Array(Float64)) << 1.0
db["counter"] = db["counter"].as(Int32) + 1
db["nil_or_float_test"] = 2.0
p db

{"frequencies" => [1.0], "counter" => 1, "nil_or_float_test" => 2.0}

Are your keys known at compile time? What I mean is, do you know all possible names that go as hash keys, and their types? Maybe with default values (empty Arrays, zero, null values)? If so, I think you should define a class with getters or properties. It’ll be more efficient too.

In Crystal we don’t use Hash as often as in Ruby. Named tuple is a quick replacement but I never recommend it because it’s original use case is representing named arguments.

Yes, all keys and types are known at compile time. I’m an EE that does a lot of data processing (simulation results, etc). I typically write a lot of scripts that build up Hash-based databases and then dump them to yaml files for down-the-line processing. I ran into some giant files that need parsing/processing and choked my Ruby (even @ v2.6), so I’m porting to Crystal.

How about this?

class DB
  getter grid : Array(Array(Float64)) = [] of Array(Float64)
  getter a_static_string : String
  property nil_or_float_test : Nil | Float64 = nil

  def initialize(@a_static_string)
  end

  def to_yaml
    {
      grid: @grid,
      a_static_string: @a_static_string,
      nil_or_float_test: @nil_or_float_test,
    }.to_yaml
  end
end

db = DB.new("static_string")
db.grid << [1.0,2.0]
p db
db.nil_or_float_test = 1.0
p db
puts db.to_yaml

#<DB:0x2b7118bdfea0 @grid=[[1.0, 2.0]], @a_static_string="static_string", @nil_or_float_test=nil>
#<DB:0x2b7118bdfea0 @grid=[[1.0, 2.0]], @a_static_string="static_string", @nil_or_float_test=1.0>
---
grid:
- - 1.0
  - 2.0
a_static_string: static_string
nil_or_float_test: 1.0

1 Like

Nice!

You can also reduce it a bit more with YAML::Serializable, and optionally removing some type annotations from the properties:

require "yaml"

class DB
  include YAML::Serializable

  getter grid = [] of Array(Float64)
  getter a_static_string : String
  property nil_or_float_test : Float64?

  def initialize(@a_static_string)
  end
end

db = DB.new("static_string")
db.grid << [1.0, 2.0]
p db
db.nil_or_float_test = 1.0
p db
puts db.to_yaml
1 Like

Whoa, this got fixed! There was a bug at one point with generics (like Array(Float64) here) used in a .as call to downcast from a union type, so we had to do this a lot:

value.as(Array).map(&.as(Float64))

I didn’t realize that value.as(Array(Float64)) was working now! :100: