深入 Ruby 13:Module、include、prepend 与 refinement:复用影响哪条查找链
日志模块混入之后为何没有执行
类中已经定义了 run,再 include 一个同名日志模块,调用仍然直接进入类的方法。改成 prepend 后,日志又会先执行。这一差异来自查找位置,不是 Ruby 猜测哪个方法更像装饰器。
本篇承接单例类与 super,在 Ruby 3.4 中比较组合、混入与词法扩展。独立实验位于 examples/ruby/labs/13/run.rb,用事件数组记录完整顺序。这里只构造本地日志标记,不把输出日志当成外部投递成功的证明。
1 | |
这个模块没有提供底层工作实现。它依赖查找链后面还有一个兼容的 run,并依赖后续返回值与异常语义符合预期。能成功 include 并不意味着这个协议已经满足。
include 插入复用实现
实验中的 Work#run 记录 :work 并返回 :result。Included < Work 混入日志模块而不自己定义 run,调用顺序为 before → work → after。
1 | |
Included 自身没有命中方法,查找经过 Logging,再通过 super 到达 Work。如果在 Included 中新增一个 run 且不调用 super,日志模块就被遮住了。模块没有失效,只是该调用没有走到它。
因此 include 常用于为类补充协议实现,而不是保证拦截类里已有的同名方法。像 Enumerable 这样基于宿主的 each 提供一组方法的模块,接口依赖清楚;混入一个假定许多实例变量存在的模块,则会增加隐式耦合。
混入不会复制一份完全独立的组件实例。模块中的实例方法运行时,self 是宿主实例,实例变量也落在宿主上。两个模块共享同名实例变量可能互相干扰。独立资源或独立配置更适合由组件对象管理。
prepend 将实现放在类前面
1 | |
本例的祖先前缀为 [Logging, Prepended, Work],事件顺序为 [:before, :own, :work, :after]。日志模块先命中,super 进入宿主自己的方法,宿主再次 super 后到达基类。
这个结构适合围绕已有方法添加行为,但顺序本身成为接口的一部分。若加入缓存与鉴权两个模块,缓存位于鉴权前面可能跳过某些检查。交换模块顺序不仅影响日志排列,还可能改变语义,因此应以具体场景验证,不把“都调用 super”当作可任意组合的证明。
多个包装层还必须遵守返回值与参数契约。某一层忘记返回 super 的结果,最后一条日志语句可能成为业务返回值。某一层只转发位置参数,则关键字与 block 可能丢失。包装器测试至少应观察输出、调用次数、参数和异常传播。
Ruby 官方Module 文档提供祖先相关接口,但最终执行顺序由真实方法体决定。一个 ancestors 数组只能说明候选位置,不能替代事件实验。
组合使被包装对象显式出现
相同日志行为也可以通过普通对象完成:
1 | |
此时日志对象与工作对象是两个实例。日志状态可以由包装器拥有,底层对象的类也不必改变。实验比较组合与 include 版本的事件和返回值,确认在限定契约内二者相同。
组合需要显式转发使用的接口。若底层对象方法很多,把所有未知调用都动态转发会再次引入隐藏依赖。只委托当前需要的少量方法,往往更容易说明:包装器支持的是这个接口,而不是底层对象全部能力的自动镜像。
选择组合还是混入,可以从状态所有权开始。共享宿主状态并扩充宿主协议时,模块自然;持有独立目标、需要多个可替换实例、包装顺序需要显式构造时,组合更清楚。这里不需要为两种方式建立统一抽象层,真实接口很小时保留直接实现即可。
refinement 的生效位置是词法作用域
重新打开核心类会影响进程里使用该类的代码。refinement 允许在明确的词法范围启用扩展,但它不会因为调用栈上某一层执行了 using,就自动修改所有后续方法的世界。
1 | |
LabelClient.label('a') 返回 task:a,而 LabelClient.outside('a') 抛出 NoMethodError。第二次调用进入 outside_refinement。该方法定义于未启用 refinement 的作用域,其 task_label 调用不会继承调用者的启用状态。
官方refinement 文档明确其词法规则。using 可以在顶层或类、模块定义中使用,不能在普通方法作用域中随意打开。启用之后的定义与文件边界都需要检查;重开类也不能假定上一次的启用状态自动恢复。
这个机制适合受控文件中有限的语义扩展。例如为格式化代码增加短方法,而不要求全进程的字符串都获得它。但如果接口将对象传到其他库再期待库里识别扩展,词法边界就可能让设计失败。显式的格式化函数或包装对象通常更容易跨库传递。
不把局部扩展当作沙箱
refinement 控制方法查找的可见范围,不限制代码读取文件、修改全局变量或执行其他 Ruby 操作的权限。它改善猴子补丁的作用范围,但不提供执行不可信脚本的安全隔离。
同样,prepend 到自己拥有的类与全局修改第三方核心类,影响范围不同。依赖外部库内部方法名进行包装时,升级可能改变调用路径,即使公开 API 没变,补丁也可能失效。优先使用库提供的扩展接口;没有接口时,把依赖点和版本约束写进测试。
日志包装失败时,要先确认方法是否被命中,再确认后续链是否存在,最后确认异常路径。直接加更多日志模块可能改变顺序,使原问题更难观察。最小实验只需要一个接收者、三个事件和一个返回值。
| 机制 | 本例可观察路径 |
|---|---|
| include,类未重写 | 类 → 模块 → 父类 |
| include,类直接返回 | 类自身结束调用 |
| prepend | 模块 → 类 → 父类 |
| 组合 | 包装器显式调用目标 |
| refinement | 定义处启用范围决定调用 |
包装接口要分别验证成功与失败
日志扩展最常见的测试只有正常顺序,但生产问题往往发生在异常路径。假设底层工作在写入 :work 后抛出错误,当前模块将留下 before 与 work,而不会留下 after。这符合“成功后记录”的含义。如果 after 表达资源清理,就必须使用 ensure 并补上失败实验。
缓存包装还有另一种路径:命中时不执行 super。此时后面的统计或鉴权包装是否运行,完全受顺序影响。所谓“透明包装”需要列明哪些结果保持一致,不能从模块名推导。返回值相同但调用次数不同,对具有副作用的处理器仍是重大差异。
组合版的优势之一是可把目标替换成受控测试对象,直接记录参数与调用次数。混入版也能通过小型宿主类完成同样验证。测试替身应保持真实接口的失败行为;一个永远返回成功的替身只能证明包装器走到了某条路径,不能证明错误传播正确。
模块依赖还应避免通过零散实例变量暗示。如果日志模块要求宿主提供 logger,最好把它写成明确的方法协议;如果它自行创建 logger,就承担该资源的生命周期。宿主恰好有同名变量并不是可靠契约,未来重构很容易把偶然配合打破。
refinement 也需要在真正的调用位置验证。把断言写在启用范围里只证明本地调用有效;若应用实际把值交给另一个文件的方法,必须通过那个方法测试。仅打印对象的方法列表或在控制台调用成功,不能替代跨文件的实际路径。
库维护者采用 refinement 时,应在文档中明确调用端需要启用,并避免让公开返回值依赖外部无法看到的扩展方法。如果每个调用者都必须理解复杂的启用位置才能使用一个对象,普通包装类型通常更适合作为公开 API。
模块重用也不能跳过加载边界。扩展代码尚未执行时,祖先链不会包含预期模块;同一模块被其他代码重新定义方法时,后续调用又可能改变。复现脚本应明确加载顺序并使用独立命名空间,避免控制台历史污染实验。
在不需要修改宿主类型关系时,先用普通包装对象完成日志任务,可以把目标与顺序直接留在构造代码中。只有确实需要把行为加入宿主协议时,再引入混入机制。这个选择能减少隐含查找,同时保留用模块共享行为的空间。
实验与练习
运行 ruby examples/ruby/labs/13/run.rb,应出现全部事件顺序断言及 PASS lab 13。refinement 外部调用失败是预期反例,脚本会确认异常类型。
练习一:再加一个记录 enter 与 leave 的模块,交换两个 prepend 的顺序。对每种顺序写完整事件断言,说明返回值与异常路径有没有变化。
练习二:将日志实现从 prepend 改成组合,保持成功输出与失败异常一致。给底层对象增加一个包装器未声明的方法,确认包装器没有无意中扩大公开接口。
参考资料
系列导航
导读 · 上一篇:12:单例类、方法查找与 super:调用命中了哪个方法 · 下一篇:14:鸭子类型与委托:不继承也能满足什么契约 · 完整源码包
