4

TL;DR

I solved the problem through trial and error, but there's clearly a gap in my understanding of how the splat operator and pp method are consistently giving me a different object than the one I think I have. I'd like to understand that gap, and identify a better way to merge an array of hashes. I'd also like to be able to debug this sort of thing more effectively in the future.

Code samples and debugging steps are first. My semi-satisfactory solution and more-detailed question are at the bottom.

The Code

I'm using MRI Ruby 2.6.2. Given class Foo, I expect Foo#windows to return a merged hash. Here is a minimal example of the class:

class Foo
  attr_reader :windows

  def initialize
    @windows = {}
  end

  def pry
    { pry: "stuff pry\r" }
  end 

  def irb
    { irb: "stuff irb\r" }
  end 

  def windows= *hashes
    @windows.merge! *hashes
  end
end

foo = Foo.new
foo.windows = foo.pry, foo.irb

The Problem (with Debugging)

However, trying to assign to foo.windows (or even trying to be less ambiguous with to help out the parser with foo.windows= foo.pry, foo.irb) I get an exception from the REPL:

TypeError: no implicit conversion of Array into Hash

However, if I modify the instance with a singleton method to capture the value of the *hashes argument, I see an array of hashes that I can merge just fine. Consider the following:

def foo.windows= *hashes
  pp *hashes
end
foo.windows = foo.pry, foo.irb
#=> [{:pry=>"stuff pry\r"}, {:irb=>"stuff irb\r"}]

{}.merge *[{:pry=>"stuff pry\r"}, {:irb=>"stuff irb\r"}]
#=> {:pry=>"stuff pry\r", :irb=>"stuff irb\r"}

Grabbing the output from #pp gives me something that works as expected. And yet, when I dig a little deeper, it turns out that something is layering on an extra nesting of the Hash:

def foo.windows= *hashes
  pp *hashes.inspect
end
foo.windows = foo.pry, foo.irb
"[[{:pry=>\"stuff pry\\r\"}, {:irb=>\"stuff irb\\r\"}]]"

Even though the return value doesn't show it, there's an extra set of square brackets causing the array to be nested. I don't really understand where they're coming from.

What Works

So, for whatever reason, I have to splat the array, flatten it, and then I'm able to merge:

def foo.windows= *hashes
  @windows.merge! *hashes.flatten
end

# The method still returns unexpected results here, but...
foo.windows = foo.pry, foo.irb
#=> [{:pry=>"stuff pry\r"}, {:irb=>"stuff irb\r"}]

# the value stored in the instance variable is finally correct!
foo.windows
#=> {:pry=>"stuff pry\r", :irb=>"stuff irb\r"}

But Why?

So yes, I've managed to solve the problem. However, my question is really about why merging the hashes doesn't work as expected, and where the extra layer of nesting is coming from. I'm not expecting an Array of Array of Hashes, but rather an Array of Hashes. Is there a gap in my understanding, or is this a weird edge case of some sort?

More importantly, why is this so difficult to debug? I'd expect #pp or #inspect to show me the object I really have ahold of, rather than show me an Array of Hashes as the return value when I clearly have an Array of Arrays containing the Hash.

Todd A. Jacobs
  • 81,402
  • 15
  • 141
  • 199

2 Answers2

3

First off, *hashes can mean two very different things:

  • def windows=(*hashes) means "Store all arguments passed to this method into the array hashes in order of appearance."
  • whereas @windows.merge! *hashes use the items of hashes as the individual arguments to the method call merge!.

However, when you have an assignment method, listing several values automatically creates an array:

You can implicitly create an array by listing multiple values when assigning:

a = 1, 2, 3
p a # prints [1, 2, 3]

Thus foo.windows(foo.pry, foo.irb) with

def windows(*hashes)
    pp hashes
    @windows.merge! *hashes
end

would print [{:pry=>"stuff pry\r"}, {:irb=>"stuff irb\r"}] as expected. But since you have an assignment method, you should just remove the asterisk from your definition.

def windows=(hashes)
  @windows.merge!(*hashes)
end
idmean
  • 14,540
  • 9
  • 54
  • 83
2

What you're missing there is that the Ruby parser doesn't allow setter methods with more than one parameter.

When you pass multiple parameters to a setter, they're automatically put together on an array (because a = 1, 2 means the same thing as a = [1, 2]):

def foo.a=(values)
  pp values
end

foo.a = 1, 2 # [1, 2]

If you define a splat argument, however, since that array is considered to be a single argument, this happens:

def foo.a=(*values)
  pp values
end

foo.a = 1, 2 # [[1, 2]]
GBrandt
  • 685
  • 4
  • 11
  • 1
    Both this answer and [the one from @idmean](https://stackoverflow.com/a/55170156/1301972) helped me, but this one drew my attention more clearly to the fact that I was (ab)using the setter method syntax. Once I realized that, other self-inflicted problems went away too. – Todd A. Jacobs Mar 16 '19 at 21:07