Constant Lookup Scope
Video transcript & code
Let's say we have a file that constants for some vital stats on planets in the solar system.
module Planets module Jupiter RADIUS_MILES = 43_441 APHELION_MILES = 507_363_000 end end
We can reference these constants wherever we need, using their fully-qualified names.
require "./planets" Planets::Jupiter::RADIUS_MILES # => 43441 Planets::Jupiter::APHELION_MILES # => 507363000
Now let's say we decide we also want these stats in metric form. We add a new file,
metric.rb, which re-opens the
Planets module and adds metric versions of the numbers.
module Planets::Jupiter RADIUS_KM = 69_911 APHELION_KM = 816_520_800 end
Once again, we can reference these constants wherever we want.
require "./planets" require "./metric" Planets::Jupiter::RADIUS_KM # => 69911 Planets::Jupiter::APHELION_KM # => 816520800
Next, we get the bright idea that rather than hardcoding kilometers, we can simply convert the original mile units into kilometers. Back in the
metric.rb file we add a conversion factor to the top-level
Planets module, and redefine the metric stats in terms of this new constant.
module Planets MILES_TO_KM = 1.60934 end module Planets::Jupiter RADIUS_KM = (RADIUS_MILES * MILES_TO_KM).round APHELION_KM = (APHELION_MILES * MILES_TO_KM).round end
But now when we try to load these files and use the constants, we get a surprise failure:
require "./planets" require "./metric2" Planets::Jupiter::RADIUS_KM # => Planets::Jupiter::APHELION_KM # => # ~> /home/avdi/Dropbox/rubytapas/158-constant-lookup-scope/metric2.rb:7:in `<module:Jupiter>': uninitialized constant Planets::Jupiter::MILES_TO_KM (NameError) # ~> from /home/avdi/Dropbox/rubytapas/158-constant-lookup-scope/metric2.rb:6:in `<top (required)>' # ~> from /home/avdi/.rvm/rubies/ruby-2.0.0-p247/lib/ruby/site_ruby/2.0.0/rubygems/core_ext/kernel_require.rb:45:in `require' # ~> from /home/avdi/.rvm/rubies/ruby-2.0.0-p247/lib/ruby/site_ruby/2.0.0/rubygems/core_ext/kernel_require.rb:45:in `require' # ~> from -:2:in `<main>'
It seems this code can't find the
MILES_TO_KM constant we just defined.
In defining the metric constants, we've used a syntactical shortcut to reopen the
Jupiter module. Rather than using two nested module declarations, we've used a single module declaration with the fully-qualified module name.
Let's try switching this to the longer nested form.
module Planets MILES_TO_KM = 1.60934 end module Planets module Jupiter RADIUS_KM = (RADIUS_MILES * MILES_TO_KM).round APHELION_KM = (APHELION_MILES * MILES_TO_KM).round end end
Suddenly, the code starts to work.
require "./planets" require "./metric3" Planets::Jupiter::RADIUS_KM # => 69911 Planets::Jupiter::APHELION_KM # => 816519570
So what's going on here? In order to get some insight into how these two forms of module declaration differ, we'll use a special method Ruby defines for introspecting the current constant lookup scope. We'll start with a long-form nested module declaration. Inside the declaration, we examine the return value of
Module.nesting. This method shows us the list of places that Ruby will look for a constant within the current scope.
module Planets module Jupiter Module.nesting # => [Planets::Jupiter, Planets] end end
As we can see, the constant lookup chain consists of two locations: first, Ruby will look in the
Jupiter module, and if the constant is not found there, it will then look in the
Ruby actually omits one final implicit final stop in the lookup chain. If we define a constant at the top level, outside of any class or module, it is added to the
Object class. It can then be found inside our nested module, even though
Module.nesting doesn't show it.
ANSWER = 42 Object::ANSWER # => 42 module Planets module Jupiter Module.nesting # => [Planets::Jupiter, Planets] ANSWER # => 42 end end
Now let's look at
Module.nesting inside a shorthand module declaration.
module Planets module Jupiter Module.nesting # => [Planets::Jupiter, Planets] end end module Planets::Jupiter Module.nesting # => [Planets::Jupiter] end
As we can see, this time the lookup path contains only the
Jupiter module, not the containing
Planets module. This is because Ruby determines the lookup chain based only on the lexical scoping of the current code. It does not take into consideration the containing modules or classes of the current class or module. This might come as a surprise. It certainly came as a surprise to me, the first time I found out about it.
In keeping with the Principle of Least Astonishment, I feel that this is a good argument for preferring the fully nested form of module and class declarations to the shorthand version. But this is not the only reason to prefer the longer form. The shorthand form is also problematic when it comes to load order. If we switch the order the files are loaded in, we see an error:
module Planets::Jupiter Module.nesting # => end module Planets module Jupiter Module.nesting # => end end # ~> -:1:in `<main>': uninitialized constant Planets (NameError)
This is because the shorthand form will only create the module at the tail end of the module nesting. It won't automatically create any intermediary modules if they don't already exist. This limitation is unavoidable, because it has no way of knowing if the intervening missing constants should be declared as modules or as classes.
The takeaway from all this is simple: there is no reason that I can think of to ever use the shorthand form of module declaration. If we choose, instead, to always declare each level of nesting explicitly, we avoid surprises.
Before I go, I'd like to thank Conrad Irwin, whose article "Everything you ever wanted to know about constant lookup in Ruby" helped inspire this episode.