How to use Ruby metaprogramming to reduce the number of methods

I have a bunch of methods that are iterative and I am sure that somehow I can use Ruby metaprogramming.

My class looks like this:

class SomePatterns

  def address_key
    "..."
  end

  def user_key
    "..."
  end

  def location_key
    "..."
  end

  def address_count
    redis.scard("#{address_key}")
  end

  def location_count
    redis.scard("#{location_key}")
  end

  def user_count
    redis.scard("#{user_key}")
  end

end

      

I thought I could only use one method:

def count(prefix)
  redis.scard("#{prefix}_key") # wrong, but something like this
end

      

The above is not correct, but I am saying that the methods *_count

will follow the pattern. I hope to learn how to use metaprogramming to avoid duplication.

How can i do this?

+3


source to share


8 answers


I would put all the "function prefixes" in an array. Once initialized, you can use :define_singleton_method

these prefixes to dynamically create an instance method for each of them:

class SomePatterns
  def initialize()
    prefixes = [:address, :location, :user]
    prefixes.each do |prefix|
      define_singleton_method("#{prefix}_count") { redis.scard("#{prefix}_key") }
    end
  end
end

      

EDIT:



:define_singleton_method

may actually be overkill. It will work for you, but it will define these functions for that particular instance (hence its named singleton). The difference is subtle but important. It would be better to use : class_eval in combination with : instead . define_method

class SomePatterns
    # ...
    class_eval do
      [:address, :location, :user].each do |prefix|
        define_method("#{prefix}_count") { redis.scard("#{prefix}_key") }
      end
    end
end

      

+4


source


As @ sagarpandya82 points out, you can use # method_missing . Let's say you want to shorten the following.

class Redis
  def scard(str)
    str.upcase
  end
end

class Patterns
  def address_key
    "address_key"
  end
  def address_count
    redis.send(:scard, "address_count->#{address_key}")
  end

  def user_key
    "user_key"
  end
  def user_count
    redis.send(:scard, "user_count->#{user_key}")
  end

  def modify(str)
    yield str
  end

  private

  def redis
    Redis.new
  end
end

      

which behaves like this:

pat = Patterns.new                      #=> #<Patterns:0x007fe12b9968d0> 
pat.address_key                         #=> "address_key" 
pat.address_count                       #=> "ADDRESS_COUNT->ADDRESS_KEY"
pat.user_key                            #=> "user_key" 
pat.user_count                          #=> "USER_COUNT->USER_KEY"
pat.modify("what ho!") { |s| s.upcase } #=> "WHAT HO!" 

      

Note that since the object was redis

not defined in the class, I assumed it was an instance of another class that I named redis

.



You can reduce the number of methods to one by changing the class Patterns

as follows.

class Patterns
  def method_missing(m, *args, &blk)
    case m
    when :address_key, :user_key     then m.to_s
    when :address_count, :user_count then redis.send(:scard, m.to_s)  
    when :modify                     then send(m, *args, blk)
    else super
    end
  end

  private

  def redis
    Redis.new
  end
end

pat = Patterns.new                      #=> #<Patterns:0x007fe12b9cc548>
pat.address_key                         #=> "address_key" 
pat.address_count                       #=> "ADDRESS_COUNT->ADDRESS_KEY" 
pat.user_key                            #=> "user_key" 
pat.user_count                          #=> "USER_COUNT=>USER_KEY" 
pat.modify("what ho!") { |s| s.upcase } #=> "WHAT HO!" 
pat.what_the_heck!                      #=> #NoMethodError:\
  # undefined method `what_the_heck!' for #<Patterns:0x007fe12b9cc548>

      

However, there are some disadvantages with this approach:

  • the code using is method_missing

    not as easy to understand as the usual way of writing each method separately.
  • the number of variables and the presence or absence of a block are not respected
  • Debugging can be painful, with exceptions commonplace.
+1


source


You can create a macro style method to pick things up. For example:

Create a new class Countable

:

class Countable
  def self.countable(key, value)
    define_method("#{key}_count") do
      redis.scard(value)
    end

    # You might not need these methods anymore? If so, this can be removed
    define_method("#{key}_key") do
      value
    end
  end
end

      

Inherit from Countable

and then just use a macro. This is just an example - you can also, for example, implement it as ActiveSupport Content or extend a module (as suggested in the comments below):

class SomePatterns < Countable
  countable :address, '...'
  countable :user, '...'
  countable :location, '...'
end

      

+1


source


The easiest way I could think of is to loop through Hash

with the required key-value pairs.

class SomePatterns
  PATTERNS = {
    address:  "...",
    user:     "...",
    location: "...",
  }

  PATTERNS.each do |key, val|
    define_method("#{key}_key") { val }
    define_method("#{key}_count") { redis.scard(val) }
  end
end

      

+1


source


You can call the so-called magic methods by interrupting the search method_missing

. Below is a basic example confirming how you might approach a solution:

class SomePatterns
  def address_key
    "...ak"
  end

  def user_key
    "...uk"
  end

  def location_key
    "...lk"
  end

  def method_missing(method_name, *args)
    if method_name.match?(/\w+_count\z/)
      m = method_name[/[\w]+(?=_count)/]
      send("#{m}_key")        #you probably need: redis.scard(send("#{m}_key"))
    else
      super
    end
  end
end

      

method_missing

checks if a method ending in _count

, if so, has been called _key

. If the corresponding method _key

does not exist, you will receive an error message stating this.

obj = SomePatterns.new
obj.address_count  #=> "...ak"
obj.user_count     #=> "...uk"
obj.location_count #=> "...lk"
obj.name_count
#test.rb:19:in `method_missing': undefined method `name_key' for #<SomePatterns:0x000000013b6ae0> (NoMethodError)
#        from test.rb:17:in `method_missing'
#        from test.rb:29:in `<main>'

      

Note that we are calling methods that are not actually defined anywhere. But we still return a value or error message according to the rules defined in SomePatterns#method_missing

.

For more information, check out Russ Olsen's Eloquent Ruby, from which this answer links in part. Please also note that it is worth understanding how it BasicObject#method_missing

works in general, and I am not at all sure if the above method is recommended in professional code (although I can see that @CarySwoveland has some idea about it).

+1


source


Since everyone else was so kind to share their answers, I thought I could contribute. My initial thought on this question was to use a call mapping pattern method_missing

and a few common methods. ( #key

and #count

in this case)

Then I expanded this concept to provide free initialization of the required prefixes and keys, and this is the end result:

#Thank you Cary Swoveland for the suggestion of stubbing Redis for testing
class Redis
  def sdcard(name)
    name.upcase
  end
end


class Patterns 
  attr_accessor :attributes
  def initialize(attributes)
    @attributes = attributes
  end 
  # generic method for retrieving a "key"
  def key(requested_key)
    @attributes[requested_key.to_sym] || @attributes[requested_key.to_s]
  end
  # generic method for counting based on a "key"
  def count(requested_key) 
    redis.sdcard(key(requested_key))
  end
  # dynamically handle method names that match the pattern 
  # XXX_count or XXX_key where XXX exists as a key in attributes Hash
  def method_missing(method_name,*args,&block)
    super unless m = method_name.to_s.match(/\A(?<key>\w+)_(?<meth>(count|key))\z/) and self.key(m[:key])
    public_send(m[:meth],m[:key])
  end  
  def respond_to_missing?(methond_name,include_private= false) 
    m = method_name.to_s.match(/\A(?<key>\w+)_(?<meth>(count|key))\z/) and self.key(m[:key]) || super
  end
  private 
    def redis
      Redis.new
    end
end 

      

This allows the following implementation to be implemented, which I think offers a very nice public interface to support the requested functionality

p = Patterns.new(user:'some_user_key', address: 'a_key', multi_word: 'mw_key')
p.key(:user)
#=> 'some_user_key'
p.user_key 
#=> 'some_user_key'
p.user_count
#=> 'SOME_USER_KEY'
p.respond_to?(:address_count) 
#=> true
p.multi_word_key
#=> 'mw_key'
p.attributes.merge!({dynamic: 'd_key'})
p.dynamic_count
#=> 'D_KEY'
p.unknown_key 
#=> NoMethodError: undefined method `unknown_key'

      

Obviously, you could pre-define which attributes and prevent this object from mutating.

+1


source


You can try something like:

def count(prefix)
  eval("redis.scard(#{prefix}_key)")
end

      

This will interpolate the prefix into the line of code to run. In not running error handling, which is likely to be safe to use the operator eval

.

Please note that using metaprogramming can cause unexpected problems, including:

  • Security issues if user input enters your statement eval

    .
  • Errors if you supply data that doesn't work. Be sure to always include robust error handling when using operators eval

    .

For ease of debugging, you can also use metaprogramming to dynamically generate the code that you specified above when you first run the program. Thus, the operator eval

will be less prone to unexpected behavior. See Kyle Boss's answer for more information on how to do this.

0


source


You can use class_eval to create a group of methods

class SomePatterns



  def address_key
    "..."
  end

  def user_key
    "..."
  end

  def location_key
    "..."
  end

  class_eval do
    ["address", "user", "location"].each do |attr|
      define_method "#{attr}_count" do
        redis.scard("#{send("#{attr}_key")}"
      end
    end
  end

end

      

0


source







All Articles