Eki Eqbal
Eki Eqbal

Reputation: 6017

Using define_method and meta-programming to define instance methods dynamically in Ruby?

I have some code something like :

class Country
  attr_reader :name

  def initialize
    @name     = "MyName".freeze
  end

  def government
    @government ||= Government.new(self)
  end

  def symbols
    @symbols ||= Symbols.new(self)
  end

  def economy
    @economy ||= Economy.new(self)
  end

  def education
    @education ||= Education.new(self)
  end

  def healthcare
    @healthcare ||= Healthcare.new(self)
  end

  def holidays
    @holidays ||= Holidays.new(self)
  end

  def religion
    @religion ||= Religion.new(self)
  end

end

How can I create the methods dynamically? I tried :

class Country
  attr_reader :name

  COMPONENETS = %w(government symbols economy education healthcare holidays religion)


  COMPONENETS.each do |m|
    define_method(m) do |argument|
      instance_variable_set("@#{m}",Object.const_get(m.capitalize).new(self))
    end
  end

  def initialize
    @name     = "MyName".freeze
  end

end 

If I try:

puts Country.new.education.inspect

I get the following error:

country.rb:16:in `block (2 levels) in <class:Country>': wrong number of arguments (0 for 1) (ArgumentError)
    from country.rb:27:in `<main>'

What am I missing here?

Upvotes: 0

Views: 1510

Answers (3)

J&#246;rg W Mittag
J&#246;rg W Mittag

Reputation: 369428

In your original code, you defined all the methods to take no arguments:

def education
#            ^^^
  @education ||= Education.new(self)
end

In the metaprogrammed code, you define all the methods to take a single argument called argument:

define_method(m) do |argument|
#                   ^^^^^^^^^^
  instance_variable_set("@#{m}", Object.const_get(m.capitalize).new(self))
end

However, you call it with zero arguments:

puts Country.new.education.inspect
#                        ^^^

Obviously, your methods are meant to be lazy getters, so they should take no arguments:

define_method(m) do
  instance_variable_set("@#{m}", Object.const_get(m.capitalize).new(self))
end

Note that there are other problems with your code. In your original code, you use a conditional assignment to only perform the assignment if the instance variable is undefined, nil or false, whereas in the metaprogrammed code, you are always unconditionally setting it. It should be something more like this:

define_method(m) do
  if instance_variable_defined?(:"@#{m}")
    instance_variable_get(:"@#{m}")
  else
    instance_variable_set(:"@#{m}", const_get(m.capitalize).new(self))
  end
end

Note: I also removed the Object. from the call to const_get to look up the constant using the normal constant lookup rules (i.e. first lexically outwards then upwards in the inheritance hierarchy), since this corresponds to how you look up the constants in the original code snippet.

This is not fully equivalent to your code, since it sets the instance variable only when it is undefined and not also when it is false or nil, but I guess that is closer to your intentions anyway.

I would encapsulate this code to make its intentions clearer:

class Module
  def lazy_attr_reader(name, default=(no_default = true), &block)
    define_method(name) do
      if instance_variable_defined?(:"@#{name}")
        instance_variable_get(:"@#{name}")
      else
        instance_variable_set(:"@#{name}",
          if no_default then block.(name) else default end)
      end
    end
  end
end

class Country
  attr_reader :name

  COMPONENTS = %w(government symbols economy education healthcare holidays religion)

  COMPONENTS.each do |m|
    lazy_attr_reader(m) do |name|
      const_get(name.capitalize).new(self))
    end
  end

  def initialize
    @name = 'MyName'.freeze
  end
end

That way, someone reading your Country class won't go "Huh, so there is this loop which defines methods which sometimes get and sometimes set instance variables", but instead think "Ah, this is a loop which creates lazy getters!"

Upvotes: 3

Jean Bob
Jean Bob

Reputation: 565

You can simply use eval :

class Country
  attr_reader :name

  COMPONENETS = %w(government symbols economy education healthcare holidays religion)

  COMPONENETS.each do |m|
   eval <<-DEFINE_METHOD
   def #{m}
     @#{m} ||= #{m.capitalize}.new(self)
   end
DEFINE_METHOD
  end

  def initialize
    @name = "MyName".freeze
  end
end

Upvotes: 2

nsave
nsave

Reputation: 984

I guess you don't need the argument:

class Country
  attr_reader :name

  COMPONENETS = %w(government symbols economy education healthcare holidays religion)


  COMPONENETS.each do |m|
    define_method(m) do
      instance_variable_set("@#{m}",Object.const_get(m.capitalize).new(self))
    end
  end

  def initialize
    @name     = "MyName".freeze
  end

end 

Upvotes: 1

Related Questions