rubydynamicdslinstance-eval

Dynamically added accessor assignment doesn't work when invoking block via instance_eval in Ruby


I have a class to which I add attribute accessors dynamically at runtime. This class forms part a DSL, whereby blocks get passed to configuration methods and invoked using instance_eval. This makes it possible in the DSL to remove references to 'self' when referencing methods of the class.

However, I've discovered that I can reference the attributes to retrieve their values, but am unable to assign them, unless explicity referencing self, as the following code sample illustrates.

class Bar

  def add_dynamic_attribute_to_class(name)
    Bar.add_dynamic_attribute(name)
  end

  def invoke_block(&block)
    instance_eval &block
  end

  def self.add_dynamic_attribute(name)
    attr_accessor name
  end

end

b = Bar.new

b.add_dynamic_attribute_to_class 'dyn_attr'

b.dyn_attr = 'Hello World!'

# dyn_attr behaves like a local variable in this case
b.invoke_block do
  dyn_attr = 'Goodbye!'
end

# unchanged!
puts "#{b.dyn_attr} but should be 'Goodbye!'"

# works if explicitly reference self
b.invoke_block do
  self.dyn_attr = 'Goodbye!'
end

# changed...
puts "#{b.dyn_attr} = 'Goodbye!"

# using send works
b.invoke_block do
  send 'dyn_attr=', 'Hello Again'
end

# changed...
puts "#{b.dyn_attr} = 'Hello Again!"

# explain this... local variable or instance method?
b.invoke_block do

  puts "Retrieving... '#{dyn_attr}'"

  # doesn't fail... but no effect
  dyn_attr = 'Cheers'

end

# unchanged
puts "#{b.dyn_attr} should be 'Cheers'"

Can anyone explain why this isn't behaving as expected?


Solution

  • The issue arrises with the way that Ruby deals with instance and local variables. What is happening is that you are setting a local variable in your instance_eval block, rather than using the ruby accessor.

    This might help explain it:

    class Foo
      attr_accessor :bar
    
      def input_local
        bar = "local"
        [bar, self.bar, @bar, bar()]
      end
    
      def input_instance
        self.bar = "instance"
        [bar, self.bar, @bar, bar()]
      end
    
      def input_both
        bar = "local"
        self.bar = "instance"
        [bar, self.bar, @bar, bar()]
      end
    end
    
    foo = Foo.new
    foo.input_local #["local", nil, nil, nil]
    foo.input_instance #["instance", "instance", "instance", "instance"]
    foo.input_both #["local", "instance", "instance", "instance"]
    

    The way bocks work is that they distinguish between local and instance variables, but if a local variable is not defined when it's reader is called, the class defaults to the instance variable (as is the case with the call to input_instance in my example).

    There are three ways to get the behavior you want.

    Use instance variables:

        class Foo
          attr_accessor :bar
    
          def evaluate(&block)
            instance_eval &block
          end
        end
    
        foo = Foo.new
        foo.evaluate do
          @bar = "instance"
        end
        foo.bar #"instance"
    

    Use a self variable:

        class Foo
          attr_accessor :bar
    
          def evaluate(&block)
            block.call(self)
          end
        end
    
        foo = Foo.new
        foo.evaluate do |c|
          c.bar = "instance"
        end
        foo.bar #"instance"
    

    Use setter functions:

        class Foo
          attr_reader :bar
          def set_bar value
            @bar = value
          end
    
          def evaluate(&block)
            instance_eval &block
          end
        end
    
        foo = Foo.new
        foo.evaluate do
          set_bar "instance"
        end
        foo.bar #"instance"
    

    All of these examples set foo.bar to "instance".