Search code examples
rubyclassmoduleenumerable

Why a block invoked by a Module can't modify objects from implementing classes in Ruby?


I have some data saved in deeply nested Hashes and Arrays and I have run into trouble with the text encoding of the data. I know for fact that the texts are encoded in "UTF-8", so I decided to go over each element and force the encoding.

So, I created a method called deep_each for the Enumerable module:

module Enumerable
  def deep_each(&block)
    self.each do |element|
      if element.is_a? Enumerable then
        element.deep_each(&block)
      else
        block[element]
      end
    end
  end
end

And expected to be able to fix the data using the following method call:

deephash.deep_each {|element| element.force_encoding("UTF-8") if element.class == String}

But the result was disappointing:

deephash.deep_each {|element| element.force_encoding("UTF-8") if element.class == String}

> RuntimeError: can't modify frozen String
> from (pry):16:in `force_encoding'

Then I moved the function down the hierarchy, to the "Array" and "Hash" classes:

class Hash
  def deep_each(&block)
    self.each do |element|
      if [Array, Hash].include? element.class then
        element.deep_each(&block)
      else
        block[element]
      end
    end
  end
end

class Array
  def deep_each(&block)
    self.each do |element|
      if [Array, Hash].include? element.class then
        element.deep_each(&block)
      else
        block[element]
      end
    end
  end
end

Surprisingly, the same call works now.

What constraint am I violating here, and how can I define a method for all Enumerables without defining it for every single one of them?


Solution

  • As far as I can tell, you should get the exact same error with both your Enumerable version and your Array/Hash monkey patch. I do. Are you sure you're using the same deephash in both cases?

    Normally when you loop each on a hash, you'd pass in both key and value to the block. You're passing a single value element to the block. This then is an Array with the key and value:

    irb> {a:1, b:2}.each {|el| puts el.inspect }
    [:a, 1]
    [:b, 2]
    

    Your deep_each checks if this is an Enumerable, and it is, so it calls deep_each on the list. Then, finally, you reach the leafs and call the block on the key and the value. The block checks if it's working with a String, and if so, forces encoding.

    If your hash key is a string, you will try to mutate it. But hash keys are frozen, and so RuntimeError: can't modify frozen String is raised.

    irb> {a: {b: {c: "abc"}}}.deep_each { |el| el << "efg" if String === el}
    => {:a=>{:b=>{:c=>{:d=>"abcefg"}}}}
    irb> {a: {b: {"c" => "abc"}}}.deep_each { |el| el << "efg" if String === el}
    RuntimeError: can't modify frozen String