Blankman
Blankman

Reputation: 266988

How to use Ruby's metaprogramming to reduce method count

I have a bunch of methods that are repeating, and I am sure I can use Ruby's metaprogramming somehow.

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 was thinking I could have only one method like:

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

The above is wrong, but I'm saying that the *_count methods will follow a pattern. I'm hoping to learn to use metaprogramming to avoid the duplication.

How could I do this?

Upvotes: 4

Views: 306

Answers (8)

Sagar Pandya
Sagar Pandya

Reputation: 9497

You can call so-called magic methods by interrupting the look-up of method_missing. Here is a basic verifiable example to explain how you may approach your 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 to see if a method ending in _count was called, if so the corresponding _key method is called. If the corresponding _key method does not exist, you'll receive an error message telling you 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 we're calling methods that aren't actually defined anywhere. But we still return a value or error message according to the rules defined in SomePatterns#method_missing.

For further info check out Eloquent Ruby by Russ Olsen, from which this answer references in particular. Note also it's worth understanding how BasicObject#method_missing works in general and I am not at all sure if the above technique is recommended in professional code (though I see @CarySwoveland has some insight on the matter).

Upvotes: 1

engineersmnky
engineersmnky

Reputation: 29318

Since everyone else has been so gracious in sharing their answers I thought I might contribute as well. My original thought to this question was to utilize a pattern matching method_missing call and a few generic methods. (#key and #count in this case)

I then expanded this concept to allow for a free form initialization of the desired 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 for the following implementation 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 what attributes is and not allow for mutation of this object as well.

Upvotes: 1

Cary Swoveland
Cary Swoveland

Reputation: 110675

As @sagarpandya82 suggests, you could use #method_missing. Suppose you wish 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 so:

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 redis was not defined in the class I assumed it to be an instance of another class, which I named Redis.

You could reduce the number of methods to one, by changing 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>

There are, however, some disadvantages with this approach:

  • the code using method_missing is not as easily understood as the conventional way of writing each method separately.
  • the numbers of variables and presence or absence of a block is not enforced
  • debugging can be a pain, with stack overflow exceptions commonplace.

Upvotes: 1

reitermarkus
reitermarkus

Reputation: 708

The simplest way I could think of would be to loop through a Hash with the needed 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

Upvotes: 1

gwcodes
gwcodes

Reputation: 5690

You could create a macro-style method to tidy 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 the macro. This is just an example - you could also e.g. implement it as an ActiveSupport Concern, or extend a module (as suggested in the comments below):

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

Upvotes: 1

Kyle Boss
Kyle Boss

Reputation: 226

I would put all of the "function prefixes" into an array. Upon initialization you can use the :define_singleton_method on these prefixes to dynamically create a instance method on every one 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 might actually be overkill. It will work for what you want, but it will define these functions for that specific instance (hence why its called singleton). The difference is subtle, but important. Instead, it would probably be better to use :class_eval in conjunction with :define_method.

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

Upvotes: 4

mgidea
mgidea

Reputation: 494

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

Upvotes: 0

user886
user886

Reputation: 1197

You could try something like:

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

This interpolates the prefix into a string of the code that will be run. It does not have the error handling you likely need to safely use an eval statement.

Note that using metaprogramming can produce unexpected issues including:

  1. Security issues if user input ever makes its way into your eval statement.
  2. Errors if you supply data that doesn't work. Be sure to always include robust error handling when using eval statements.

For easier debugging, you could also use metaprogramming to dynamically generate the code you have above when you first start the program. That way the eval statement will be less likely to produce unexpected behavior later on. See Kyle Boss's answer for more details on doing it this way.

Upvotes: 0

Related Questions