更新说明: 从 Ruby 2.5.0 开始,顶层常量的错误引用会直接抛出异常而不是仅给出警告。不过本文内容基于 2017 年的 Ruby 版本,讨论的是更新前的行为逻辑,这对理解 Ruby 常量查找的本质机制仍然很有价值。
写 Ruby on Rails 已经有一年多了,随着渐渐开始走进 Rails 神秘魔法的根源,发现很多问题都跟 Ruby 的常量查找机制有关。说实话,这个机制比想象中要复杂一些,特别是在 Rails 的 autoloading 环境下,很容易踩到一些隐蔽的坑。
这篇文章想聊聊 Ruby 常量的定义、存储和查找机制,从基础概念到一些边界情况,希望能帮助大家对 Ruby 的常量系统有个比较完整的认识。我们会通过一些实际的代码例子,深入了解 Ruby 解释器内部是怎么工作的。
常量的定义与存储
要理解 Ruby 的常量查找过程,首先需要了解 Ruby 如何定义和存储常量。
1 | # module is a constant |
Ruby 中也有词法作用域的概念,不过跟大多数语言不太一样,module
和 class
关键字会打开一个全新的作用域。上面代码中的常量 SOME_VALUE
是在 class Something
作用域中定义的,那它实际上存储在哪里呢?
在 Ruby MRI 的实现中,每个 Ruby class 都对应一个 RCLASS 的 C 结构体,这个作用域中定义的常量都会记录在这个结构体里。我们可以在 irb 中通过 Namespace::Something.constants(false)
[1] 来看看 Something
里定义了哪些常量。
1 | Namespace::Something.constants |
很自然地,class Something
本身也是一个常量,定义在 module Namespace
作用域中。
那再进一步想,顶级作用域中的 module Namespace
又存储在哪里呢?答案是它们存储在 Object
的常量表中。用 Object.constants(false)
就能看到所有你定义的顶层常量。
作用域嵌套与 Module.nesting
前面说过,每当遇到 module
或 class
关键字时,Ruby 就会打开一个新作用域,这样就形成了多层嵌套的结构。
在 Ruby 的 C 实现中,有个叫 rb_cref_t
[2] 的结构体用来表示当前作用域:
1 | typedef struct rb_cref_struct { |
这里的 klass
属性表示当前作用域是哪个 class/module,next
指向上一层作用域的 cref 结构体。通过 next
指针形成的链表很清楚地表示了从当前作用域到顶级作用域的层级关系。在 irb 中可以用 Module.nesting
来查看这个链表的 klass 值。
1 | module A |
当我们在上面代码中访问常量 B
时,Ruby 会按照 Module.nesting
显示的顺序来查找:先查 A::B::C
的常量表,没找到;再查 A::B
的常量表,也没找到;最后在 A
的常量表中找到了 B
的定义。
1 | module A; end |
能猜到这段代码的结果吗?它会抛出异常:NameError: uninitialized constant A::B::C::B
继承树查找机制
光靠作用域链查找还不够,我们还需要在子类中访问父类定义的常量。
1 | class Base |
这个很容易理解,Ruby 也会通过继承树来查询常量。Ruby 会沿着当前作用域类[3]的继承树进行搜索,用 Sub.ancestors
就能看到继承树的结构。
这里就有个关键问题了:如果父作用域和超类中都有同名常量的定义,Ruby 会选择哪个呢?我们来验证一下:
1 | class Base |
第一句 p CONST
输出的是 ‘constant in namespace’,说明 Ruby 会优先在作用域链中查找常量,然后才查继承树。
第二句 p Namespace::Sub::CONST
看起来差不多,但结果不一样。执行这句时,Ruby 先找到常量 Sub
,然后在 Sub
下面找 CONST
。这时候 Ruby 不会考虑作用域链了,而是直接查 Sub
的继承树。这两种查找 CONST
的方式还有其他一些细微的差异,后面会详细说明。
顶层常量的查找机制
等等,好像漏了什么?通过 Module.nesting
看不到顶级作用域的常量,那顶层常量是怎么查找的?Ruby 对顶层常量有特殊处理吗?
这个问题的答案既是肯定的,也是否定的。
实际上,Ruby 是通过继承树找到这些顶层常量的。前面说过,Ruby 把顶层常量保存在 Object
中。
1 | class MyClass |
所以在上面的代码中,当我们在 MyClass
的类作用域中访问顶层常量 Math
时,Ruby 通过 MyClass
的继承树找到了存储在 Object
中的顶层常量。
看起来问题解决了!不需要引入新规则来处理顶层常量查找,确实值得高兴。
但问题真的完全解决了吗?
如果你了解 Ruby 内部的继承树结构,就会注意到 Object
并不是继承树的顶端。更重要的是,当我们定义那些经常用作命名空间的 module 时,它们的继承树中甚至可能只有自己!
1 | module Namespace; end |
这确实有问题!我们肯定需要在这些 module 作用域中访问顶层常量。所以,Ruby 在继承树搜索逻辑中增加了特殊处理:如果当前是 module,就从 Object
的继承树中再搜索一遍!这样就解决了问题。至于那些在继承树中位于 Object
之上的类(比如 Kernel
、BasicObject
),它们的特殊行为很可能是故意的。
其实这也是为什么 BasicObject
类作用域经常被用作特殊的、干净的作用域。
1 | class BasicObject |
常量查找的陷阱
来看一个有意思的例子:
1 | class Hash |
在类作用域(这里是 Hash
)中访问顶层常量(这里是 String
)符合我们之前的预期。但 Hash::String
是什么?显然没有这样的类。当我们期望这段代码抛出异常时,可能会被以下结果吓一跳:
1 | (irb):21: warning: toplevel constant String referenced by Hash::String |
这怎么可能?
原因其实很简单:这两种对 String
常量的查询过程在搜索继承树这一步极其相似。它们都会先尝试在 Hash
里找 String
的定义,找不到就沿着继承树向上搜索,最终都会找到 Object
。
这会带来问题吗?
会的,特别是在 Rails autoloading 环境下。因为 Rails autoloading 依赖于 const_missing
的实现,当同名的顶层常量已经加载时,程序可能会错误地把你的常量引用到那个顶层常量,只给出一条警告信息,在大量日志中很容易被忽略。下面这个例子来自 Rails 官方文档[4]:
1 | # app/models/hotel.rb |
1 | $ bin/rails r 'Image; p Hotel::Image' 2>/dev/null |
总结
Ruby 的常量查找机制遵循以下优先级顺序:
- 作用域链查找:按照
Module.nesting
的顺序,从当前作用域向外层作用域逐级查找 - 继承树查找:沿着当前作用域类的继承链向上搜索
- 特殊处理:对于 module,额外从
Object
的继承树中搜索顶层常量
以上就是我理解的 Ruby 常量查找机制。写这篇文章时查了不少资料,把找到的内容整理在这里,希望对遇到类似困惑的人有所帮助。
就酱!