一个对象的方法可以不同于同类其他对象

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

两个任务处理器都由同一个类创建,其中一个却多了一层日志行为。Ruby 允许只给单个对象定义方法,这个方法不会自动加入所有同类实例。用“类里有什么方法”解释全部调用,因此不够完整。

本篇以前一篇的实例与可见性为基础,使用 Ruby 3.4 的 singleton_class、Method#owner 与 super_method 观察实际分派。实验位于 examples/ruby/labs/12/run.rb。这些接口让方法查找成为可验证的对象关系,而不是凭语法猜测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Worker
def run
:normal
end
end

special = Worker.new
ordinary = Worker.new

def special.run
[:special, super]
end

p special.run
p ordinary.run

结果分别为 [:special, :normal] 和 :normal。special 上的定义遮住了普通类中的同名方法,super 继续到下一个实现。另一个实例不经过这个单例方法。

单例类承载单个对象的方法

普通对象的单例方法存放在与该对象关联的单例类中。可以用 object.singleton_class 观察,也可以用 class << object 打开它。类本身也是对象,所以常说的“类方法”,可以从类对象的单例方法理解。

1
2
3
p special.method(:run).owner == special.singleton_class
p ordinary.method(:run).owner == Worker
p special.method(:run).super_method.owner == Worker

三项检查都为真。owner 指向当前实现所属的类或模块,而接收者仍然是 special。方法归属与接收者身份要分别记录:执行来自父类的实现,并不会把接收者变成父类对象。

对于这个没有额外混入模块的例子,查找关系可以写成:

1
2
3
4
special
→ special 的单例类中的 run
→ Worker 中的 run
→ 更上层祖先

special.singleton_class.ancestors 展示候选链,special.method(:run).owner 指出当前命中的实现。ancestors 不会直接列出每个方法,也不能单独证明调用经过了所有节点;实际执行还取决于方法体是否继续调用 super。

不必给所有对象都主动创建或操作单例类。应用里普遍需要的行为放在普通类或模块中更容易追踪;单例方法适合明确的单对象定制、测试替身或特定工厂产物。它是对象模型能力,不是默认的扩展点设计。

完整 lab 使用 Base、item 和 sibling 验证同一关系;下面只画出本次 call 找到的实现,不把全部祖先画成实际执行轨迹:

flowchart LR
  I["item:Base 实例"] -->|"call(7)"| S["item.singleton_class 中的 call"]
  S -->|super| B["Base 中的 call"]
  J["sibling:另一个 Base 实例"] -->|call| B

item.method(:call).owner 是 item.singleton_class,它的 super_method.owner 是 Base;sibling.method(:call).owner 直接是 Base。上方路径执行 super 时,接收者始终是 item,并没有创建 Base 的另一份实例。sibling 的箭头表示另一条独立调用路径,两个接收者不会在运行中合并。lab 还断言 item.singleton_class.ancestors 的前两项依次是该单例类和 Base。

super 继续查找同名实现

super 的语义不是“直接调用源码里写在尖括号后的父类”。模块的 prepend 与 include 都会参与查找,单例方法也能在普通类前面。因此更稳定的理解是:沿当前方法实现之后的查找位置,继续寻找同名方法。

当前实现没有调用 super,链就在那里结束。调用多次,就可能重复执行后续逻辑。对计费、发送或落盘这类有副作用的操作,包装方法中的重复 super 不会获得额外保护,必须通过行为测试防止重复发生。

super_method 可以取得下一层方法对象,用于诊断。它不等于真实业务轨迹:条件分支可能使 super 根本不执行,下一层又可能抛异常。可靠的日志应同时记录实现来源和事件顺序,而不是只有祖先数组。

调试时常用 method(:name).source_location 找到 Ruby 方法的定义位置。由原生代码提供的方法可能没有 Ruby 源文件位置。没有位置不能推导为方法不存在,动态定义与原生实现都需要结合 owner 解释。

三种 super 写法分别传递什么

参数传递是最容易在包装器中丢失的部分。实验定义一个基类,把收到的位置参数、关键字参数和 block 结果组成数组返回。三个子类分别使用裸 super、super() 和显式实参。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base
def call(*args, **kwargs, &block)
[args, kwargs, block&.call]
end
end

class Forward < Base
def call(*args, **kwargs, &block)
super
end
end

class Empty < Base
def call(*args, **kwargs, &block)
super()
end
end

调用 Forward.new.call(1, mode: :x) { :block },结果为 [[1], {mode: :x}, :block]。这个实验中形参未被修改,裸 super 继续传递当前调用的位置参数、关键字和 block。

同样调用 Empty,结果为 [[], {}, :block]。super() 去掉位置与关键字实参,却仍然传递 block。把空括号理解为“所有输入都清空”,会让某些回调重复执行或意外泄漏到父实现。

若必须阻止 block 继续传递,要显式传入 &nil。实验中的 super(:changed, mode: :explicit, &nil) 返回 [[:changed], {mode: :explicit}, nil]。这里每一种输入通道都得到单独验证,避免仅用一个无参数示例得出过宽结论。

这也说明包装 API 时不应把关键字参数随意合并进普通 Hash。Ruby 的位置参数、关键字与 block 是不同通道;包装方法应明确接收并转发它们,或在接口适合时使用专门的参数转发语法。

接收者状态在整条调用链上保持关联

若包装器在进入 super 前修改实例变量,后续实现仍以同一个对象为接收者,能够观察该修改。包装模块不是一个天然隔离的组件,它可以读写宿主实例状态。

这种共享使日志或缓存扩展很简短,也可能造成名称冲突。两个模块都使用 @cache,但分别保存不同形状的数据,就会发生难以从继承图看出的耦合。模块内部状态应有明确命名与生命周期;若组件需要独立状态,显式组合对象通常更清楚。

调用顺序和异常路径也要分开观察。before; value = super; after 只在后续调用成功时执行 after。如果业务需要无论成功失败都清理资源,应使用 ensure;如果是成功计数,就不该移入 ensure。语法相似的包装器,承诺可能完全不同。

普通类的父实现也可能继续调用一个被子类覆盖的方法,因此一次看似直接的父类调用仍可能重新进入动态分派。追踪时应按每个接收者与方法名逐跳检查,不把整段父类代码视为静态执行单元。

为 Taskbook 选择扩展方式

假设只给一个导出器增加实验性格式,单例方法可以表达这项局部行为。但若所有导出器都应记录执行时间,统一的包装模块或组合对象更容易测试。对象是否单独定制、扩展是否需要长期复用,是两个先于语法的判断。

应用不应要求调用者记住复杂祖先顺序来获得正确结果。若两个包装层交换顺序就改变业务含义,接口应明确描述顺序,或改为显式组合管线。查看 ancestors 是诊断手段,不是让每个使用者手工还原对象模型的理由。

实验只对确定的前缀关系断言,不固定打印单例类中的内存地址。地址和默认 inspect 文本会变化,拿它们当稳定输出会使测试变脆。可重复验证应比较真实对象身份与命名类,而不是复制一次终端截图。

要定位的层面 观察接口
实际命中哪个实现 method(:name).owner
下一层同名实现 method(:name).super_method
有哪些祖先候选 singleton_class.ancestors
Ruby 实现定义在哪里 source_location

从外部调用追踪到真实执行

一个实际排查流程可以从 receiver.method(:run) 开始,先记录 owner 与源码位置,再逐层检查 super_method。若同一个方法名出现在三层模块中,就把三段实现中的参数处理、返回值与副作用分别列出来。只搜索类名往往会遗漏单例定义或动态安装的方法。

然后用固定输入执行一次,记录各层进入与退出事件。静态链显示某层存在,事件中却没有该层,通常意味着更前面的实现提前返回或没有调用 super。事件进入后没有退出,则可能是后续异常或非局部控制流,不能马上推导为“方法没执行完所以线程卡死”。

方法对象还绑定了取得它时的接收者,这有助于在诊断中精确复现一次调用。但保存 Method 对象也可能延长接收者的生命周期;用于长期缓存时,需要考虑是否无意中持有整个任务集合。反射方便观察,不应该随意变成全局注册表。

查找链的变化通常发生在类加载或扩展安装阶段。在请求处理中反复增加单例方法,会让不同实例拥有不同接口,增加排查成本。若业务确实需要按实例变化,优先考虑显式配置字段或策略对象,只有接口本身需要变化时才使用动态定义。

单例方法还影响复制选择。普通 dup 与 clone 对单例方法的处理不同,复制一个定制处理器可能丢掉行为,也可能保留原本只希望用于诊断的覆盖。复制测试不能只验证实例变量;对带单例扩展的对象,应检查公开方法归属及行为。

实验不把内存地址或方法打印格式写成固定快照。方法来源、接收者身份和预期结果已经提供了稳定的验证点。输出越接近语言保证,升级补丁版本时越容易区分真正回归与展示格式变化。

阅读继承代码时,还应区分调用父实现与创建父实例。super 延续的是同一接收者的行为;新建父实例会产生另一份状态,可能重新执行构造副作用。即使返回值偶然相同,两种方式也不能互换。为有状态处理器加身份断言,可以直接发现这种误替换。

实验与练习

运行 ruby examples/ruby/labs/12/run.rb。脚本同时验证三个输入通道、单例方法归属、普通实例归属、单例方法的 super 目标与祖先前缀,最终输出 PASS lab 12。

练习一:在 Base#call 中加入事件记录,让单例方法在前后各记录一次。使基类抛异常,观察哪条事件缺失,分别设计成功日志和清理日志的放置方式。

练习二:把 super() 改成 super(&nil),使用相同位置参数、关键字和 block 运行,给结果增加断言。说明两种写法删除了哪些通道,保留了哪些通道。

参考资料

系列导航

导读 · 上一篇:11:类、实例与可见性:对象怎样维护合法状态 · 下一篇:13:Module、include、prepend 与 refinement:复用影响哪条查找链 · 完整源码包