Ruby 方法查找、block、Enumerable与资源
前置:对象、方法与集合。Ruby的灵活性依赖清楚的协议;动态类型不意味着不需要契约。
对象模型与方法查找
方法发送给接收者,实例方法定义在类上,单例方法只属于某个对象。class << self改变定义单例方法的上下文;类方法本质上是类对象的单例方法,不是“所有实例都共享的普通方法”。include把模块放进祖先链的类之后,prepend放在类之前,可在方法里调用super包装原行为。Record.ancestors能观察查找顺序,诊断“到底调用哪一个方法”比猜测文件加载顺序更可靠。
super不带括号会转发原参数,super()明确不传参数;需要包装关键字/block时应按合同转发,不能无意吞掉。method_missing可提供动态接口,但应配套respond_to_missing?,否则反射与工具看到的能力不一致。不是每个一次性脚本都该元编程;直接定义方法通常更容易调试。lab.rb的Tagged prepend实际检查tag:record,展示super查找链。
Data.define适合字段固定的值对象,本项目Entry(name,amount)使用它。Data限制成员重赋,不递归冻结引用组件;项目成员是String/Integer,name若来自可变字符串仍需避免之后修改影响作为键的语义。本项目parse产生的匹配字符串只在当前流程使用,无外部修改者;若做公共库可在构造边界复制/冻结名称并收紧可见性。
block、yield、Proc与lambda
调用方法可附block,方法用yield把值交给block;block_given?检查是否提供,&block把block转为Proc保存。闭包能捕获外层局部变量,延迟执行时观察的是相应变量状态,不一定是创建时的值快照。lambda有更严格参数数量,return通常只返回lambda;普通Proc中的return可能尝试返回创建它的方法,方法已经结束后会出现LocalJumpError。需要可保存回调时,先定义其返回/异常合同。
break可让迭代提前结束,异常也可能穿过yield离开each;资源拥有者的ensure必须覆盖整个调用,不假定每个block都会走完。next跳过本次block、return可能离开外层方法,看到“循环没走完”要先看控制流而不是怀疑集合丢数据。
each、enum_for、Enumerable与lazy
Enumerable以each协议提供map/select/sum等操作;实现each向block逐项yield,没传block时返回enum_for(:each),便可继续链式操作。项目Ledger#include Enumerable因此不需要自己重写所有算法;但each背后是IO,消费会推进文件游标,第二次report不会自动回到开头。需要重读应由拥有者rewind或重新打开,不把“一切Enumerable”当可重复纯集合。
普通map/select通常物化数组;.lazy构造惰性Enumerator链,take(3).force只拉取需要的项。它降低不必要工作,但下游sort或to_a仍可能全量消费和缓冲。惰性不自动解决资源生命周期:不能在File.open block返回惰性枚举器后离开block,再在外面消费已关闭文件。借用Reader模式要让所有消费发生在拥有者作用域内。
rescue、ensure、编码与文件
rescue StandardError捕获常见可恢复失败;不要默认rescue Exception吞掉SystemExit、Interrupt等进程控制。ensure无论成功、异常或提前return都执行,文件使用File.open(path,'r:UTF-8') { |io| ... }让block返回时关闭。库借用IO时不关闭,调用者决定何时结束,测试检查io.closed?仍为false。
指定UTF-8不一定在读取时立即验证全部字节,本项目逐行valid_encoding?再正则解析。文件系统错误Errno::ENOENT与业务ArgumentError有不同根源,CLI最外层映射统一退出2,调试时保留原类型/堆栈。GC回收内存不等于外部文件句柄及时释放,确保关闭比等终结器更可预测。
完整方法链与分配实验
lab.rb
# frozen_string_literal: true
module Tagged
def label
"tag:#{super}"
end
end
class Record
prepend Tagged
def label
'record'
end
end
raise 'method lookup' unless Record.new.label == 'tag:record'
count = 0
input = (1..100).lazy.map { |n| count += 1; n * 2 }
raise 'eager enumeration' unless input.take(3).force == [2, 4, 6] && count == 3
[false, true].each do |materialize|
GC.start
allocated = GC.stat(:total_allocated_objects)
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
values = (0...100_000).lazy.select(&:even?).map { |n| n * n }
sum = (materialize ? values.to_a : values).sum
raise 'unequal semantics' unless sum == 166_661_666_700_000
puts "materialize=#{materialize} sum=#{sum} seconds=#{Process.clock_gettime(Process::CLOCK_MONOTONIC) - started} objects=#{GC.stat(:total_allocated_objects) - allocated}"
end
puts 'method lookup, block and lazy Enumerable passed'运行ruby lab.rb,两次sum=166661666700000,最后输出method lookup, block and lazy Enumerable passed。它用单调时钟和GC.stat(:total_allocated_objects)记录分配对象数,后者是累计分配而非仍存活对象、也不是内存字节。GC.start只让比较起点相对一致,不能模拟真实服务负载。一次实验受JIT开关、版本、热身与迭代器对象影响;惰性并不保证对象数更少,不能因为期待优化就改记录或忽略反例。
MRI的线程调度与GVL使CPU密集Ruby线程不等于多核并行,但I/O期间可有并发;Ractor或多进程有不同隔离与传递成本。当前实验没有引入共享线程状态,不宣称完成所有Ruby并发生态。性能问题先固定结果与输入,再看分配热点和调用栈,不为了几次计时就改全局GC参数。
练习与参考解法
练习:把prepend改include预测输出;解释lazy后sort为何不适合无限序列;让block在第一项raise,谁关闭文件?为什么不能在File.open返回惰性结果后才force?
参考解法
类自有label优先于include模块,结果不再带tag;prepend先包裹再super。sort需要所有元素,无限序列无法完成。File.open的block确保关闭,Ledger借用IO不关闭。返回的Enumerator仍引用Reader,离开作用域后force会读关闭的资源;应该在block内完成消费并返回独立结果。Lab与test.rb都验证真实枚举行为,不靠描述假装执行。
继续阅读
- 开发实践
- 对象与枚举