Better Know A Ruby Thing Bonus: Contestants and Nesting
Sorry for skipping a week or two – I was approving copyedits on the book that is now called Programming Ruby 3.3, because we now want to be proactive about the next release.
Coincidentally, the copyedit review does relate to this newsletter. I noticed a particular code sample as I was going through the book again, and it highlights a feature of Ruby’s constant lookup that I didn’t discuss last time.
Hi – we’ve gotten some comments that the code snippets don’t look good on Apple Mail in dark mode. Buttondown is working on this, but if you need to, you can also find this newsletter on the web at https://blog/2023/11/better-know-a-ruby-thing-bonus-contestants-and-nesting
If you like this and want to see more of it in your mailbox, you can sign up at http://buttondown.email/noelrap. If you really like this and would like to support it financially, thanks, and you can sign up for a Monthly Subscription for $3/month or an Annual Subscription for $30/year.
Also, you can buy Programming Ruby 3.3 in ebook from Pragmatic or as a pre-order from Amazon, coming soon.
Thanks!
What’d I Miss?
Here is the code sample.
CONST = "top level"
module Outer
CONST = "outer"
end
module Outer
class Inner
def self.get_const
CONST
end
end
end
class Outer::Shortcut
def self.get_const
CONST
end
end
puts Outer::Inner.get_const
puts Outer::Shortcut.get_const
So, we have a module and two classes. One class is explicitly declared inside a re-opened declaration of the outer module. The other class is declared using the shortcut syntax rather than explicitly declaring the outer module.
The last two lines print out different results.
The next-to-last line, puts Outer::Inner.get_const
, results in "outer"
, but the last line, puts Outer::Shortcut.get_const
, results in "top level"
.
What? Why do those two lines return different values – they are both looking up the same CONST
, right?
Let’s trace it.
Outer::Inner.get_const
calls CONST
inside of class Inner
. Since CONST
is not defined inside class Inner
, Ruby goes hunting. The next place Ruby looks for constants is based on the nesting at the point of the call. Class Inner
is nested inside module Outer
, and since module Outer
defines CONST = "outer"
, that’s the result that gets printed out. An interesting point here is that the Outer::CONST
value gets used even though CONST
is not defined in the part of Outer
that Inner
is nested within.
Moving on, to Outer::Shortcut.get_const
. We start the same way, CONST
isn’t defined inside class Shortcut
either. The difference is that Ruby does not consider class Outer::Shortcut
to be nested the way that explicitly declaring module Outer
external to class Inner
is considered nesting. You can verify this by inserting a puts Module.nesting
call before the two references to CONST
– the one inside Inner
will include Outer
, the one inside Shortcut
will not. The nesting does not find a value, Ruby keeps moving outward, and eventually decides that CONST
equals "top level"
.
I’m not a big fan of the style of blog headline that tries to guess what the reader knows, like “10 things about Ruby Modules that you didn’t know”, so I don’t want to say that this should surprise you, but it surprised me.
And by surprised me, I mean it surprised me on my second pass through the book. I didn’t write the original snippet, it’s from the previous edition – I edited it, but the import of the snippet didn’t stick in my head until I went back to it fresh from writing the last newsletter.
Why is Ruby behaving this way?
I’m not completely sure, but I’d hazard a guess that it’s more likely to be an emergent property of the way the Ruby interpreter works than it is a deliberate design decision on its own.
If the code says module Outer
as its own statement followed by class Inner
, then there is space for you to include other definitions as part of module Outer
, so Ruby actually opens module Outer
:
module Outer
# Anything here is part of Outer
class Inner
# Stuff here is part of Inner
end
# This is also part of Outer.
end
If the code says class Outer::Shortcut
, there’s no space to define anything as part of Outer
, and so Ruby does not open module Outer
:
class Outer::Shortcut
# everything here is part of Shortcut
end
Not opening Outer
in this context is probably a deliberate design choice, but the downstream affect of changing the way constant lookup behaves feels like a side-effect to me. That said, I have no special knowledge here.
Do I have a hot take?
I have a few kind of lukewarm takes.
The first is to avoid the class Outer::Shortcut
form both in regular code and avoid RSpec.describe Outer::Shortcut
in specs, instead use the full version:
module Outer
class NoLongerAShortcut
end
end
Or
module Outer
RSpec.describe NoLongerAShortcut do
end
end
I realize the RSpec one is a little counter-intuitive.
This is what I do in my own projects, which is learned behavior from the days when the shortcut syntax was dicey with Rails autocorrect, and I’ve never gone back. Even though it irks me to give up the two text columns and have everything more indented.
If I encounter the shortcut style in a code review, I tend to recommend not doing it, for the Rails reason, and now in the future for the weird-constant-behavior reason. I don’t normally make a big deal about it if the other coder likes it and understands the trade off.
My second lukewarm take is that it’s probably not a high priority but Ruby should probably behave such that the shortcut form acts as nesting for constant lookup. There may be some principled or performance reason that I’m missing – lots of odd things about Ruby have principled reasons. But on the metric of “how hard is this to explain to a newbie”, this is pretty hard to explain and might be worth cleaning up. It’d probably break some existing code, though, which the Ruby core team is understandably reluctant to do.
My third lukewarm take is about deep module nesting in general. This is going to be wishy-washy even by my standards, but I don’t think of deep nesting as particularly elegant or idiomatic in Ruby, while at the same time, I completely understand how you can get to something like User::Posts::Api::V1::CreatePost
and I’m not sure I have a better solution. (My bias would be toward UserApi::V1::CreatePosts
or even UserApiV1::CreatePosts
, but I’m not convinced those are better, and if you wanted to talk me out of them, I’d fold like a paper airplane.) I put it in the “strict scrutiny” bucket, if I can borrow an ill-fitting legal term. There’s a case to be made that deep nesting is the right choice, but I don’t think of it as the best default option.
And that’s the module definition quirk that I missed the first time around. Back to a full newsletter next week, not sure yet which Ruby thing I’m going to do yet. Also coming up, a full post about the Pickaxe book, which I’ll time for the actual release.