深入 Ruby 25:RBS、Steep 与静态检查边界
系列导航
导读 · 上一篇:24:调试器、警告与语法诊断 · 下一篇:26:JSON、CSV 与领域输入边界 · 完整源码包
签名承诺了整数,Ruby 会自动拒绝字符串吗
一个方法在 RBS 中声明返回 Integer,Ruby 执行器并不会因为旁边存在签名文件,就自动验证每次返回。RBS 描述接口,Steep 根据接口分析选定代码,运行时则继续遵循 Ruby 的动态语义。把这三层分开,才能理解“类型检查通过”和“程序实际正确”为什么需要分别验收。
本篇使用 RBS 3.9.5、Steep 1.10.0 和 CRuby 3.4.11。前置是鸭子类型、数据表示、依赖管理和行为测试。完整实验在 examples/ruby/labs/25/,签名在 sig/typed_tasks.rbs,检查范围由 Steepfile 明确指定。
为了让推断过程可见,实验选取 Taskbook 的任务集合与标题遍历协议,构成独立的 TypedTasks 子集。它不等于整个 Taskbook 已通过类型检查;JSON/CSV、CLI、HTTP 和动态实验仍由各自行为测试验证。
record 把需要的字段写出来
实验任务使用只含 id 和 title 的记录,签名如下:
1 | |
Array[task] 表达集合元素类型,record 表达每个元素的字段合同。titles 返回字符串数组,each_title 接收一个处理字符串的 block,并返回 nil。这里 block 的结果没有被实现消费,所以用 void 表达调用方不依赖该结果。
这份签名没有“猜测”任意 Hash 都合法。它为当前子集定义明确形状,因此 task[:title] 可以被解释为 String。若把记录宽化成任意键值 Hash,检查器就很难保留字段级关系;若全部替换成 untyped,检查通过也会失去原本要获取的反馈。
RBS 的 语法文档 描述类型与方法签名形式。本文使用的具体语法由锁定 RBS 版本实际解析验证;在线主分支文档可能继续演进,不能把未在本版本运行的语法直接加入教程。
Ruby 实现保持普通方法形式
实现仍然是普通 Ruby:
1 | |
签名为参数提供结构信息,检查器沿调用分析返回值和 block 参数。这种方式没有要求给每一行都添加注解。接口稳定、数据形状有限的代码,通常更容易得到有用的静态反馈;动态生成大量方法的代码则需要额外表达或缩小边界。
显式结尾 nil 让 each_title 的返回合同清晰。若直接返回 tasks.each 的结果,实际返回可能是原集合,与签名不符。这个差异不是风格争议:调用方是否允许依赖返回集合,属于公开协议。类型检查帮助发现实现与声明的偏离,但应先确定哪个合同是希望保留的,再修代码或签名。
声明也可能写错。如果一个方法实际上应该返回集合,却为了通过检查随手在末尾加 nil,就会改变行为。类型错误要求核对意图,不是命令开发者无条件服从现有签名。行为测试在这里为已有公开合同提供另一份证据。
检查范围写在配置里
工程配置只选择这份实现:
1 | |
执行 steep check 成功,说明检查器在这份配置、签名与工具版本下,没有报告所选文件的类型问题。未列入配置的文件没有因此获得相同结论。代码仓库里存在 Steepfile,不是“整个项目已静态验证”的证明。
范围可以逐步扩大,但每次扩大都应明确新进入的边界。比如 IO 解码结果可能需要先做形状验证,再转成静态可描述的领域记录;动态路由可能需要显式接口;第三方 gem 则需要与版本相符的签名。一步把整个目录加入,随后用大量 untyped 消除错误,可能只得到更大的绿色区域而没有更强保障。
Steep 1.10.0 文档 提供配置和检查流程。RBS 声明不依赖 Steep 才能存在,Steep 也不是 Ruby 执行器;分别记录二者版本能解释升级后不同的诊断结果。
一个错误返回值应该怎样失败
正例通过后,实验在临时目录复制签名与配置,创建错误变体:
1 | |
其余方法仍保持原实现,签名仍要求 Integer。Steep 对临时变体返回非零,日志保留相应类型诊断。父实验只把这个失败视为预期的负场景,最终仍应退出零并输出 PASS 25。
这个测试避免修改工作区的正确实现,也避免依赖“检查器应该能看出”的推测。若工具没有检查到该文件、签名路径拼错或错误被配置隐藏,负控制就可能意外通过,外层实验会报告失败。配置本身也是可出错的输入,需要被检验。
正反例使用同一套签名和工具版本,差异集中在返回行为,便于解释诊断为什么出现。单独向检查器提交一个语法损坏文件,只能证明它会拒绝语法错误,无法验证方法返回类型的检查能力。
运行时仍要验证外部输入
行为部分用合法记录调用总数、标题数组和 block 遍历,检查真实返回。随后绕开外部校验,故意传入缺少字段的 Hash:
1 | |
运行时没有因为 RBS 文件存在就提前阻止调用。titles 得到 nil 元素,后续字符串操作失败。这个反例显示的是“未经检查的调用路径可以违反声明”,不是说类型系统没有价值。静态检查只针对被纳入分析的代码与假设,外部 JSON、反射调用或其他未分析来源仍需要边界验证。
即使输入被确认是 Integer,也可能违反 priority 的一到五范围;String 也可能是空白或错误编码。普通类型约束与业务取值约束不是同一个层次。Taskbook.validate 保留运行时检查,确保来自 CLI 或 HTTP 的输入在进入业务处理前满足领域合同。
签名也不能替代资源检查。一个方法返回 String,却泄漏文件描述符,类型结果仍可能完全正确。线程间顺序、写入耐久性、退出协议等性质需要对应实验。选择静态工具的价值是提前发现一类错误,同时让其他类错误继续由更适合的观测手段承担。
动态能力越强,接口越需要明确
Ruby 可以通过 define_method、method_missing 和运行配置构造行为。接口如果在运行时才决定,静态分析需要获得可用的声明或明确的动态边界。第 16 篇的可诊断协议与这里一致:未知调用不能悄悄吞掉,respond_to? 与实际调用应保持一致。
RBS 项目的 签名实践说明 讨论了签名表达和工具能力的边界。工程上不必为了类型工具完全放弃动态机制,但应判断它是否真的减少了当前问题的代码与错误。如果普通对象或显式方法已经足够,给动态包装追加大批签名,可能增加了维护面却没有新增业务能力。
签名、实现和测试需要共同演化。删除一个参数时,静态检查能找出部分调用点;行为测试能检查退出码和输出;打包验证能发现签名或入口漏入归档。三者分别约束内部关系、外部结果和交付边界,没有一个结果可以替代另外两个。
可空值和联合类型改变调用条件
如果标题允许缺失,签名就不能继续声明无条件返回 String。允许 nil 的返回值要求调用方在调用字符串方法之前做分支处理;这正好把“业务是否允许缺字段”转化为接口上的可见差异。为了消除诊断而把返回值写成任意类型,会丢失这个有用提醒。
同样,一个参数允许字符串或整数,不意味着所有调用都能用两种类型共有之外的方法。实现需要先识别实际形态,再进入相应分支。静态检查擅长检查这种声明与控制流关系,但分支条件本身是否符合业务仍需要测试。例如字符串虽然非空,却可能全是空白,类型层面依旧成立。
接口签名表达的是对象支持什么操作,不一定要求继承某个具体类。一个只需要 each 的函数可以依赖遍历协议,让数组与自定义集合共同满足。这与第 14 篇的鸭子类型相连:动态调用时由对象行为满足合同,静态分析时由签名让工具获得相同协作信息。
不过接口越抽象,越要说明枚举元素与 block 的关系。只写“有 each 方法”不足以告诉检查器 yield 什么值,也不足以告诉读者是否能多次遍历、是否会读取文件或产生副作用。类型签名可以表达一部分关系,资源生命周期与重复消费合同还需文字和行为实验补充。
运行与练习
1 | |
运行记录位于 evidence/25/run.txt。应同时看到正例检查成功、临时错误变体诊断、合法运行结果和未校验输入的预期失败。日志中出现负例类型错误并不表示整个实验失败,父脚本退出状态才是汇总判定。
练习一:让 titles 返回任务 ID,保持原签名,比较静态诊断与行为断言各自指出什么。练习二:为一个支持 each 的任务源写接口签名,把实现参数从具体数组扩展为该协议;确认普通数组和自定义任务源都满足合同,并记录新增的检查范围。
