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?
source to share
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
source to share
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.
source to share
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
source to share
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
source to share
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).
source to share
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.
source to share
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.
source to share
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
source to share