In Progress
Unit 1, Lesson 1
In Progress

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 Planets module.

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.

Happy hacking!

Responses