同一个模块名,常量查找却不同

系列导读 · 下载本篇完整实验

把嵌套模块改成带双冒号的写法,文件缩进减少了,程序却可能开始抛出 NameError。这种重构改变的不只是排版,还改变了代码定义处的词法嵌套。理解这一点,需要把局部变量、当前接收者和常量的查找分开讨论。

本篇承接方法、参数和闭包,使用 Ruby 3.4 的核心能力。例子围绕 Taskbook 的命名空间展开,不涉及 Rails 自动加载。完整实验位于 examples/ruby/labs/09/run.rb,其中每个规则都有可失败断言。

1
2
3
4
5
6
7
8
9
10
module Taskbook
DEFAULT_PRIORITY = 2
module Rules
def self.default_priority
DEFAULT_PRIORITY
end
end
end

p Taskbook::Rules.default_priority

这段代码返回 2。方法定义处嵌套在 Taskbook::Rules 与 Taskbook 中,未限定的常量名能够沿这个定义上下文查找。把内部定义移到顶层写成 module Taskbook::Rules,虽然仍在修改同一个模块对象,词法嵌套却不再包含 Taskbook。

这项差异由 Ruby 的模块与类语法文档明确说明。重构前后应分别记录 Module.nesting,不能用模块的完整名称代替词法作用域。

局部变量属于定义环境

局部变量通过名称与所在局部作用域关联。普通 block 能使用外部已定义的局部变量,这个关系不会因为 block 稍后才执行而消失。方法定义关键字 def 会建立自己的局部变量作用域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
threshold = 2

klass = Class.new do
define_method(:captured) { threshold }

def isolated
threshold
end
end

p klass.new.captured
begin
klass.new.isolated
rescue NameError => error
p error.class
end

captured 返回 2,因为传给 define_method 的 block 捕获了外部局部环境。isolated 中没有形参或赋值建立同名局部变量,裸名称按方法调用处理;接收者也没有这个方法,于是失败。把 def 移进 Class.new 的 block 并不会让它继承 block 的局部变量表。

这解释了一个常见的动态建类问题:配置值存在于构建类的函数中,后来定义的实例方法却读不到。可以把配置值作为实例构造参数,也可以有意识地使用 define_method 捕获它。前者把依赖放在对象状态里,便于检查;后者把依赖放在闭包里,适合生成少量固定行为。两者的生命周期和共享关系需要分别考虑。

捕获一个数组不会复制数组。多个生成方法若使用同一个可变配置对象,就会观察到它后续的修改。解决作用域问题不等于解决状态隔离问题,这正是复制与冻结需要单独研究的原因。

self 决定默认接收者

self 是当前执行上下文中的接收者。实例方法执行时,它通常是收到该调用的实例;类体执行时,它是正在打开的类对象;模块体执行时,它是模块对象。类体中的语句会执行,所以在那里调用 attr_reader 实际是在类对象上调用一个方法。

1
2
3
4
5
6
7
8
9
10
11
class Task
CLASS_BODY_RECEIVER = self

def receiver
self
end
end

task = Task.new
raise unless Task::CLASS_BODY_RECEIVER.equal?(Task)
raise unless task.receiver.equal?(task)

普通 block 保留定义处的 self。数组调用 each 只负责把元素传给 block,不会自动把每个元素设成 self。因此 tasks.each { complete! } 不等价于 tasks.each { |task| task.complete! };前一种写法是在 block 的当前接收者上调用 complete!。

这种区分也适用于实例变量。@status 是当前对象上的状态,读取的是哪个对象,要由执行时的 self 判断。同名实例变量可以分别存在于类对象和该类实例上,二者不会因为同在一个类的源码中就共用存储。类级配置与实例级配置发生混淆时,先打印接收者身份,比继续增加变量名更容易定位问题。

instance_eval 等接口能够改变 block 执行时的 self,但它们没有把 block 的所有词法信息都重新建立一遍。第 17 篇会用配置 DSL 检验这个区别。

Module.nesting 记录源码的嵌套

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module ScopeLab
TOKEN = :outer

module Nested
NESTING = Module.nesting
VALUE = TOKEN
end
end

module ScopeLab::Flat
NESTING = Module.nesting
end

p ScopeLab::Nested::NESTING
p ScopeLab::Flat::NESTING

两次输出分别是 [ScopeLab::Nested, ScopeLab] 与 [ScopeLab::Flat]。双冒号形式确定了目标模块,却没有在源码中进入外层模块体。因此 Flat 内写 TOKEN 与写 ScopeLab::TOKEN 是不同的依赖表达。

命名空间全名适合回答“这个常量放在哪里”,Module.nesting 适合回答“这段代码定义时处在哪些词法层次”。两项观察可能指向同一个模块对象,但不能互相替代。维护库代码时,明确限定外部配置常量,通常比依赖偶然的文件嵌套更容易移动代码。

重开一个模块也不会恢复最初定义时的嵌套。每次打开模块都有自己的定义上下文;同一模块中的两个方法,可以因为定义位置不同而解析到不同常量。阅读源码时需要找到实际方法定义,不能只看最终的模块名。

继承不会重新解释父类方法中的常量

实验还定义了父类与子类各自的 TOKEN。父类方法返回父类定义处的 TOKEN,子类新定义的方法返回子类自己的 TOKEN。

1
2
3
4
5
6
7
8
9
10
11
class Parent
TOKEN = :parent
def token = TOKEN
end

class Child < Parent
TOKEN = :child
def own_token = TOKEN
end

p [Child.new.token, Child.new.own_token]

结果是 [:parent, :child]。父类方法被子类实例调用时,self 的确是子类实例,但方法中的未限定常量名仍受定义上下文影响。不能把常量读取想象成一次隐式的 self.class::TOKEN。后者是另一种显式查询,表达的是按运行时类取常量。

继承本身也能让类访问祖先定义的常量。这里的重点不是排除继承,而是防止把“祖先可以提供常量”扩大为“所有常量都按实际接收者动态分派”。词法嵌套、当前定义位置和祖先路径共同影响具体形式。遇到复杂场景,缩成一个常量、一层继承和一个方法,观察结果后再还原框架代码。

顶层常量可以使用 ::Name 明确引用。反射接口 const_get 的继承开关则是另一套显式 API 参数;不能拿一次 const_get 结果证明某个源码位置的裸常量名一定采取相同路径。

在 Taskbook 中组织配置

任务优先级的默认值适合放在明确的模块常量中,任务自身的当前优先级适合存储在实例变量里,单次筛选阈值适合作为参数。三者的修改频率、所有权与生存期不同,统一放成全局或类级状态会隐藏依赖。

如果需要允许调用者覆盖默认值,构造器应接受关键字参数,默认表达式显式引用 Taskbook::DEFAULT_PRIORITY。这样测试可以传入不同值,不必重开模块替换常量,也不会把测试顺序引入结果。

常量名的存在不保证引用对象不可变。DEFAULT_TAGS = [] 的绑定使用常量语法,数组仍可能被修改。冻结问题要沿对象引用继续分析;单靠大写名称无法得出线程安全或深度不可变结论。

调试问题 应观察的对象
裸方法调用发给谁 当前 self
block 使用哪个局部变量 block 定义处的局部环境
同名常量为何变了 定义形式与 Module.nesting
父类方法读取谁的常量 方法定义位置与祖先关系

从错误名称回到定义位置

定位名字解析问题时,可以先把失败表达式按语法分类。小写裸名称可能是局部变量或方法调用,大写开头的名称走常量机制,带 @ 的名称读取当前接收者的实例变量。对不同类别使用同一个“到父类找一遍”的解释,会同时漏掉闭包与词法嵌套。

例如导出模块内读取 FORMAT 失败,应先检查定义所在模块体,而不是只检查调用该导出器的类是否有这个常量。如果导出器由一个工厂动态创建,还需要找到 block 实际定义的位置。调用栈中的最近一层应用代码不必是常量依赖的来源。

缩减案例时,保留产生失败的定义形式。把 module Outer::Inner 改写成两层模块再测试,已经移除了可能的根因。反过来,把文件里的嵌套类改写成 Class.new,又引入了 block 的局部捕获能力。最小复现要删去无关业务,不能更换决定语义的语法结构。

同样,使用交互解释器逐行输入与执行一个完整文件,可能使局部变量的解析环境不同。需要验证生产文件中的行为时,应保存为独立脚本,并使用固定解释器运行。实验记录应包含完整源码,不能只有结果截图,否则无法判断是否在缩减过程中改变了作用域。

常量查询还可能触发自定义的缺失处理或自动加载。当前实验没有引入这些扩展,故能够直接把找不到常量归因到定义环境。应用若重写了相关钩子,需要把钩子加入调用路径;不能用纯语言实验直接证明框架加载顺序。

一个可维护的命名空间应减少对偶然嵌套的依赖,但也不必把每个内部常量都写成冗长全名。稳定的模块内部可以保留局部引用;跨命名空间或经常移动的代码则使用明确限定。选择依据是依赖是否清楚,而不是缩进层数是否统一。

实验与练习

在系列固定的 Ruby 3.4 环境运行 ruby examples/ruby/labs/09/run.rb。脚本断言 block 捕获、def 隔离、两种模块定义形式、类体接收者与继承方法常量行为,最终输出 PASS lab 09。脚本把预期的 NameError 作为反例验收;若没有出现错误,实验本身失败。

练习一:把嵌套模块改成双冒号形式,保留未限定常量名,先记录失败,再改成明确限定名。解释为何目标模块身份没有改变,程序行为仍然改变。

练习二:把生成方法捕获的整数改成可变数组,生成两个实例并修改原数组。为共享行为加断言,再改为构造器注入独立副本,观察哪项身份关系发生变化。

参考资料

系列导航

导读 · 上一篇:08:Enumerable、Enumerator 与 Lazy · 下一篇:10:身份、相等、复制与冻结:谁共享了可变状态 · 完整源码包