In Progress
Unit 1, Lesson 21
In Progress

Block Inherited

Video transcript & code

Note: the audio quality is a little below my standards in this episode. I didn't crank up the post-processing to eliminate ambient noise sufficiently, and by the time I caught the mistake I was out of time. Sorry!

Today let's start by briefly revisiting Episode #443. In that episode, we had an abstract base class which defined the special inherited class method.

This enables the base class to auto-magically register subclasses as soon as they are loaded.

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

In that episode, we discovered that this led to some unplanned side-effects. One example we found was that involved a test that caused a temporary, anonymous subclass to be created.

We saw that the subclass was automatically registered, and it stayed registered even after the test was finished executing.

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

require "rspec/autorun"
require "./models"

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.00174 seconds (files took 0.11442 seconds to load)
# >> 1 example, 0 failures
# >>
# >> Registered implementations after test run:
# >> [Days, Weeks, Months, #<Class:0x00561e661f33a0>]

In that episode, we talked about how this is one reason we need to think carefully before using the inherited hook to auto-register child classes.

But what if we're stuck in a situation where we have to work with a superclass that implements the inherited hook? Maybe it's in a third-party gem, or maybe too much existing code depends on the auto-registration feature for us to quickly change it.

What we're about to do is some more advanced defensive metaprogramming. Hopefully, you'll never have to do this. But I think it's worth going through the exercise, in order to understand Ruby's reflection hooks a little better. As well as to give us a starting point, should we ever need to debug code that makes use of these hooks.

Let's see if we can get a clearer view of exactly how the inherited hook works. In order to do so, let's create a simple class hierarchy.

We'll start with a base class, A.

Inside, we'll define the inherited hook method to spit out a tracing message.

We'll subclass it, with B.

Again, we define a tracing message.

Let's execute this as-is.

class A
  def self.inherited(subclass)
    puts "A.inherited(#{subclass})"
  end
end

class B < A
  def self.inherited(subclass)
    puts "B.inherited(#{subclass})"
  end
end
# >> A.inherited(B)

We see a single line of tracing output. It looks like the hook inside of A was invoked automatically, with the new subclass B as the argument. The hook inside B was not invoked.

Let's make a hypothesis about the rules at work here: when a new class is defined, any class methods called inherited defined in its superclasses are invoked. However, the inherited in the class being defined is not invoked. This makes sense, since the hook is only intended for noting new child classes.

Now let's add a third level to this hierarchy, with a class called C, inheriting from B.

What do we expect to happen here?

Well, according to our hypothesis, the inherited callbacks in classes A and B should be invoked.

Let's try it out.

class A
  def self.inherited(subclass)
    puts "A.inherited(#{subclass})"
  end
end

class B < A
  def self.inherited(subclass)
    puts "B.inherited(#{subclass})"
  end
end

class C < B
end

# >> A.inherited(B)
# >> B.inherited(C)

Does this match our expectations?

Well, we see B inheriting from A, as before.

And we see C inheriting from B.

Our hypothesis was that all superclass inherited hooks would be invoked every time a new subclass was defined. But if that were the case, we'd also see a the A.inherited method being called again, this time for its grandchild C. That's not what we're seeing here.

OK, new hypothesis. Maybe the inherited hook is only invoked for immediate children.

How can we test this? Well, we now have a class C with no inherited method defined. Let's give it a subclass D.

If our new hypothesis is correct, we will see no inherited call for D, because its immediate parent C does not define the callback.

Let's find out.

class A
  def self.inherited(subclass)
    puts "A.inherited(#{subclass})"
  end
end

class B < A
  def self.inherited(subclass)
    puts "B.inherited(#{subclass})"
  end
end

class C < B
end

class D < C
end

# >> A.inherited(B)
# >> B.inherited(C)
# >> B.inherited(D)

Again, this is not quite what we were expecting. This time, we see the inherited method on B being invoked twice: once for its immediate child class C, and once for its grandchild D.

At this point, we can formulate a more realistic idea of the semantics of the inherited callback. It looks like what happens is this:

When a class is defined, Ruby searches up its hierarchy of parent classes for the first one to define a class method named inherited if and when it finds one, it invokes it. Then, it stops its search. A maximum of one inherited callback will be invoked for any class definition.

Now that we understand the model better, it gives us a clue as to how to block a given inherited hook from being called.

We know that Ruby will stop at the first callback it finds as it walks up the inheritance chain.

So if we want to block the definition in B from being invoked…

…we just need to intercept it with a nearer definition. Let's make one in C.

We'll define this method as a no-op, and leave a comment to make it clear that this space is intentionally left blank.

Now when we execute this code, we see that there is no longer a trace message for D inheriting from B.

class A
  def self.inherited(subclass)
    puts "A.inherited(#{subclass})"
  end
end

class B < A
  def self.inherited(subclass)
    puts "B.inherited(#{subclass})"
  end
end

class C < B
  def self.inherited(subclass)
    # NOOP
  end
end

class D < C
end

# >> A.inherited(B)
# >> B.inherited(C)

Alright, now we have a strategy in hand. Let's apply it to the test code we saw at the beginning of this episode.

We have a temporary subclass of Duration, and we want to block the superclass inherited hook from being called for it.

In order to intercept the search for an inherited callback, we need to interpose a new class. We'll call it DurationNoInherited, and make it a subclass of Duration.

Of course, this intermediate class will be auto-registered, whether we want it to be or not. In order to make it as benign as possible, we define the try_parse method, which all Duration subclasses are supposed to define, to always return nil. This will ensure that it is always ignored in searches for implementations.

According to what we learned a moment ago, in order to interdict calls to inherited, we need to define a no-op version in this class. But we're going to leave that off for now, and you'll see why in a moment.

We'll use this new intermediary class as the immediate parent of the test temporary.

When we run this, and take a look at the list of registered implementations, we see something interesting.

As expected, the DurationNoInherited class has been registered. But what we don't see is an entry for the anonymous subclass generated inside the test.

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

require "rspec/autorun"
require "./models"

class DurationNoInherited < Duration
  def self.try_parse
    nil
  end
end

describe Duration do

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

# >> .
# >>
# >> Finished in 0.00124 seconds (files took 0.07072 seconds to load)
# >> 1 example, 0 failures
# >>
# >> Registered implementations after test run:
# >> [Days, Weeks, Months, DurationNoInherited]

Now, this is what we ultimately wanted to accomplish. But this seeming success feels a bit fishy, because we haven't yet defined a no-op inherited callback to intercept invocations of the base class implementation.

In order to see what has happened, we'll add another bit of logging when the program ends. To complement the dump of the list of Duration.implementations, we also dump DurationNoInherited.implementations.

When we run this and take a look at the output, we can see what went wrong. Even if it isn't immediately obvious why it went wrong. We see that there are now two lists of implementations, one for the base Duration class, and one for the DurationNoInherited class.

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

require "rspec/autorun"
require "./models"

class DurationNoInherited < Duration
  def self.try_parse
    nil
  end
end

describe Duration do

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

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

This is unexpected. And if we hadn't known to dump DurationNoInherited.implementations, we might have gone for a long time thinking that we had somehow succeeded in blocking the auto-registration. But in fact all we've done is reveal a latent defect in the auto-registration code: the fact that it only works correctly if there is a single level of inheritance.

For now let's leave aside the subject of fixing this bug. We now understand that our incomplete intermediary class appeared to accomplish its goal, but really it just added another layer of incorrect behavior. Now that we're aware of this gotcha, let's proceed with the implementation we originally planned.

We add our no-op definition of self.inherited to DurationNoInherited.

This time, when we run the tests, we can see that no temporary Duration classes show up in either the Duration or the DurationNoInherited implementation lists.

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

require "rspec/autorun"
require "./models"

class DurationNoInherited < Duration
  def self.try_parse
    nil
  end
  def self.inherited(subclass)
    # NOOP
  end
end

describe Duration do

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

# >> .
# >>
# >> Finished in 0.00158 seconds (files took 0.07432 seconds to load)
# >> 1 example, 0 failures
# >>
# >> Registered implementations after test run:
# >> [Days, Weeks, Months, DurationNoInherited]
# >> DurationNoInherited.implementations:
# >> []

This is an ugly kludge of a fix, but for now it enables us to run our tests without one test contaminating the global environment of the next. A better long-term fix would involve making changes to the Duration superclass so that child classes aren't always forced to be auto-registered.

As I said before we dove in today, hopefully you'll never have to write code like this, or have to think about inheritance callbacks to this level of detail. But if you ever do, now you'll have a better idea of what rules Ruby follows for finding and invoking these hook methods. Happy hacking!

Responses