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