In Progress
Unit 1, Lesson 21
In Progress

Decoupled Pluggable Conversion

Video transcript & code

In Episode #441, we had a hardcoded case-statement for parsing time durations into concrete class instances. From there, we refactored the code so that the subclasses could register themselves with the superclass. Now, the generic subscript constructor method looks through a list of implementations, and chooses one with a pattern attribute that matches the given input.

Let's pick up a little before where we left off in that episode, as we were going over why the extraction we've performed still has some serious problems.

Yes, it's true that we are now delegating the pattern to be used for input matching to the concrete subclasses.

But instead of making those subclasses an active participant in the input matching process, the Duration superclass is simply grabbing their pattern attribute and doing the matching itself.

To make matters even worse, it then makes the bold assumption that the duration magnitude part of the input will be matched by the first capture group in the regex.

This means that implementors of Duration subclasses have to know, not only to add a pattern method, but that the returned regex must include a first capture group that grabs the magnitude.

This is a rather nasty bit of implicit coupling.

OK, so let's iterate on our approach.

Instead of taking the pattern attribute from the implementation, we'll have the factory method ask the subclass if it recognizes the given input.

Then, instead of assuming we know what the concrete subclass' initializer looks like, we'll use a specially-named constructor called for_string, passing in the raw user input.

This means that we have to implement two new class-level methods in our Fortnights subclass.

First, we need to implement the recognize? predicate. It will simply test the pattern we defined earlier against the given input.

Second, we implement the for_string constructor method. It also matches against the pattern, but it then uses the captured magnitude in constructing a new object.

For the sake of variety, we've changed from using "magic" regex capture variables, to using a MatchData object to get at the capture data.

Let's make sure this new version works.

require "./models"

class Duration
  def self.[](raw_value)
    return new(raw_value) if self < Duration
    case raw_value
    when Duration
      raw_value
    when String
      implementations.each do |c|
        if c.recognize?(raw_value)
          return c.for_string(raw_value)
        end
      end
      ExceptionalValue.new(raw_value, reason: "Unrecognized format")
    else
      fail TypeError, "Can't make a Duration from #{raw_value.inspect}"
    end
  end

  def self.implementations
    @implementations ||= []
  end

  def self.register(klass)
    implementations << klass
  end
end

class Fortnights < Duration
  def self.pattern
    /\A(\d+)\s+fortnights\z/i
  end

  def self.recognize?(raw_value)
    pattern =~ raw_value
  end

  def self.for_string(raw_value)
    match = pattern.match(raw_value)
    new(match[1].to_i)
  end
end

Duration.register(Fortnights)

Duration["2 fortnights"]
# => Fortnights[2]

OK, so this version is an improvement from an object design perspective. Yes, the subclasses are now coupled by two methods instead of just one. But the superclass constructor is no longer making detailed assumptions about how the subclasses will work.

It just asks them whether they recognize some input,

and then if they do, tells them to instantiate themselves for that input. This gives the implementations a lot more freedom in how they do their jobs.

But there's still some room for improvement here.

For one thing, like we just said, the concrete subclasses are now required to implement two different class methods to satisfy the requirements for pluggability.

Another problem, or at least annoyance, is that our Fortnights class has to apply the same regular expression twice: first to see if it recognizes input, and second to make a new object based on that input.

Can we address these shortcomings? Let's see.

This time, let's start from the concrete subclass, and then change our superclass factory to accommodate the difference.

We'll get rid of all of our existing class methods. In their place, we'll make just one new method, called try_parse.

It will take a raw_value argument.

It will attempt to match the given input against a regular expression, saving the match data in the process.

If the match succeeds, it will use the match capture data to instantiate a new object.

If the match fails, Ruby's default return value for a failed if condition ensures that the return value of this method will be nil.

OK, now let's update the superclass construction method.

Where before we used each to imperatively iterate through implementations, this time we'll send detect to the collection.

Inside the detect block, we'll attempt to create an object for the given input by sending try_parse to the currently selected implementation.

We'll save the return value into a local variable.

Then, if the try_parse succeeded and returned a non-nil value, we use the trick we learned in episode #440 to break and return the new duration object from the detect, instead of returning the matched class.

If no implementation is able to match the input and create an object, detect will return nil. We use || to handle this case by instantiating an ExceptionalValue.

One more time, let's see if this works.

require "./models"

class Duration
  def self.[](raw_value)
    return new(raw_value) if self < Duration
    case raw_value
    when Duration
      raw_value
    when String
      implementations.detect{|c|
        value = c.try_parse(raw_value) and break value
      } || ExceptionalValue.new(raw_value, reason: "Unrecognized format")
    else
      fail TypeError, "Can't make a Duration from #{raw_value.inspect}"
    end
  end

  def self.implementations
    @implementations ||= []
  end

  def self.register(klass)
    implementations << klass
  end
end

class Fortnights < Duration
  def self.try_parse(raw_value)
    if (match = /\A(\d+)\s+fortnights\z/i.match(raw_value))
      new(match[1].to_i)
    end
  end
end
Duration.register(Fortnights)

Duration["2 fortnights"]
# => Fortnights[2]

OK, admittedly, seeing the same output for the fourth time in a row isn't that exciting.

But code-wise, this final version has a lot going for it. We've opened our Duration factory method to extension, by enabling new duration types to register themselves.

We've whittled down the coupling between the extension point and the various implementations to just a single method with dead-simple expectations. Despite the fact that we are, fundamentally, asking the Duration implementations for a new duration object, we've managed to find a way to do it that respects the principle of "tell, don't ask".

We tell each implementation to try to construct itself from a string. We know the response will either be a valid object, or nil—and that's all we know. Meanwhile, the implementation classes are free to define their matching and creation code however they wish.

This seems like a good stopping point. And I think it's a solid example of how to set up a pluggable construction or conversion mechanism in Ruby. I hope you find it useful the next time you come upon a problem like this one. Happy hacking!

Responses