Smart strategies for the strategy pattern

The Strategy Pattern can make the behavior of a class extensible without requiring modification of the class definition. Does that sound strange? Consider the following very simple example

require 'json'
require 'yaml'

class Document

  attr_accessor :body

  def initialize(body)
    self.body = body
  end

  def parse_json
    JSON.parse(body)
  end

  def parse_yaml
    YAML.load(body)
  end

end

This Document class can be used to parse both JSON and YAML content in order to create Ruby objects (hashes in this example).

doc = Document.new <<EOS
{
  "a": "one",
  "b": "two",
  "c": "three"
}
EOS
puts doc.parse_json #=> {"a"=>"one", "b"=>"two", "c"=>"three"}

doc = Document.new <<EOS
---
  'a': 'one'
  'b': 'two'
  'c': 'three'
EOS
puts doc.parse_yaml #=> {"a"=>"one", "b"=>"two", "c"=>"three"}

Now let us say that we want to add the ability to parse XML content. The current design requires the addition of a parse_xml method. One way to avoid modification of the document class would be to choose a design based on the Strategy Pattern. Instead of specifying the parsing algorithm in the class, we inject the parsing algorithm into the class. This will decouple the Document class from the parsing algorithm. In the following example, we inject a lambda that encapsulates a parsing algorithm.

require 'json'
require 'yaml'

class Document

  attr_accessor :body, :parser

  def parse
    parser.call(body)
  end

end

doc = Document.new
doc.parser = ->(body) { JSON.parse(body) }
doc.body = <<EOS
{
  "a": "one",
  "b": "two",
  "c": "three"
}
EOS
puts doc.parse #=> {"a"=>"one", "b"=>"two", "c"=>"three"}

While the Document class does not currently do much, its definition is decoupled from that of the parsing algorithm. This allows us to create other parsing strategies, for example ones that handle YAML or XML content, and to use those strategies with the Document class unmodified.

Especially when dealing with more complex algorithms, it is common to create classes to define the strategies. For example

require 'json'
require 'yaml'

class Document

  attr_accessor :body, :parser

  def parse
    parser.parse body
  end

end

class JSONStrategy

  def self.parse(body)
    JSON.parse(body)
  end

end

doc = Document.new
doc.parser = JSONStrategy
doc.body = <<EOS
{
  "a": "one",
  "b": "two",
  "c": "three"
}
EOS
puts doc.parse #=> {"a"=>"one", "b"=>"two", "c"=>"three"}

While the design has its advantages, it requires the programmer to know which strategies can be used with the Document class. Ideally, we would like to have the flexibility to extend the abilities of the Document class without modification of the definition and make the class intelligent enough to know which strategies are available and usable at any given time.

Imagine an AutoParser that can auto select an appropriate strategy (from a list of known strategies) given a particular document. The usage of the AutoParser might look like this

doc = AutoParser::Document.new <<EOS
---
  'a': 'one'
  'b': 'two'
  'c': 'three'
EOS
puts doc.strategy #=> AutoParser::Strategies::YAML
puts doc.parse #=> {"a"=>"one", "b"=>"two", "c"=>"three"}

doc = AutoParser::Document.new <<EOS
{
  "a": "one",
  "b": "two",
  "c": "three"
}
EOS
puts doc.strategy #=> AutoParser::Strategies::JSON
puts doc.parse #=> {"a"=>"one", "b"=>"two", "c"=>"three"}

Here the YAML strategy is chosen for the YAML document and the JSON strategy for the JSON document, without the need to specify the document format in advance.

To achieve this, we move the Document class into a module called AutoParser, and place the strategy classes into a submodule called Strategies.

require 'json'
require 'yaml'

module AutoParser

  class Document

    attr_accessor :body
    attr_writer :strategies

    def initialize(body)
      self.body = body
    end

    def strategies
      @strategies || AutoParser::Strategies.to_a
    end

    def strategy
      strategies.detect{ |strategy| strategy.available?(body) }
    end

    def parse
      strategy.parse body
    end

  end

  module Strategies

    def self.to_a
      self
        .constants
        .map { |c| self.const_get c }
        .select { |o| o.is_a? Class }
    end

    class Base

      def self.parse(body)
        raise
      end

      def self.available?(body)
        !!parse(body)
      rescue
        false
      end

    end

    class JSON < Base

      def self.parse(body)
        ::JSON.parse(body)
      end

    end

    class YAML < Base

      def self.parse(body)
        ::YAML.load(body)
      end

    end

  end

end

Simultaneously, we add an available? method to each strategy class (In this case done through inheritance from a base class; the method is the same for both strategies). This method is queried by the Document class to determine if a strategy is appropriate to be used on the given Document body. All strategies in the Strategies module will be considered until one is found that reports availability. However, an array of target strategies can also be injected when instantiating a Document. In this way the class is extensible without requiring modification or injection.