Skip to content

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
ruby
# 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
end
main.rb
ruby
# 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
end
test.rb
ruby
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。在工程目录执行:

shell
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查看LASTEXITCODE,Linux查看?。

工作包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、命令选项。

继续阅读