Thursday, August 8, 2013

Strategy pattern in Ruby - x 4

One of my weaknesses as a developer is that I don't really know design patterns as well as I should. I use some of them frequently (Builder, Factory, Adapter, Decorator) but I've never used some of the others, and it caused me some pain the other day. So I'm going through the various design patterns and trying to implement them in various ways in Ruby.

I started playing this evening with the Strategy pattern. The most obvious implementation of the strategy pattern in Ruby is just using modules, like so:

module StrategyWithMixin
  class Context < Strategy::Context
    def execute
      raise 'No default strategy'
    end
  end

  module A
    def execute
      arg_one + arg_two
    end
  end

  module B
    def execute
      arg_one - arg_two
    end
  end
end

context.extend StrategyWithMixin::A
context.execute

It's probably the simplest implementation of the strategy pattern in Ruby. I also came up with an alternate implementation that looks like:

module StrategyWithBlock
  class Context < Strategy::Context
    def initialize(a, b)
      super
      @strategy = DEFAULT_PROC
    end

    def set_strategy(proc)
      @strategy = proc
    end

    def execute
      self.instance_eval &@strategy
    end
  end

  DEFAULT_PROC = Proc.new do
    raise 'No default strategy'
  end

  A = Proc.new do
    arg_one + arg_two
  end

  B = Proc.new do
    arg_one - arg_two
  end
end

context.set_strategy StrategyWithBlock::B
context.execute

Not quite as elegant, but only a single function is being added here, whereas the Mixin has to add the entire module's worth of functions. Not a problem in this case, but it could be in some. A little benchmarking showed that the block implementation was several times faster on JRuby 1.9.

After that I started digging around on the internet and found an implementation that looked something like this:

module StrategyWithClass
  class Context < Strategy::Context
    def initialize(a, b)
      super
      @strategy = DefaultStrategy.new self
    end

    def set_strategy(strategy_class)
      @strategy = strategy_class.new self
    end

    def execute
      @strategy.execute
    end
  end

  class DefaultStrategy
    def initialize(obj)
      @__context_obj__ = obj
    end

    def execute
      raise 'No default strategy'
    end
  end

  class A < DefaultStrategy
    def execute
      @__context_obj__.arg_one + @__context_obj__.arg_two
    end
  end

  class B < DefaultStrategy
    def execute
      @__context_obj__.arg_one - @__context_obj__.arg_two
    end
  end
end

context.set_strategy StrategyWithClass::A
context.execute

This one is actually closer to what would be seen in a Java implementation - the strategy behavior is encapsulated in a class. Surprisingly, this was the fastest implementation yet - it seems like the context switch in the block implementation is slightly slower than making the direct call.

This implementation has one significant advantage over the previous two: if the strategy has state, this encapsulates that state inside the strategy object itself. It's easy to imagine a strategy with cached computations, or a strategy that depends on previous executions of the strategy. This would keep all of that complex behavior encapsulated into the strategy object where it belongs.

The downside is that it isn't very attractive. The execute implementation is littered with ugly calls to the context object. If this method had been written inside of the context class, it would require some significant refactoring to move it into the strategy class. There's got to be a better way.

Of course there is, or this wouldn't be a very interesting blog post. The answer is to delegate to the context object, which looks something like this:

module StrategyWithDelegate
  class Context < Strategy::Context
    def initialize(a, b)
      super
      @strategy = DefaultStrategy.new(self)
    end

    def set_strategy(strategy_class)
      @strategy = strategy_class.new self
    end

    def execute
      @strategy.execute
    end
  end

  require 'delegate'
  class DefaultStrategy < SimpleDelegator
    def initialize(obj)
      super(obj)
    end

    def execute
      raise 'No default strategy'
    end
  end

  class A < DefaultStrategy
    def execute
      arg_one + arg_two
    end
  end

  class B < DefaultStrategy
    def execute
      arg_one - arg_two
    end
  end
end

context.set_strategy StrategyWithDelegate::B
context.execute

Now we're talking. The strategy implementations can work directly off the instance variables of the context, which looks much more elegant than the previous implementation. The downside is that it is significantly slower - the delegation takes its toll on the benchmarks. In most cases I would prefer the readable version as long as it did not completely destroy performance.

And finally, the benchmarks are below:
                             user     system      total        real
Strategy with mixin      0.307000   0.000000   0.307000 (  0.307000)
Strategy with block      0.087000   0.000000   0.087000 (  0.087000)
Strategy with class      0.072000   0.000000   0.072000 (  0.072000)
Strategy with delegate   0.435000   0.000000   0.435000 (  0.434000)

No comments:

Post a Comment