Skip to content

Latest commit

 

History

History
260 lines (196 loc) · 8.9 KB

chain_of_responsibility.md

File metadata and controls

260 lines (196 loc) · 8.9 KB

Software Design Patterns

This is the second article in a series on software design patterns, continuing from my first article covering the Strategy Pattern. This time, I'll be going over something called the Chain of Responsibility.

A Simple Example

We'll be using the same simple kata in our exploration of the Chain of Responsibility Pattern that we used to explore the Strategy Pattern. The naive solution simply determines whether a number is prime by using a series of rules testing whether a given number is not prime:

  def is_prime?(input)
    return false if input < 2
    return false if input > 2 && input.even?

    sqrt = Math.sqrt(input)
    return false if sqrt == sqrt.floor

    (3..(sqrt.floor)).each do |i|
      return false if (input%i).zero?
    end

    return true
  end

Each rule checks if the input is not prime, and so if the check is true, then the number being tested is not prime and we return false. If all rules are false, we are left with a prime number.

Chain of Responsibility Pattern

In my previous article, I showed how to move these sequential checks into an array of strategies that we could use as part of the Strategy Pattern. This time, I will use these to implement a form of the Chain of Responsibility pattern.

Instead of calling these "strategies", we will call them "processors". And, rather than handle an array of these processors up front, we will let each one handle the decision. Each processor will either know the answer, or pass the decision on to another object to make the decision. In this way, we can potentially have a complex tree of decisions our program will make to determine an answer, much more flexible and powerful than a straight array of strategies to be applied.

This is absolute overkill for our example, of course. We will only need a linear path of decisions, but this will allow us to explore the concepts in this pattern without having to actually deal with a whole tree of decisions.

Converting to a Chain of Responsibility

Briefly, we'll start with our naive solution, then move each check into a Processor class and call that new object to perform the check. As we add each check to a processor, we'll have the last processor hand off the decision to the new object.

The starting point is the same as the code we started with on our exploration of the Strategy Pattern: our first if statement:

  def is_prime?(input)
    return false if input < 2
    return false if input > 2 && input.even?

The first check is whether the input is less than two. But where do we move that?

Processor Classes

We need something to hold the check, and also hold a way to call the next Processor along with logic on when to pass the decision along in the chain.

The first pass looks almost identical to our first Strategy:

module Processors
  class LessThanTwo
    def not_prime?(number)
      return number < 2
    end
  end
end

LessThanTwo

However, this doesn't have any logic on when to pass the decision along in the chain, nor any way to specify a successor. We can add the logic to not_prime?, just sort of guessing at what we need at the moment and waiting for specifics if the need should arise:

    def not_prime?(number)
      if number < 2
        true
      else
        successor.not_prime?(number)
      end
    end

It looks like we need a successor of some sort, perhaps a variable that holds a Processor object. Even better, we can make it a method we can call that will determine the Processor we need, and return an instance of it for us to use:

    def successor
      nil
    end

We don't actually want to return nil, though, because that would just throw an error when we try to call not_prime? on it. We could add a test to see if our successor is nil, and return false if that's the case:

    def not_prime?(number)
      if number < 2
        true
      else
        return successor.not_prime?(number) unless sucessor.nil?
        false
      end
    end

But that unless inside an if just doesn't look right. Instead of nesting if-s and unless-s, let's create a default Processor that just returns false for not_prime?:

module Processors
  class LessThanTwo
    def not_prime?(number)
      if number < 2
        true
      else
        successor.not_prime?(number)
      end
    end

    def successor
      Default.new
    end
  end

  class Default
    def not_prime?(number)
      false
    end
  end
end

Now, we'll call that just like we called the Strategy before:

require 'processors'

class PrimeByChain
  def is_prime?(input)
    return false if Processors::LessThanTwo.new.not_prime?(input)
    return false if input > 2 && input.even?

This passes our tests without a complaint, so we move on to the next check.

IsEven

  class IsEven
    def not_prime?(number)
      if input > 2 && input.even?
        true
      else
        successor.not_prime?(number)
      end
    end

    def successor
      Default.new
    end
  end

We will use Default as the successor on IsEven, and then change the successor for LessThanTwo to create an IsEven object to use:

  class LessThanTwo
    def successor
      Processors::IsEven.new
    end
  end

Next, instead of changing return false if input > 2 && input.even? to use Processors::IsEven, we just remove the line:

class PrimeByChain
  def is_prime?(input)
    return false if Processors::LessThanTwo.new.not_prime?(input)

    sqrt = Math.sqrt(input)
    return false if sqrt == sqrt.floor

Our tests pass, since the LessThanTwo processor passes responsibility on to IsEven all on its own.

Rinse and Repeat

We repeat these steps for the last two checks, ending up with only two lines in is_prime?:

class PrimeByChain
  def is_prime?(input)
    return false if Processors::LessThanTwo.new.not_prime?(input)

    return true
  end
end

We move the last two checks into their own Processor classes, and set the successor methods appropriately:

module Processors
  class IsEven
    def successor
      Processors::HasSquareRoot.new
    end
  end

  class HasSquareRoot
    def not_prime?(number)
      sqrt = Math.sqrt(number)
      if sqrt == sqrt.floor
        true
      else
        successor.not_prime?(number)
      end
    end

    def successor
      Processors::HasIntegerDivisor.new
    end
  end

  class HasIntegerDivisor
    def not_prime?(number)
      if divisor_found(number)
        true
      else
        successor.not_prime?(number)
      end
    end

    def successor
      Default.new
    end

    private

    def divisor_found(number)
      sqrt = Math.sqrt(number)
      (3...sqrt).any? do |i|
        (number%i).zero?
      end
    end
  end

  class Default
    def not_prime?(number)
      false
    end
  end
end

One thing I want to note here is the addition of the private method def divisor_found? to HasIntegerDivisor. I wanted to keep the flow going in the not_prime? method, so I moved the any? check down out of the way.

Epilogue

At the end of the article on the Strategy pattern, we were also left with just two lines in our is_prime? method, which may make it look very similar to our Chain of Responsibility solution here. The two biggest differences I wanted to highlight here both involve increased flexibility.

First, we can add and remove Processors on the fly. If we had a range of Processor classes from which to choose, and the successor pulled from a database, we wouldn't need to change a single line of code to swap out processor objects. We could also have a Processor that somehow alerts someone and then waits for that user to respond with an answer, possibly handing the decision off to yet another user. This would allow an arbitrary number of users to be involved in the decision.

Second, we can put logic in the successor method to branch out in a way we weren't easily able to in the Strategy pattern. This relates to the first point, being able to swap on the fly, so that we can even loop back to an earlier Processor and take a different branch. We could even call two or more Processors, and whichever answers first is the 'winner' of the decision tree.

Unfortunately, this can lend to dense complexity in unraveling the steps involved in a particular decision. This can be ameliorated by logging or some form of a chain of custody, but is still a factor to consider when looking to apply this pattern.