Ruby 完整实践:Enumerable分类账、资源与诊断
输入输出合同
前置:基础、block与资源。项目examples/ruby/ledger只用Ruby标准库,test.rb使用StringIO,不需要gem install。读取无BOM UTF-8文件,无表头;每行name|amount。name首位ASCII字母,其余字母/数字/_/-,总长1–32;amount为1–999,不接受符号、空白或前导零。最多10000行;空文件成功,空行错误;LF/CRLF及末行无换行均可。
按分类排序输出name=total,正常0;参数、文件、编码或格式错误2且stderr有说明,stdout为空。空输入不等于失败,错误不能用空数组悄悄代替。全部汇总成功后才print,坏尾行不会产生半份报告;若stdout本身中途失败,仍不能把输出文件视为原子事务。
IO.each_line按行分配,单个超长行仍可能占大内存;10000行限制不是字节限制,处理不信任上传需额外限制。整数在Ruby中可自动扩展大整数,但业务仍限定金额,不能因不会像Int那样溢出就接受无限数字导致资源耗尽。
完整实现与可运行测试
ledger.rb
# frozen_string_literal: true
class Ledger
include Enumerable
Entry = Data.define(:name, :amount)
def initialize(io)
@io = io
end
def self.parse(line, number)
match = /\A([A-Za-z][A-Za-z0-9_-]{0,31})\|([1-9][0-9]{0,2})\z/.match(line)
raise ArgumentError, "line #{number}: expected name|1..999" unless match
Entry.new(match[1], Integer(match[2], 10))
end
# The caller owns @io, including when a block raises or stops early.
def each
return enum_for(:each) unless block_given?
@io.each_line.with_index(1) do |line, number|
raise ArgumentError, 'more than 10000 rows' if number > 10_000
raise ArgumentError, "line #{number}: invalid UTF-8" unless line.valid_encoding?
yield self.class.parse(line.chomp, number)
end
end
def report
totals = each_with_object(Hash.new(0)) { |entry, result| result[entry.name] += entry.amount }
totals.sort.map { |name, amount| "#{name}=#{amount}\n" }.join
end
endmain.rb
# frozen_string_literal: true
require_relative 'ledger'
begin
raise ArgumentError, 'usage: ruby main.rb FILE' unless ARGV.length == 1
output = File.open(ARGV.first, 'r:UTF-8') { |io| Ledger.new(io).report }
print output
rescue StandardError => error
warn error.message
exit 2
endtest.rb
require 'stringio'
require_relative 'ledger'
def check(value)
raise 'assertion failed' unless value
end
io = StringIO.new("z|2\na|3\nz|4\n")
check(Ledger.new(io).report == "a=3\nz=6\n")
check(!io.closed?)
['a|0', 'a|01', 'a|2|3', 'a| 2', 'a|1000', 'x!|1', ''].each do |bad|
begin
Ledger.parse(bad, 1)
raise 'accepted invalid'
rescue ArgumentError
end
end
io = StringIO.new("a|1\na|2\n")
check(Ledger.new(io).lazy.map(&:amount).take(1).force == [1])
check(!io.closed?)
puts 'Ruby module: parse, Enumerable, lazy early stop, borrowed IO passed'Ledger实现each并include Enumerable,parse在Integer转换前严格匹配全行,Data Entry承载name/amount。report用each_with_object维护局部计数Hash,Hash.new(0)避免可变默认值别名;最后sort.map格式化。main在File.open block内消费报告,离开后已获得独立String,再向stdout输出;资源不逃逸。
测试检查总额、非法行、借用IO未关闭、lazy只消费第一条;它使用raise明确失败,不需要测试框架或隐藏依赖。Ruby自己的异常栈在测试失败时帮助定位;CLI只给操作错误说明。不要把CLI捕获错误后退出2说成模块“吞异常”,层次不同。
运行、语法检查与故障复现
保存sample.txt三行z|2、a|3、z|4,无BOM UTF-8。PowerShell7可用Set-Content -Encoding utf8NoBOM sample.txt @('z|2','a|3','z|4');Bash可用printf 'z|2\na|3\nz|4\n' > sample.txt。在工程目录执行:
ruby -c ledger.rb
ruby -c main.rb
ruby test.rb
ruby main.rb sample.txt
ruby lab.rb测试预期Ruby module: parse, Enumerable, lazy early stop, borrowed IO passed;报告为a=3、z=6。把最后一行改z|0,应退出2并显示第3行错误,stdout为空;Ruby -c不会发现数据错误,因为它没有执行文件读取。用缺文件、无参数、多参数分别运行,不能误回成功0。Windows查看
工作包verify.py真实启动每门17项进程测试,含10001行、非法UTF-8、空文件与CRLF,逐项核对完整输出、错误流和退出码。Ruby3.3/4.0两版本分别实跑,不把一份兼容语法描述冒充双平台验证。
NoMethodError、路径、编码与性能
NoMethodError先看接收者实际类型,尤其是否nil;用warn value.class.inspect向stderr调试比直接全链加&.更能定位,安全导航可能把必需数据缺失悄悄变成nil。LoadError先检查require_relative路径与文件大小写,Errno::ENOENT检查工作目录与文件;Windows可运行并不保证Linux大小写一致。
invalid byte sequence先核对源文件/输入编码以及valid_encoding?,不能用force_encoding把错误字节“修复”为正确文本;它主要重新标记编码,真正转码应明确源编码并验证。LocalJumpError常来自保存的Proc里return越过失效上下文,回到block/lambda控制流理解。FrozenError说明修改了冻结对象,检查字面量、dup与所有权,不应无条件去掉整个文件的冻结策略。
调试可在Ledger.parse、each的yield、main rescue处设断点。标准库debug与独立debug gem的可用性因版本/发行打包而异,未配置时不声称已使用编辑器断点验证;可用warn/p和backtrace先定位,确保业务stdout不受污染。性能Lab见方法与枚举实验,对象累计分配不是存活大小,版本与JIT开关要与记录一起保存;一次更少对象不等于端到端更快。
综合练习与复习
练习:增加分类记录数及最大类别限制,先测试相邻边界、重复键、空文件与坏尾行;解释为什么Enumerable不保证可重复读取;在File.open返回Enumerator后才force为何失败?Hash.new([])与Hash.new(0)有什么差别?
参考方向
新键第一次出现时检查类别上限,聚合值改为清楚的total/count记录;输出合同改变需同步CLI期望,错误前不打印。IO驱动的each会推进文件位置,重复枚举不是重新打开;惰性消费必须留在资源作用域内。数组默认值可变且共享,整数默认值不可变、+=会写回新键;需要每键独立数组时用block构造。
本批完成Ruby语言/对象/迭代/错误资源和完整标准库项目,Rails请求生命周期、数据库事务、Ractor与大型gem工程属于额外专题,不把空命令页或一段概述当框架深入已完成。依据:IO、Data、命令选项。
继续阅读
- 对象与枚举
- 对象与枚举