Recently, a colleague of mine decided that it was time to refactor some parts of our (Rails-) codebase. In particular, he used the autocorrect feature of rubocop to switch from qualified constant definitions (aka. compact style) to explicit nesting:
Unbeknownst to me, this seems to be a ruby style guide recommendation because using compact style can lead to “surprising constant lookups”. This sounded good in my ear, so I approved his pull request.
But there was one commit that was bothering me. The commit message said:
Fix regressions resulting from enforcing nesting style * Fix resolving `Contract` by prefixing `self::`.
self::? Never had I ever seen that
The Rails app in question is a version 6.1 monolithic monster, which makes heavy use of the trailblazer and dry-validation libraries to abstract away business logic and slim down models and controllers. The paradigm applied here is called DCI (domain - context - interaction). If you’re not familiar with it, I recommend reading Aref Aslani’s article, since explaining it is beyond the scope of this post.
Before the refactor, an
Operation and a corresponding
Contract would look like this:
After the switch to explicit nesting, the classes looks like this:
As you can see, my co-worker prepended
extend Contract::DSL and all
step calls invoking functions found in
Contract. When asked why this was needed, he responded: “Without
self:: the error
uninitialized constant Product::Contract::DSL get’s thrown. And added, that honestly speaking, he doesn’t know what’s up.
I was puzzled. From what I knew about constant lookup in Ruby, prepending
self:: should do nothing, as
self is referring to currently open class or module - and that is always the starting point from where the interpreter tries to discover the constant in question.
The possible difference
Before finding out how this unusual
self:: fix functioned, I needed to grasp why this code broke after all. After reading the infamous constant lookup blog post by Conrad Irwin, I found out that the difference between explicit nesting and compact style manifested itself in the contents of
Module.nesting. This method returns an array which contains a stack of objects representing the actual nesting. In other words, this array contains the lexical scope of the current execution context.
Apart from the lexical scope, Ruby uses the ancestors of the currently open module or class to find a constant. But we’ll come to that later, for now let’s continue examining
Explicit nesting pushes multiple objects to
- The class object following a class keyword gets pushed when its body is executed, and popped after it
- The module object following a module keyword gets pushed when its body is executed, and popped after it.
NOTE: There are some other ways to push objects to Module.nesting in Ruby, the Rails autoloading guide provides a solid overview.
That means that, when using explicit nesting for our operation,
Module.nesting will contain:
Compact style only pushes one object to
Module.nesting, because there is only one class keyword here.
Module.nesting will only contain:
Constant lookup order
Now, if that is the only difference, I suspect that this is where something goes wrong, I said to myself. Looking into the
trailblazer-operation gem (V2.0, because outdated legacy code), I discovered that
Contract was a module in
Trailblazer::Operation, which has a submodule called
DSL. This is what we wanted! Our Create Operation inherits from
Trailblazer::Operation, so why isn’t
Contract::DSL fetched from the operation’s ancestor?
Lexical scoping first
The reason is that lexical scoping kicks in before Ruby looks into the ancestor chain of our currently open class / module. After reading this fact, I suddenly had a realization: We have a
Contract module in
Product! That means that
Contract is resolved to
Product::Contract because of lexical scoping.
This error had not been there before, because
Product had not been in
Module.nesting when we were still using compact style.
Forcing our way into the ancestor chain
How can we “skip” lexical scoping to make sure we load the right
Contract module? I found the solution, but not in Irwin’s article, but in the Rails autoloading guide:
1. The constant is looked up in the parent and its ancestors. In Ruby >= 2.5,
Object is skipped if present among the ancestors.
BasicObject are still checked though.
2. If the lookup fails,
const_missing is invoked in the parent. The default implementation of const_missing raises
NameError, but it can be overridden.
First of all, this explains the error that we got. When Ruby tried to look up
Contract::DSL, it searched for
Contract and its parents.
Contract didn’t contain
DSL, nor did its parents, so
const_missing was invoked in it.
Now what happens when we write
Contract will become a qualified constant by itself. That means, that it’s only looked up in the parent and its ancestors - which is our
Product::Operation::Create and it’s ancestor
Trailblazer::Operation! To sum up: We successfully skipped lexical scoping by prepending
To cite Conrad Irwin, constant lookup in Ruby isn’t actually that hard after-all. Once you get a hang of it, you can easily deduce why lookup errors happen or autoloading fails. For me, this case was especially confusing because of the
self:: syntax - I think a clearer solution would have been to just explicitly state
extend Trailblazer::Operation::Contract to avoid any misconceptions.
I highly recommend you read Irwin’s article. Especially the code snippet in the summary, where he recreates the constant lookup algorithm in ruby itself, was truly helping me understand the order in which constants are resolved.