filter(tasks, min_priority: 4) 与 filter(tasks, { min_priority: 4 }) 看起来接近,却可以产生不同结果。调用不能只看参数内容,还要看参数通过哪条语法通道传入、接收方法如何声明。本篇用一个最小筛选接口检查绑定过程。

文章卡

项目 内容
先修 第 01、04 篇;Array、Hash 与基本方法
实验 bundle exec ruby labs/05/run.rb
核心问题 哪些实参进入位置、关键字与 block,默认值何时求值?
验收 正确关键字、位置 Hash 失败、未知参数失败、转发与默认对象
版本边界 Ruby 3.4.11;不采用 Ruby 2 的隐式关键字迁移经验

方法签名先定义调用形状

累计工具用一个清楚的签名:

Taskbook.filter(tasks, min_priority: 1, tag: nil, status: nil) 接收一个位置参数和三个可选关键字。完整实现见随文工程 lib/taskbook.rb。

tasks 是必须提供的位置参数,后面三项是具有默认值的关键字。位置参数按顺序绑定,关键字按名称绑定;省略可选关键字触发默认表达式。签名决定调用形状,方法体负责检查绑定后对象的类型和值,两层不能互相替代。方法定义

方法体验证 min_priority 范围、tag 的字符串条件和 status 的有限集合。一个调用可以在形状上正确,仍因 min_priority: 'high' 而被拒绝;这应成为领域输入错误,而不是误认为关键字绑定失败。

参数命名也属于 API。把 min_priority: 改成 priority: 会令旧调用出现未知关键字,不能当成纯粹局部变量重命名。发布一个 gem 后,签名变化需要兼容策略与测试。

Hash 对象不是默认的关键字包

本篇实验的最小方法允许位置清单与一个已知关键字,并显式拒绝其余选项:

1
2
3
4
5
6
def select_tasks(tasks, min_priority: 1, **options)
raise ArgumentError, 'unknown option' unless options.empty?
[tasks, min_priority]
end

raise unless select_tasks([], min_priority: 4) == [[], 4]

调用 select_tasks([], { min_priority: 4 }) 多传了一个位置参数,会抛 ArgumentError。拥有同样键值的 Hash 可以通过 ** 显式展开:select_tasks([], **{ min_priority: 4 })。实验对正确与错误两条路径分别断言。

Ruby 3 分离位置与关键字参数,但这不表示“所有看起来像关键字的调用绝不会进入 Hash”。一个只接收位置 Hash、没有关键字形参的方法,仍有相应的兼容调用规则;需要完全拒绝关键字时可以使用 **nil。判断一条调用时必须把调用语法和接收签名一起读,而不是背一个过度简化的口诀。官方关键字分离说明

从 Ruby 2 迁移库时,尤其要检查包装方法。如果旧方法只捕获 *args,再把它传给一个接收关键字的方法,不能假定最后一个 Hash 会自动得到正确解释。第 12 篇的 super 也涉及不同调用形状,但它还包含方法查找和 block 转发。

可变参数没有取消契约

*args 收集位置参数,**keywords 收集关键字,&block 捕获 block。一个方法可以为了转发接收它们,也可以为了开放选项接收未知关键字。后者是否合理,需要具体的业务协议。

本篇 lab 没有让 **options 自动成为“所有输入都有效”的通道。select_tasks([], unexpected: true) 会被方法体拒绝。实际 Taskbook 的固定签名则直接让未知关键字在绑定阶段失败,两种方案都要求错误可见。

把未知选项忽略会让拼写错误变成成功调用。例如 min_priorty: 5 被忽略后仍使用默认 1,输出数量变多,却没有异常。对于小型固定接口,拒绝未知字段通常更容易诊断;确实需要扩展映射时,再建立明确的允许集合。

默认表达式在调用时求值

Ruby 方法的默认表达式不是在定义方法时只计算一次。lab 用一个有计数器的工厂作默认值,连续省略实参两次,检查得到两个不同数组且计数为 2。

这避免了某些语言中可变默认值被长期共享的特定问题,却没有保证所有默认值都独立。默认表达式若返回常量数组或闭包保存的同一个对象,仍然会共享;新对象是否产生由表达式本身决定。

默认参数还可以使用先前已绑定的参数。设计 API 时要考虑这种依赖是否让调用者容易理解。默认值中的时间、随机数与外部读取也会让每次调用不同,测试应控制来源,而不把它们当成普通静态常量。

转发要保留三个通道

完全透传可以使用 ...:

1
2
3
4
5
def forward(...)
select_tasks(...)
end

raise unless forward([], **{ min_priority: 3 }) == [[], 3]

... 表示转发传入参数和 block。它适合不修改参数的包装;需要检查或重写关键字时,显式捕获并传出更清楚。显式形式是 target(*args, **keywords, &block),不能只写 target(*args) 后假定其余信息保留。方法调用

lab 的另一个方法同时收到一个位置 Hash、一个关键字和一个 block,返回三者的观察结果:

1
2
3
4
5
6
7
8
9
10
def separated(*args, **keywords, &block)
[args, keywords, block&.call]
end

result = separated({ min_priority: 4 }, min_priority: 2) { :block }
raise unless result == [
[{ min_priority: 4 }],
{ min_priority: 2 },
:block
]

同名的 min_priority 在两个不同通道中同时存在。方法不会自动选择“更合理”的那一个;接口负责解释。block&.call 此处只在 block 非 nil 时调用,不会把任意假值都当作缺失,安全导航的规则同样需要准确理解。

接口变化需要调用侧反例

单独测试方法体,只能看到已经绑定好的局部变量。要验证 API,必须从外部按真实语法调用,并检查返回值或特定错误。签名中的默认值、未知关键字、参数数量与转发都是这种调用边界。

本次 lab 输出 forward=[[], 3]、三个通道的数组,以及 defaults=2,最终 PASS 05。错误路径没有被统一 rescue 吞掉,预期 ArgumentError 未发生时会让整个实验失败。

练习是给一个包装方法增加 min_priority 的默认覆盖:调用者已传入时保留,未传入时用 4,同时原样转发 tag 与 block。为覆盖、保留和 block 执行各写断言。再给只接收位置 Hash 的接口加入 **nil,比较现有调用哪些会失败,并明确迁移影响。

参考资料

系列导航

导读 · 上一篇:04:Array、Hash 与 Range · 下一篇:06:block、Proc 与 lambda · 完整源码包