In Progress
Unit 1, Lesson 1
In Progress

Just-In-Time Decoupling

Video transcript & code

Lately we've been talking about decoupling. Specifically, we have this abstract Duration base class.

And we've been looking at different ways to register concrete duration subclasses such as Days, Weeks, or Months without hard-coding any specifics about these implementations.

In episode #445, we looked at kind of an extreme example of decoupling. We implemented a system where we could register a new Duration type by simply passing a block.

The block would take raw user input value, and return either a new concrete Duration object, or nil to indicate that the input wasn't recognized.

In this case, the block uses a regular expression to see if the input text is describing a number of fortnights. If it is, it returns a new Fortnights concrete instance initialized from the string.

class Fortnights < Duration
end

Duration.register{|raw_value|
  if (match = /\A(\d+)\s+fortnights\z/i.match(raw_value))
    Fortnights.new(match[1].to_i)
  end
}

This way, we can use the base class subscript construction method for all of our Duration parsing. We've enabled this method to recognize and return Fortnights, just by plugging in our new conversion block.

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

In that episode, we talked about how this doesn't just decouple the base class from implementation classes. It also decouples the classes representing duration measurements from the process of mapping input to a specific object. Rather than embedding this input parsing code inside the Fortnights class, we've separated it out into a block.

We also discussed how this level of decoupling introduced some real accessibility issues into our object design. Ultimately, we decided that this strategy was a step too far, and we returned to an earlier, more conservative implementation.

Let's quickly review that older approach. In this version, each concrete implementation class defines a class-level try_parse method, which takes some raw user input, and will return an object if it recognizes that input.

Then, rather than registering with a block, we just register the implementation class itself.

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

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

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

Now, when we're setting up APIs, and making calls like this one about how decoupled we're going to be, it can feel like a lot is riding on our decision. As if the choices we make now are going to decide, once and for all, how tightly coupled all later code will be.

There's a certain amount of truth to this feeling. And in statically typed languages like Java or C++, decisions like these really can lock down the opportunities that later programmers have for introducing flexibility.

But Ruby isn't one of those statically-typed languages. While the decisions we make today will certainly influence how people extend our code in months and years to come, we're really building guidelines rather than stone walls.

Let me show you what I mean by this.

In the approach we've settled on for registering new Duration types, we've said that concrete subclasses of Duration must implement a class method called try_parse which takes some user input and optionally returns a concrete Duration instance.

…right?

Well, let's take a look at the code which makes use of these try_parse methods.

What we can see here is that the code cycles through a list of implementations.

Each implementation must to be some object which responds to try_parse.

There's a poorly-named loop variable called c, which seems to suggest that the object is expected to be a class. But there's no code to actually enforce this constraint.

We can check the definition of the registration method, and see that it simply adds the given argument to a list of implementations.

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

There's no assertion that the object is a class, let alone that it's a subclass of Duration. The object could be anything, so long as it responds to try_parse.

class Duration < WholeValue
  # ...
  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
  # ...
end

So, let's say we came along later, and decided we really didn't want our new Fortnights class to have to be responsible for recognizing and parsing user input text.

class Fortnights < Duration
end

Instead, we'd like to encapsulate the repeated idiom of recognizing and parsing user input using a regular expression. We might do that with a class like this.

This class accepts a regular expression and a class as initialization arguments.

It implements try_parse as an instance method. Inside, we see the familiar regular expression matching conditional, but with the hardcoded regex and implementation class swapped out for instance variable placeholders.

class RegexRecognizer
  def initialize(regex, klass)
    @regex = regex
    @klass = klass
  end

  def try_parse(raw_input)
    if (match = @regex.match(raw_input))
      @klass.new(match[1].to_i)
    end
  end
end

Now we can register our Fortnights class with the Duration base by passing it a new RegexRecognizer.

As arguments, we pass the specific regular expression for fortnights,

and the Fortnights class.

Now, when we ask Duration to convert some user input into an object, we get the correct implementation out.

require "./models"
require "./regex_recognizer"

class Fortnights < Duration
end

Duration.register(RegexRecognizer.new(/\A(\d+)\s+fortnights\z/i,
                                      Fortnights))

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

So, without modifying the base class code at all, we've introduced a new level of decoupling to the process of registering and recognizing kinds of duration.

What we can learn from this is that we don't always need to plan ahead for decoupling. In a language as dynamic as Ruby, we can often introduce a new layer of indirection at the point that we need it, and not before. So long as we keep our expected interfaces small, and refrain from erecting any arbitrary roadblocks to extension such as explicit type checks, we can avoid excessive early abstraction done in the name of "planning ahead". And that means we don't have to worry quite so much about the future when designing our interfaces.

Happy hacking!

Responses