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