2

I have a whole bunch of similarly-structured methods, each of which look something like this:

def my_method_1
  if params[:user_id]
    #code that stays the same across my_method_1, 2, 3, 4, etc.
    #code that varies across my_method_1, 2, 3, 4, etc.
  elsif params[:tag_id]
    #code that stays the same across my_method_1, 2, 3, 4, etc.
    #code that varies across my_method_1, 2, 3, 4, etc.
  else
    #code that stays the same across my_method_1, 2, 3, 4, etc.
    #code that varies across my_method_1, 2, 3, 4, etc.
  end
end

I have my_method_2, 3, 4, etc. What I'm trying to do is avoid typing out all of this for every method I have, since most of the code is repetitive. I only want to type out the bit of code that actually varies across the methods 1, 2, 3, 4, etc.

My attempt at this uses eval(), which works, and certainly dries up all my individual methods, but makes me uncomfortable. Basically, I have a helper method that takes in key-value pairs, the key being the "context", and the value being a "statement" specified as a string:

def helper_method
  hash.each do |context, statement|
    if params[eval(":#{context}_id")]
      #code that stays the same
      eval(statement)
      return
    end
  end
  eval(hash[:none])
end

Now my individual methods can be super dry, just calling the helper methods and passing in the strings of code:

def my_method_1
  helper_method(
    user:   '#code that varies',
    tag:    '#code that varies',
    none:   '#code that varies'
  )
end

Again, typing out chunks of code inside strings makes me uncomfortable. Any help in going about this another way would be much appreciated!

4
  • There's missing information. So far I'm not sure why you wouldn't just call a method that handles the same code and takes an argument. Or create a utility class that does that for you, and allows either subclassing or method dispatching based on (most likely) a type somehow. So far I don't see a need for anything over-dynamic, which tends to complicate things quite a bit. Commented Feb 4, 2015 at 18:02
  • Yes, without supplying #code that stays the same across my_method_1, 2, 3, 4, etc. #code that varies across my_method_1, 2, 3, 4, etc. it is not clear how to clean it up. Commented Feb 4, 2015 at 18:57
  • I would not use eval or method_missing. I would be very cautious about using any metaprogramming because it tends to make the code much less readable. There should be some other Ruby structures you can use. Commented Feb 4, 2015 at 18:59
  • I don't understand the question. For example, for params[:user_id] is #code that stays the same across my_method_1, 2, 3, 4, etc. something that could be put in a method that would be called by each of the methods 1...4? I suggest you provide a complete example, which I don't think would have be especially verbose. Commented Feb 4, 2015 at 20:32

3 Answers 3

1

The repetitive branches in your code tell me your class could use a little refactoring to remove the need for multiple if-statements. It sounds like your class needs to delegate to another class for specific functionality. While I don't know that your class looks like, or it's intent, the following is an example that you could apply to your code so that you don't need to generate dynamic methods at all.

The hypothetical Order class with repetitive if-statements

Consider this Order class with multiple, similar looking if-statements:

class Order
  attr_accessor :order_type

  def discount_amount
    if order_type == 1
      .2
    elsif order_type == 2
      .5
    else
      0
    end
  end

  def discount_end_date
    if order_type == 1
      DateTime.new(2014, 12, 31)
    elsif order_type == 2
      DateTime.new(2014, 3, 31)
    else
      # Always expires 100 years from now
      DateTime.new(DateTime.now.year + 100, 1, 1)
    end
  end
end

We have three discounts: 20% off that expires end of 2014; 50% which expires end of March, 2014. Finally the default discount of 0% always expires 100 years in the future. Let's clean this up to remove the if-statments, and instead delegate to a Discount class for these calculations.

Refactor the Order class to utilize delegate methods

First up, let's clean up the Order class, then we will implement a Discount class:

class Order
  attr_accessor :order_type

  def discount
    @discount ||=
      if order_type == 1
        Discount.twenty_percent_off
      elsif order_type == 2
        Discount.half_off
      else
        Discount.default_discount
      end
  end

  def discount_amount
    discount.amount
  end

  def discount_end_date
    discount.end_date
  end
end

Nice and clean. An Order object needs a Discount object to get the discount amount and end date. The Order class is now virtually infinitely extensible since the logic for calculating discounts is off-loaded to another class entirely. The Order#order_type value determines the discount. Now, let's define our Discount class.

Implementing the Discount class

According to our (fake) business rules, only three discounts exist:

  1. 20%-off, expiring end of 2014
  2. 50%-off, expiring end of March, 2014
  3. 0%-off (no discount) which always expires 100 years from today, essentially meaning it never expires

We don't want people to create arbitrary discounts, so let's limit our Discount instances to only those that we define by using a private constructor, then declaring static methods for each kind of discount:

class Discount
  private_class_method :new

  def self.default_discount
    @@default_discount ||= new(0)
  end

  def self.half_off
    @@half_off_discount ||= new(.5, DateTime.new(2014, 3, 31))
  end

  def self.twenty_percent_off
    @@twenty_percent_off ||= new(.2, DateTime.new(2014, 12, 31))
  end

  def initialize(amount, end_date = nil)
    @amount = amount
    @end_date = end_date
  end

  def amount
    @amount
  end

  def end_date
    @end_date ||= DateTime.new(DateTime.now.year + 100, 1, 1)
  end
end

Trying to run Discount.new(...) should throw an error. We only have three Discount instances available:

Discount.half_off
Discount.twenty_percent_off
Discount.default_discount

Given that the Order#order_type is used to determine the discount, we emulate this with Order#discount returning the proper Discount instance based on the Order#order_type. Furthermore, we prevent people from gaming the system by defining their own discounts and the logic for all the discounts is in one class.

order = Order.new
order.order_type = 1
puts order.discount_amount # -> .2

order = Order.new
order.order_type = 2
puts order.discount_amount # -> .5

You can use sub classing to create even more specific business logic, for instance a "random" discount:

class Discount
  protected_class_method :new

  ...

  def self.random
    @random_discount ||= RandomDiscount.new(nil)
  end

  class RandomDiscount < Discount
    def amount
      rand / 2
    end
  end
end

Now Discount.random.amount outputs a different discount every time. The possibilities become endless.

How this applies to your question

The existence of repetitive if-statements means your class is doing too much. It should be delegating to another class that specializes in one of those branches of code. You shouldn't have to manipulate methods in Ruby at runtime to achieve this. It's too much "magic" and is confusing to new developers. With the approach I outlined above, you get a strongly typed definition of what these discounts are, and you keep each class focused on one task (and no, "strongly typed" is not a four letter word in Ruby when properly used). You get well defined relationships between objects, code that is easier to test and you get strong enforcement of business rules. All with no "magic."

Sign up to request clarification or add additional context in comments.

Comments

1

You can improve this by using instance_eval, which simply changes self inside the block you pass it (also String#to_sym avoids the eval for the hash key). You can also make the helper method define the method itself, which is a little shorter to use.

def self.define_structured_method(name, hash)
  define_method(name) do
    hash.each do |context, block|
      if params["#{context}_id".to_sym]
        #code that stays the same
        instance_eval &block
        return
      end
    end
    instance_eval &hash[:none]
  end
end

define_structured_method(:my_method_1,
    user:   proc { puts "user code" },
    tag:    proc { puts "tag code"  },
    none:   proc { puts "else code" }
)

Comments

0

You need to use dynamic methods:

def method_missing(method_name)
    if method_name.to_s =~ /context_(.*)/
       #Some code here that you want
       # ...
    end
end

def respond_to_missing?(method_name, include_private = false)
   method_name.to_s.start_with?('context_') || super
end

2 Comments

I don't think this is an appropriate use of method_missing, since all possible cases are known. There's no need for a dynamic runtime hook.
...not to mention that method_missing can be a pain to debug.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.