You have encountered a common Ruby gotcha - constant lookup.
The most important concept in constant lookup is Module.nesting (unlike in method lookup, where the primary starting point is self). This method gives you the current module nesting which is directly used by the Ruby interpreter when resolving the constant token. The only way to modify the nesting is to use keywords class and module and it only includes modules and classes for which you used that keyword:
class A
Module.nesting #=> [A]
class B
Module.nesting #=> [A::B, A]
end
end
class A::B
Module.nesting #=> [A::B] sic! no A
end
In meta programming, a module or class can be defined dynamically using Class.new or Module.new - this does not affect nesting and is an extremely common cause of bugs (ah, also worth mentioning - constants are defined on the first module of Module.nesting):
module A
B = Class.new do
VALUE = 1
end
C = Class.new do
VALUE = 2
end
end
A::B::VALUE #=> uninitialized constant A::B::VALUE
A::VALUE #=> 2
The above code will generate two warnings: one for double initialization of constant A::VALUE and a second for reassigning the constant.
If it looks like "I'd never do that" - this also applies to all the constants defined within RSpec.describe (which internally calls Class.new), so if you define a constant within your rspec tests, they are most certainly global (unless you explicitly stated the module it is to be defined in with self::)
Now let's get back to your code:
class SubExample < SuperExample
puts Module.nesting.inspect #=> [SubExample]
class << self
puts Module.nesting.inspect #=> [#<Class:SubExample>, SubExample]
end
end
When resolving the constant, the interpreter first iterates over all the modules in Module.nesting and searches this constant within that module. So if nesting is [A::B, A] and we're looking for the constant with token C, the interpreter will look for A::B::C first and then A::C.
However, in your example, that will fail in both cases :). Then the interpreter starts searching ancestors of the first (and only first) module in Module.nesting. SubrExample.singleton_class.ancestors gives you:
[
#<Class:SubExample>,
#<Class:SuperExample>,
#<Class:Object>,
#<Class:BasicObject>,
Class,
Module,
Object,
Kernel,
BasicObject
]
As you can see - there is no SuperExample module, only its singleton class - which is why constant lookup within class << self fails (print_constant_fails).
The ancestors of Subclass are:
[
SubExample,
SuperExample,
Object,
Kernel,
BasicObject
]
We have SuperExample there, so the interpreter will manage to find SuperExample::A_CONSTANT within this nesting.
We're left with print_constant_works_2. This is an instance method on a singleton class, so self within this method is just SubExample. So, we're looking for SubExample::A_CONSTANT - constant lookup firstly searches on SubExample and, when that fails, on all its ancestors, including SuperExample.