Ruby.Metaprogramming. class_eval

2020-02-11 16:27发布

问题:

There seem to be a mistake in my code. However I just can't find it out.

class Class
def attr_accessor_with_history(attr_name)
  attr_name = attr_name.to_s

  attr_reader attr_name
  attr_writer attr_name

  attr_reader attr_name + "_history"
  class_eval %Q{
   @#{attr_name}_history=[1,2,3]
  }

end
end

class Foo
 attr_accessor_with_history :bar
end

f = Foo.new
f.bar = 1
f.bar = 2
puts f.bar_history.to_s

I would expect it to return an array [1,2,3]. However, it doesn't return anything.

回答1:

You will find a solution for your problem in Sergios answer. Here an explanation, what's going wrong in your code.

With

class_eval %Q{
 @#{attr_name}_history=[1,2,3]
}

you execute

 @bar_history = [1,2,3]

You execute this on class level, not in object level. The variable @bar_history is not available in a Foo-object, but in the Foo-class.

With

puts f.bar_history.to_s

you access the -never on object level defined- attribute @bar_history.

When you define a reader on class level, you have access to your variable:

class << Foo 
  attr_reader :bar_history
end
p Foo.bar_history  #-> [1, 2, 3]


回答2:

You shouldn't be opening Class to add new methods. That's what modules are for.

module History
  def attr_accessor_with_history(attr_name)
    attr_name = attr_name.to_s

    attr_accessor attr_name

    class_eval %Q{
      def #{attr_name}_history
        [1, 2, 3]
      end
    }

  end
end

class Foo
  extend History
  attr_accessor_with_history :bar
end

f = Foo.new
f.bar = 1
f.bar = 2
puts f.bar_history.inspect
# [1, 2, 3]

And here's the code you probably meant to write (judging from the names).

module History
  def attr_accessor_with_history(attr_name)
    attr_name = attr_name.to_s

    class_eval %Q{
      def #{attr_name}
        @#{attr_name}
      end

      def #{attr_name}= val
        @#{attr_name}_history ||= []
        @#{attr_name}_history << #{attr_name}

        @#{attr_name} = val
      end

      def #{attr_name}_history
        @#{attr_name}_history
      end
    }

  end
end

class Foo
  extend History
  attr_accessor_with_history :bar
end

f = Foo.new
f.bar = 1
f.bar = 2
puts f.bar_history.inspect
# [nil, 1]


回答3:

Solution:

class Class
  def attr_accessor_with_history(attr_name)
    ivar         = "@#{attr_name}"
    history_meth = "#{attr_name}_history"
    history_ivar = "@#{history_meth}"

    define_method(attr_name) { instance_variable_get ivar }

    define_method "#{attr_name}=" do |value|
      instance_variable_set ivar, value
      instance_variable_set history_ivar, send(history_meth) << value
    end

    define_method history_meth do
      value = instance_variable_get(history_ivar) || []
      value.dup
    end
  end
end

Tests:

describe 'Class#attr_accessor_with_history' do
  let(:klass)     { Class.new { attr_accessor_with_history :bar } }
  let(:instance)  { instance = klass.new }

  it 'acs as attr_accessor' do
    instance.bar.should be_nil
    instance.bar = 1
    instance.bar.should == 1
    instance.bar = 2
    instance.bar.should == 2
  end

  it 'remembers history of setting' do
    instance.bar_history.should == []
    instance.bar = 1
    instance.bar_history.should == [1]
    instance.bar = 2
    instance.bar_history.should == [1, 2]
  end

  it 'is not affected by mutating the history array' do
    instance.bar_history << 1
    instance.bar_history.should == []
    instance.bar = 1
    instance.bar_history << 2
    instance.bar_history.should == [1]
  end
end


回答4:

@Sergio Tulentsev's answer works, but it promotes a problematic practice of using string eval which is in general fraught with security risks and other surprises when the inputs aren't what you expect. For example, what happens to Sergio's version if one calls (no don't try it):

attr_accessor_with_history %q{foo; end; system "rm -rf /"; def foo}

It is often possible to do ruby meta-programming more carefully without string eval. In this case, using simple interpolation and define_method of closures with instance_variable_[get|set], and send:

module History

  def attr_accessor_with_history(attr_name)
    getter_sym  = :"#{attr_name}"
    setter_sym  = :"#{attr_name}="
    history_sym = :"#{attr_name}_history"
    iv_sym      = :"@#{attr_name}"
    iv_hist     = :"@#{attr_name}_history"

    define_method getter_sym do
      instance_variable_get(iv_sym)
    end

    define_method setter_sym do |val|
      instance_variable_set( iv_hist, [] ) unless send(history_sym)
      send(history_sym).send( :'<<', send(getter_sym) )
      instance_variable_set( iv_sym, val @)
    end

    define_method history_sym do
      instance_variable_get(iv_hist)
    end

  end
end


回答5:

Here is what should be done. The attr_writer need be defined withing class_eval instead in Class.

class Class
  def attr_accessor_with_history(attr_name)
    attr_name = attr_name.to_s

    attr_reader attr_name
    #attr_writer attr_name  ## moved into class_eval

    attr_reader attr_name + "_history"

    class_eval %Q{
      def #{attr_name}=(value)
        @#{attr_name}_history=[1,2,3]
      end
    }

end
end