4

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?

Sagar Pandya
  • 9,323
  • 2
  • 24
  • 35
Blankman
  • 259,732
  • 324
  • 769
  • 1,199
  • Why doesn't `redis.scard("#{prefix}_key")` work? – whodini9 Apr 13 '17 at 19:35
  • 2
    I think [`send`](http://ruby-doc.org/core-2.4.1/Object.html#method-i-send) may be what you're looking for? Or possibly [`public_send`](http://ruby-doc.org/core-2.4.1/Object.html#method-i-public_send). Or manipulation utilising `method_missing`. – Sagar Pandya Apr 13 '17 at 19:38
  • @sagarpandya82 I was certainly surprised noone implemented a method_missing pattern as an answer – engineersmnky Apr 14 '17 at 01:51
  • @engineersmnky, see my answer. I posted it earlier today, but then took it down because I thought it was too general. I've made some changes and undeleted it. I figured there might be interest in seeing how [BasicObject#method_missing](http://ruby-doc.org/core-2.4.0/BasicObject.html#method-i-method_missing) works. – Cary Swoveland Apr 14 '17 at 03:16
  • @engineersmnky yeah I saw Cary undeleted his answer and I've also added an answer which might be handy too. – Sagar Pandya Apr 14 '17 at 06:08
  • I'm assuming this is just an illustration and there are more methods to handle. If it's just these 6, keeping them as is would be much easier to understand. – fylooi Apr 14 '17 at 14:02
  • 1
    Given the number of answers and your current reputation it might make sense to pick an answer as a solution. This cleans up the forum a bit because at least one of these answers satisfies the question and that means one less question is "unanswered". – engineersmnky Apr 15 '17 at 00:57

8 Answers8

4

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
Kyle Boss
  • 216
  • 2
  • 4
  • 1
    `define_method("#{prefix}_count")` the method name should be with _count – Alex Kojin Apr 13 '17 at 19:47
  • 2
    Why is this defined inside the `initialize` method? – Sagar Pandya Apr 13 '17 at 19:50
  • Good catch, Alex! I just made some changes to account for that. – Kyle Boss Apr 13 '17 at 19:50
  • strange why a instance method is called a singleton method?? – Blankman Apr 13 '17 at 19:52
  • 2
    Rather than say "awesome question", up-vote the question itself. Remember, Stack Overflow is not a discussion list, it's a reference book of programming questions and their answers. Answers aren't part of a conversation, they stand alone. – the Tin Man Apr 13 '17 at 19:53
  • 1
    sagarpandya82, it is in the initialize method because I am assuming OP desires for these functions to be accessible upon initialization right from the get-go. Sure we could place this code somewhere else, but I feel this more fits to OP's needs :) – Kyle Boss Apr 13 '17 at 19:53
  • Seems to me you're generating these methods everytime a new method is created. If you use `define_method` like you originally did and place the method generation within the class scope, the instance-methods are there ready for every new instance. I may be wrong, just trying to understand what's going on here. – Sagar Pandya Apr 13 '17 at 20:04
  • Aww, I see what you are saying! You are totally right -- just made an edit to reflect this. – Kyle Boss Apr 13 '17 at 20:13
  • 2
    There is also no need for the `class_eval`, since you are already in the `class` scope anyways. – reitermarkus Apr 13 '17 at 21:16
  • reitermarkus, would the alternative be to use `:eval`? I think `:eval` is a totally valid (even faster!) solution. It is a string that defines the function though, and as Andrew points out, this brings about it's own issues with error handling. – Kyle Boss Apr 13 '17 at 22:03
  • Kyle, your original answer was (almost) completely correct and your edits though in good faith have caused more confusion. If I were you, I would rollback to version 1 and make the following edits: 1. remove the `initialize` method and just put everything in the class directly (no `class_eval` either), this ensures new instance methods are defined. 2. In the `define_method` block use `redis.scard(send("#{prefix}_key"))` this ensures the return value of `_key` methods are passed to `redis.scard`. If you agree with me go for the edit or I can do it for you if you like. thanks – Sagar Pandya Apr 15 '17 at 11:31
1

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.
Cary Swoveland
  • 106,649
  • 6
  • 63
  • 100
  • While I was thinking of a combination of both similar to the way rails originally implemented dynamic finders (generic method and a pattern matching method_missing) . I find your answer well thought out albeit certainly more cumbersome to understand than the others. – engineersmnky Apr 14 '17 at 03:24
  • @engineersmnky, one problem with *all* the other answers (and with the question) is that they have not demonstrated that their code will work. I assume their authors concluded they couldn't test because `redis` and `scard` were not defined, but didn't stop to ask themselves how `redis` could possibly be defined, considering that the entire class is shown. Could `redis` be anything other than an instance of a class that contains an instance method `:scard`? That was my assumption, which allowed me to (informally) test my answer. I am not a big fan of `mm`, btw. – Cary Swoveland Apr 14 '17 at 03:39
  • @engineersmnky, one more thing: I added the method `modify` to provide a more general context for the use of `mm`, rather than confining my code to the specific methods the OP wants to abbreviate. – Cary Swoveland Apr 14 '17 at 03:45
  • I agree that using `mm` is not usually the best method to facilitate functionality and it can add to the "magic" confusion but I have posted and answer of how I would implement this functionality if i were to use `method_missing` – engineersmnky Apr 14 '17 at 13:27
  • Don't forget `respond_to_missing?` if you're implementing a `method_missing` approach. I find this approach technically sound but least appropriate for the current case, since it adds complexity to `method_missing`'s original intent. – fylooi Apr 14 '17 at 14:14
1

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
gwcodes
  • 5,632
  • 1
  • 11
  • 20
  • 3
    rather than inheritance from `Countable` why not make it a module and then `extend Countable` this offers more flexibility in my opinion but I do prefer this pattern – engineersmnky Apr 13 '17 at 20:58
1

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
reitermarkus
  • 678
  • 1
  • 11
  • 27
1

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).

Sagar Pandya
  • 9,323
  • 2
  • 24
  • 35
  • 1
    Since everyone else has been so gracious I also posted a `method_missing` pattern that can be found in high level production enviroments (read `rails` where [dynamic finders](https://github.com/rails/rails/blob/4-2-stable/activerecord/lib/active_record/dynamic_matchers.rb#L19) **since depreciated** was implemented in a similar fashion) – engineersmnky Apr 14 '17 at 13:31
1

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.

engineersmnky
  • 25,495
  • 2
  • 36
  • 52
  • Same concerns as on @Cary Swoveland's answer, ie. should implement `respond_to_missing?` and makes missing method resolution more complex than need be. – fylooi Apr 14 '17 at 14:18
  • @fylooi fair point updated with `respond_to_missing?`. Not sure what you mean when you say *"makes missing method resolution more complex than need be."*. While I am not a huge proponent of using `method_missing` as a handler the implementation here is about as clean as I feel it can be and as mentioned elsewhere implements a known pattern (albeit much simpler) which was until recently part of `rails`. – engineersmnky Apr 14 '17 at 15:04
  • Say i'm a new team member spelunking through a bunch of deeply nested method calls. By default, I expect objects to either directly define methods or look up the inheritance chain, so I can use `object.methods(false)` to view methods directly defined on the current class. This approach won't show me any of the dynamic methods. – fylooi Apr 15 '17 at 03:59
  • There are situations where `method_missing` is a better solution, but `define_method` looks like a more intuitive fit here to me. – fylooi Apr 15 '17 at 04:04
  • 1
    @fylooi I agree `define_method` is probably a better fit for this given example but I thought it prudent to offer other options for slightly different implementation. Also if you're a new team member and no one documented this code shame on them or they didn't want you to know. – engineersmnky Apr 15 '17 at 12:47
0

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.

the Tin Man
  • 158,662
  • 42
  • 215
  • 303
user886
  • 1,149
  • 16
  • 16
0

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
mgidea
  • 494
  • 2
  • 6