深入 Ruby 14:鸭子类型与委托:不继承也能满足什么契约
有 each 方法,还不等于能遍历任务
一个对象的 respond_to?(:each) 返回真,但调用后从不产生元素。代码没有抛异常,任务报告却变成空集合。动态类型允许调用在运行时解析,协议是否满足仍要靠行为定义。
本篇承接 Enumerable 与对象协作,使用 Ruby 3.4 的核心转换协议,建立两种互不继承的任务源。完整实验在 examples/ruby/labs/14/run.rb。关注点是依赖哪些行为,而不是让所有输入先变成同一种类。
1 | |
这个函数要求 source 在每次迭代时传入包含 :id 的行对象。它没有要求 source 继承 Array,也没有要求行对象必须是某个任务类。接口约束由实际调用构成:能调用 each、能接收 block、按约定产生元素,元素支持 fetch(:id)。
两种独立实现共享一个协议
内存源保存一个数组,生成源则即时产生三条任务。二者都实现 each,因此同一个 ids 函数能处理它们。
1 | |
生成源没有共同业务基类,仍满足调用方的有限要求。Enumerable 提供一批以迭代为基础的集合操作,但它没有替宿主实现真正的数据读取。提供一个名字叫 each 的空方法,无法得到正确集合行为。
无 block 时返回 Enumerator 是本例公开协议的一部分。GeneratedSource.new.each.take(2) 可以在枚举对象上取得前两项;有 block 时,本例返回源对象自身。并非所有任意名为 each 的方法都必须拥有完全相同的返回约定,调用方应写明自己依赖哪些部分。
任务源是否可重复遍历也要明确。本例每次从头生成固定记录,因此两次 ids 相同。文件或网络源可能是一次性流,消费后不能自然重放。如果算法先计算数量再遍历处理,就已经依赖可重放性,不能只写“支持 Enumerable”便省略说明。
方法名检查不验证行为
实验构造一个错误对象:
1 | |
它接受调用,却忽略传入的 block。respond_to? 能回答对象是否声称响应某个名称,不能回答元素形状、调用次数、错误传播或是否真的产出数据。这个反例比“忘了定义方法”更接近工程问题,因为接口看起来齐全,结果却悄然错误。
协议测试应在具体输入上执行真实调用。对两个任务源,用同一契约检查 ID 顺序、无 block 返回值、空输入和元素错误。未来实现文件源时,可以复用这些可观察要求,而不强制复用某个父类的内部状态。
边界验证仍有价值。库接收任意对象时,可以用明确的错误说明拒绝不支持 each 的输入,但这样的检查只是更早报告问题。若把预检查当成全部验证,动态代理或运行时改变行为的对象仍可能在真正调用时失败。
显式转换与隐式转换不同
Ruby 的转换接口有具体名称和约定。to_a 通常表达显式数组转换,to_ary 表达对象愿意作为数组参与需要数组语义的操作。不能因为两者都返回数组,就随意互换。
1 | |
把 ExplicitArray.new 直接放到 [0] + value 中会抛出 TypeError。实验确认这个操作要求的协议不同。Array(value) 是显式转换入口,可使用 to_ary,并在适用时尝试 to_a;它不等同于每个要求 Array 的操作都会自动调用 to_a。
给任务源定义 to_ary,会让它在更多隐式数组语境里被转换。若转换需要把整个数据集读入内存,代码表面上的数组操作就隐藏了消费流与分配成本。因此对可能很大的任务源,保留显式枚举接口,通常比伪装成数组更清楚。
转换方法返回错误类型也属于协议违反。真正的转换测试应覆盖返回值类型与失败路径,不只是检查这个方法存在。对于外部数据,允许转换到什么程度还涉及业务语义;自动把缺失 ID 转成零,虽然完成了数值转换,却可能破坏任务唯一性。
委托显式保留调用边界
当日志层或筛选层持有另一个任务源,可以把有限的方法转发给目标:
1 | |
显式委托只有几行,已经保留 block、返回值与异常。无 block 时 &block 为 nil,目标仍能返回自己的 Enumerator;有 block 时交给目标完成遍历。实验用生成源通过委托读取相同 ID,确认包装没有遗漏数据。
如果目标的 each 未来增加关键字选项,当前委托不会自动支持它们。这是需要明确维护的接口变化。对于少量方法,显式签名能让变化容易被发现;大量重复接口才值得考虑已有委托工具,不能因为动态转发简短就先把所有调用开放出去。
委托不会复制目标状态,也不会让目标变成不可变对象。多个包装器持有同一个一次性源时,仍可能互相消费数据。调试时应分别记录包装器身份和目标身份,避免以为新建包装器等于新建数据源。
把协议写成调用者需要的承诺
Taskbook 的统计函数只需要迭代任务,可以接受最小的 each 协议。排序需要收集全部数据,应明确其内存行为;支持按 ID 查询则是另一个接口,不能假定所有可枚举源都能高效随机访问。
一个协议描述至少要回答输入形状、输出形状、失败方式和状态影响。排序稳定性、是否重复迭代、是否允许修改任务,只有在调用者依赖时才加入约定。协议越宽,实现者承担的义务越多,也越难替换。
鸭子类型的价值来自只依赖必要行为,同时通过实际测试守住这些行为。它没有免除文档与测试,反而要求把原本隐藏在类层次里的期待写得更明确。类名相同只能说明构造来源,不能保证一个实现没有覆盖方法破坏约定。
本例不增加公共抽象父类。两种任务源没有共享状态与初始化逻辑,唯一共同点已经由 each 表达。若以后出现真正共享的行为,再判断模块或组件是否值得提取。
| 需要确认的能力 | 合适的验证 |
|---|---|
| 是否声明响应名称 | respond_to? |
| 能否产出合法任务 | 执行 each 并断言元素 |
| 是否支持隐式数组操作 | 在实际操作中验证 to_ary |
| 委托是否透明于当前协议 | 比较返回值、事件与异常 |
行为协议包含时间与副作用
一个任务源可能每次调用都读取最新状态,也可能在构造时形成快照。这两个实现都能输出合法任务,但在连续两次遍历之间输入发生变化时,结果不同。调用者若需要一致报告,应请求快照或一次性收集数据,不能仅凭 each 接口假定一致性。
顺序也是协议的一部分。本例生成器按 ID 递增输出,因此断言数组顺序;若业务只要求同一组任务而不保证顺序,测试应该比较排序后的 ID 或集合语义。错误地固定无承诺的顺序,会阻止合法实现替换;完全忽略顺序,又可能漏掉报表的真实要求。
返回值契约同样会影响委托。某些 each 返回目标自身,包装器直接转发后返回的是底层对象,调用者若继续链式操作就可能绕过包装层。本例 ids 不依赖 each 的返回值,所以这一差异不影响它。若包装器公开承诺链式返回自己,就必须主动转换返回值并增加测试。
对异常也要沿边界区分。源无法读取数据与某条数据缺少字段,可以由不同异常表达。统计函数不要为了兼容多种源而把它们都转成空数组,否则没有数据和读取失败无法区分。上层是否重试、提示修复输入或终止,依赖这种区别。
协议的最小化有实际成本收益。要求来源实现 size 会迫使未知长度的流先消费全部数据;要求随机索引可能迫使它缓存所有任务。调用方只需要一次统计时,就只依赖一次遍历,把更强能力作为可选接口,而不是先要求所有实现都模仿 Array。
新增实现时,复用同一套行为检查比复制原类更有意义。用合成输入验证空集合、单项、重复 ID 与非法行,能发现“看起来像原实现”的对象是否真正满足当前业务。动态类型把错误发现推迟到运行时,测试可以把这些重要路径提前执行。
实验与练习
运行 ruby examples/ruby/labs/14/run.rb。结果覆盖两个任务源、Enumerator 返回、显式与隐式转换、委托及伪协议反例,最终输出 PASS lab 14。
练习一:新增一个每次最多产生一条任务的一次性源,连续执行两次 ids。为消费行为建立断言,并修改一个依赖重复遍历的算法,使它只遍历一次。
练习二:给错误对象实现会 yield 非法行的 each,确认 respond_to? 仍为真,但 fetch(:id) 失败。选择在源还是统计函数中验证字段,写出错误归属与理由。
参考资料
系列导航
导读 · 上一篇:13:Module、include、prepend 与 refinement:复用影响哪条查找链 · 下一篇:15:Struct、Data 与模式匹配:数据如何被拆解 · 完整源码包
