深入 Ruby 37:Ruby 惯用法与必要优化
系列导航
导读 · 上一篇:36:YJIT 与性能实验 · 下一篇:38:输入与安全边界 · 完整源码包
优化从业务需要多少结果开始
对一组任务先筛选、再转换、最后只取前二十条,写成枚举链很清楚。但普通 Array 上的 select 和 map 会先完成各自处理,再把结果交给下一步。如果输入很大、最终只需要很少结果,中间工作可能超过业务需要。
这并不意味着所有枚举链都应该改成循环。要先固定契约:输入顺序是否保留、需要全部结果还是前缀、后续非法项是否必须验证、块中是否有副作用。不同契约可能使看似等价的优化改变可见行为。短路带来的速度,不能以遗漏必要校验为代价。
本章比较普通枚举链、惰性枚举链和显式循环。三种写法处理同一冻结输入,返回完全相同的前二十个偶数平方,记录实际访问次数、重复耗时、分配量与 RSS。实验用确定的访问计数解释耗时差异,不以代码行数判断效率。
把三种实现放在同一合同下
从仓库根目录运行:
1 | |
输入是一到十万的整数。普通枚举链写成:
1 | |
select 扫描所有输入并产生筛选结果,map 转换该结果,再由 take 保留前二十项。即使最后只需要二十个值,前两步仍已完成。Enumerable的具体方法行为决定了这种求值顺序,缩短书写长度不会改变它。
惰性版本在链条开头加入 lazy:
1 | |
它建立逐项取值的执行链,到 force 时才实际消费。得到二十个偶数需要读取前四十个整数,之后停止。不产生全量中间数组,是此场景的重要空间变化;惰性枚举器本身仍有对象和调用成本。
显式循环使用结果数组,遇到偶数就追加平方,长度达到二十后 break。它把短路条件写在控制流中,通常容易直接看到终止位置。选择循环不意味着放弃 Ruby 惯用法;清楚表达业务约束,本身就是惯用代码的目标。
用访问次数解释测量
实验在筛选判断前增加计数器。普通链必须访问十万项,惰性链和循环各访问四十项,三个断言与结果数组断言一起运行。它们比某个毫秒数字更稳定,因为直接绑定了输入与算法路径。
耗时包含计数器开销,因此这不是无探针的极限性能排名。计数器让工作差异可观察,两种短路实现也承担相同逻辑。若要比较惰性机制与循环本身的细小差异,需要额外取消计数的性能变体,但正确性与访问数量测试应保留。
每种实现先预热十次,再保存五个正式样本。正式测量前执行 GC,记录分配差值和测量后 RSS。各实现按固定顺序运行,因此样本仍可能受到进程堆容量和缓存的先后影响;这里的主要强结论是访问数量,耗时只作本机观察。
对内存也要读对层次。普通链的中间数组可能占据较多载荷空间,却不一定对应同等数量的 Ruby 对象。一个数组扩容不会为每个整数都新建独立包装对象。对象分配数、数组容量与 RSS 可以呈现不同趋势,不能挑一个最有利的指标代替完整解释。
短路可能改变异常和副作用
假设输入第五十条是非法记录。普通链会读取到它并失败,取前二十个偶数的惰性链只消费四十条,可能正常返回。哪种行为正确,要由接口承诺决定。若接口承诺整份导入文件都通过验证,短路跳过尾部就破坏了契约。
Taskbook 的导入边界目前先验证整体输入,再执行筛选,因此不能为了加快“只看前二十条”就随意把校验放进惰性链并提前停止。可以另设流式浏览接口,但需要明确未消费部分尚未验证,以及文件句柄何时关闭。这已经属于接口设计变化,而非单纯替换语法。
块中有日志、计数、网络请求或写文件时,枚举次数与顺序也是可见行为。普通 map 先执行全部转换,惰性 map 随需求逐项执行;重复消费一个可重放枚举器还可能重复副作用。最好让筛选与转换块保持纯计算,把必要副作用放在明确的提交阶段。
异常出现的时点也会移动。创建 lazy 链条可能不读取任何内容,调用方直到 force 或 each 时才收到错误。若原有 rescue 只包住构建链条的部分,迁移后异常可能逃离预期边界。优化验收需要覆盖消费位置,而非只检查对象创建成功。
不需要结果数组时避免保留它
如果业务只要求计数或求和,没有必要先构造所有转换结果再丢弃。可以使用适合归约的接口或一次循环,直接更新聚合状态。这里的判断依据是返回契约,而非“链越短越快”。
同样,统计标签数量时,重复构造包装对象或动态代理可能增加分配,却没有提供当前需求需要的能力。若一个包装层只把调用转发给唯一实现,删除它往往能同时降低阅读与运行成本。删除前仍要确认它是否承担验证、权限检查或错误转换,不能把有用边界当作样板代码清掉。
对真正需要全量结果的短集合,普通枚举链通常更易读,额外引入 lazy 未必值得。惰性机制的优势来自减少未消费工作或控制流式内存;必须遍历全部元素时,它也要支付逐项协作成本。不能从前缀实验推出所有 map 都应改为 lazy。
性能预算与代码可读性
一个接口已经满足延迟与内存要求时,继续把明确的业务步骤压成难读表达式,收益可能低于维护成本。先列出真实瓶颈,再做最小改动,以相同输入回归。优化提交应说明减少了哪些工作,而非只写“提升性能”。
把复杂判断揉进单行语法,可能隐藏空值、异常和资源释放。特别是 each、map、filter_map 的返回契约不同,替换后结果类型变化容易被正常样本掩盖。空输入、无匹配、重复值和非法类型应进入回归,再考虑微小性能收益。
若业务每次只处理几十条任务,十万整数上的差异不一定影响实际体验。实验输入用来暴露机制,部署决策需要真实分布。应同时测常见输入与允许的上限,避免只针对极端大数组优化,却使常见路径更复杂。
本次样本如何避免错误结论
本次普通链五个样本都访问十万项,惰性链和循环都访问四十项。普通链耗时约四毫秒,短路版本在十至几十微秒量级,时间差主要来自少做了大量工作;不能据此宣称惰性调用本身比普通枚举器快几百倍。
惰性版本每次记录八十七个分配,显式循环为两个,普通链稳定样本为四个。这个看似反直觉的结果与对象大小口径有关:少量数组对象可以持有较多元素空间,而惰性协议会创建更多小对象。所有样本的RSS相同,也不等于三种方法峰值内存相同,因为它们共享同一进程的页面高水位。
如果要严格比较峰值内存,应把每个实现放到独立进程,用一致启动条件测量,并记录输入本身占用。当前实验故意保留这种限制,结论只覆盖确定访问数量、同结果和当前进程观察,不用一列RSS替代完整内存剖析。
另一个可复查细节是结果从四开始,以一千六百结束,一共二十项。排序与数量都包含在数组相等断言里,避免一种实现只返回集合相同但顺序不同。对任务列表,排序往往属于用户可见契约,不能在“优化”时默认忽略。
读者复现时应保留所有样本,避免手工筛除不符合预期的慢值。
练习与验收
在第四十一项放入会抛错的对象,比较三种实现是否触及它。先写出“前缀消费”与“全量验证”两份不同契约,再分别建立正确实现。不要把其中一个结果简单标记为语言 bug。
把 take(20) 改为需要全部偶数结果,保留五个样本和同结果断言,观察惰性短路优势是否消失。再把返回需求改为只求平方和,尝试删除中间数组。每次只改变一项条件,结果才有可解释性。
| 业务条件 | 优先考虑 | 验收重点 |
|---|---|---|
| 只取很短前缀 | lazy或显式break | 访问数量与终止位置 |
| 全量验证后返回前缀 | 独立验证阶段 | 尾部非法项仍被拒绝 |
| 只需聚合值 | 直接归约 | 不保留无用结果数组 |
| 小集合且性能达标 | 清晰的现有实现 | 维持契约与可读性 |
