In Progress
Unit 1, Lesson 21
In Progress

Inherited

Video transcript & code

In Episode #441, we came up with a way for concrete subclasses of an abstract Duration class to register themselves with their parent.

There are three steps we have to take to make this work.

First, we have to define the subclass.

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

Next, we must explicitly register the subclass with the parent class.

Duration.register(Fortnights)

Then, we need to ensure that the file we just created is loaded.

require "./models"
require "./fortnights"

Once we've performed these three steps, we can construct new objects of the subclass type using the superclass subscript constructor method.

require "./models"
require "./fortnights"

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

Two of these steps—creating the class, and loading the file—are pretty obvious.

But the middle step, registering the class is less usual, and it would be easy to accidentally forget about this step and then wonder why the new duration type isn't being picked up.

It might be nice if we could make this registration step automatic.

To do this, we go into our Duration superclass definition, and add a new class-level method called inherited.

This method will take a single argument: the subclass that is inheriting from this class.

Inside, we invoke the registration method.

def self.inherited(subclass)
  register(subclass)
end

Then we remove all of the explicit registrations of subclasses.

Back in our new Duration type, we also remove the explicit registration.

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

Now when we load our models file and our fortnights file, we can still use fortnights in duration input, despite the fact that we never explicitly informed the superclass about it.

require "./models"
require "./fortnights"

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

Why does this work?

Well, the class method name inherited is a very special one.

When Ruby sees a new class being defined, it goes and looks at the parent to see if it has a class method called inherited. If so, it calls it automatically, providing the child class object as the argument.

So, by defining this method, we've removed the need for a separate call to register the new duration type.

Now, is this always a good idea? In a word, no. And I'll show you a few reasons why.

First of all, doing registration automatically moves the decision to register from the subclass definition, to the superclass. This may or may not be the best plan.

For instance, as a slightly silly example, let's say we only want to register the Fortnights duration type if we're speaking British English.

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
if ENV["LANGUAGE"] =~ /en-GB/
  Duration.register(Fortnights)
end

Once we decide to auto-register subclasses via the inherited hook, they can no longer make local decisions like this. They are always registered, whether they like it or not.

A less obvious objection to auto-registration has to do with dynamically-generated classes. When I'm writing tests, occasionally I decide I want to create an anonymous subclass of a class that will only be around for the duration of one test scenario.

require "rspec/autorun"
require "./models2"

describe Duration do
  it "provides subclasses with a subscript constructor" do
    subclass = Class.new(Duration)
    instance = subclass[23]
    expect(instance).to be_a(subclass)
    expect(instance.magnitude).to eq(23)
  end
end

# >> .
# >>
# >> Finished in 0.00113 seconds (files took 0.09401 seconds to load)
# >> 1 example, 0 failures
# >>

If we dump the list of Duration implementations after the tests are finished…

…and then we re-run our tests, we can see that the anonymous subclass we generated dynamically is still in the global list of implementations.

at_exit do
  puts "Registered implementations after test run:"
  p Duration.implementations
end

require "rspec/autorun"
require "./models2"

describe Duration do
  it "provides subclasses with a subscript constructor" do
    subclass = Class.new(Duration)
    instance = subclass[23]
    expect(instance).to be_a(subclass)
    expect(instance.magnitude).to eq(23)
  end
end

# >> .
# >>
# >> Finished in 0.0012 seconds (files took 0.07184 seconds to load)
# >> 1 example, 0 failures
# >>
# >> Registered implementations after test run:
# >> [Days, Weeks, Months, #<Class:0x0055f60493a738>]

This an example of "test bleed": our test has unintentionally made a permanent change to the global program environment, and this modified state may cause problems with other tests down the line.

This is not to say we should never use the inherited hook to register classes. But we need to be aware of the trade-offs and gotchas involved. Particularly if we're going to be sharing this codebase with more junior developer, the explicit version may be easier to understand and less prone to surprises. Happy hacking!

Responses