Enumerable 深度探索

任何 Ruby 开发者都会频繁使用 mapselectreduce。大多数教程到此为止。但 Enumerable 的能力远不止这些基础方法。深入理解 Enumerable,你可以构建自定义集合类、实现惰性无限序列、用高级遍历方法替代手动循环。

Enumerable 是 Ruby 标准库中最被低估的模块之一。它只需要一个 each 方法就能提供超过 50 个集合操作方法。这一章带你挖掘 Enumerable 的全部潜力。

运行 hello advance enumerable 可以查看完整演示代码。

自定义 Enumerable 类

Enumerable 是一个 mixin 模块。只要你的类实现了 each 方法并 include Enumerable,就自动获得所有集合操作能力:

class TemperatureReadings
  include Enumerable

  def initialize(locations)
    @data = locations
  end

  def each(&block)
    @data.each(&block)
  end

  # Enumerable 提供的能力:
  def average
    map { |_, temp| temp }.reduce(0, :+) / count
  end

  def hottest
    max_by { |_, temp| temp }
  end

  def coldest
    min_by { |_, temp| temp }
  end
end

readings = TemperatureReadings.new([
  ["北京", 28], ["上海", 32], ["广州", 35],
  ["哈尔滨", 18], ["成都", 26], ["武汉", 33]
])

puts "平均气温: #{readings.average}°C"       # 29
puts "最热: #{readings.hottest[0]}"           # 广州
puts "过热城市: #{readings.select { |_, t| t > 30 }.map(&:first).join(', ')}"
# 过热城市: 上海, 广州, 武汉

核心规则就是:实现 eachinclude Enumerable,获得一切。这个模式在 Ruby 中被称为"最小接口,最大能力"。

惰性求值与无限序列

普通的 mapselect 会立即遍历整个集合并创建新数组。对于大型集合,这意味着大量的中间对象分配。lazy 方法改变了这种行为:它返回一个惰性 Enumerable,只在需要时才计算元素。

# 无限斐波那契数列
fib = Enumerator.new do |yielder|
  a = 0
  b = 1
  loop do
    yielder << a
    a, b = b, a + b
  end
end

# 惰性操作:取 > 100 的前 3 个偶数
# 不会导致无限循环!take(3) 后停止计算
result = fib.lazy.select { |n| n > 100 && n.even? }.take(3).force
puts result.inspect  # [144, 987, 6765]

force 方法将惰性序列转为普通数组。在调用 force 之前,链路上的所有操作都不会实际执行。这是处理大数据集的标准做法。

惰性求值的另一个优势是性能:

large_range = (1..1_000_000)

# 立即求值:map 先处理 100 万个元素,select 再过滤
result1 = large_range.map { |n| n * 3 }.select(&:even?).take(5)

# 惰性求值:逐元素流过 map → select → take,取够 5 个后停止
result2 = large_range.lazy.map { |n| n * 3 }.select(&:even?).take(5).force

puts result1 == result2  # true,结果相同
# 但惰性方式只处理了约 10 个元素,立即方式处理了 100 万个

当你处理的数据集远大于最终结果时,lazy 会带来数量级的性能提升。

minmax_by:一次遍历获最值

minmax_by 在一次遍历中同时找出最小值和最大值。比分别调用 min_bymax_by 少遍历一次,对于大数据集能省一半的时间。

words = %w[apple banana cherry date elderberry fig grape]
shortest, longest = words.minmax_by(&:length)
puts "最短: #{shortest}, 最长: #{longest}"
# 最短: fig, 最长: elderberry

# 按自定义规则比较
numbers = [15, 3, 42, 7, 28, 1]
min_max = numbers.minmax_by { |n| Math.sqrt(n) }
puts "平方根最小/最大: #{min_max}"  # [1, 42]

minmax(不带 _by)是按元素自身的默认比较。minmax_by 是按你提供的转换函数的结果比较。两者都在一次遍历中完成。

chunk_while:按条件分组

chunk_while 根据相邻元素之间的关系将序列分段。这对于按自然断点分组数据非常有用。

numbers = [1, 2, 3, 5, 6, 8, 10, 11, 12]
groups = numbers.chunk_while { |a, b| b == a + 1 }.to_a
puts groups.inspect
# [[1, 2, 3], [5, 6], [8], [10, 11, 12]]
# 把连续的数字分成一组

# 按奇偶分组
parity = [1, 3, 5, 2, 4, 7, 9, 11].chunk_while do |a, b|
  (a.odd? && b.odd?) || (a.even? && b.even?)
end.to_a
puts parity.inspect
# [[1, 3, 5], [2, 4], [7, 9, 11]]

chunk_while 接收一个条件块,当相邻两个元素满足条件时,它们属于同一组。这个模式在处理日志、时间序列、连续事件时非常常见。

slice_after:按标记分割

slice_after 在匹配条件的元素之后将序列切开。常用于按标题分割文档、按分隔符分割日志等场景。

mixed = ["# 标题", "内容1", "内容2", "# 章节", "内容3", "# 结束"]
sections = mixed.slice_after(/^#/).to_a

sections.each_with_index do |section, i|
  puts "段#{i}: #{section.inspect}"
end
# 段0: ["# 标题", "内容1", "内容2"]
# 段1: ["# 章节", "内容3"]
# 段2: ["# 结束"]

类似的还有 slice_before(在匹配之前切开)和 slice_when(条件变化时切开)。这三个方法覆盖了大多数基于标记的分组需求。

grep_v:反向匹配

grep_vgrep 的反面,返回不匹配模式的元素。grep=== 运算符测试匹配,grep_v 取反。

words = %w[hello world ruby programming rust c golang python]
long_words = words.grep_v(/^. {0,5}$/)
puts long_words.join(", ")
# ruby, programming, golang, python

emails = %w[user@example.com admin@test.org not_an_email root@localhost]
invalid = emails.grep_v(/@.*\./)
puts "无效邮件: #{invalid.join(', ')}"
# not_an_email

grepgrep_v 支持正则表达式、类、范围等任何实现了 === 的对象,比 select { |x| x =~ pattern } 更简洁。

Enumerable 方法全景图

只要实现了 each,你就自动获得以下方法:

过滤和筛选: select/filterrejectgrepgrep_vtaketake_whiledropdrop_whilefirstcompact

聚合和统计: reduce/injectcountmin/max/minmaxmin_by/max_by/minmax_bysum

分组和排序: sort/sort_bygroup_bychunkchunk_whileslice_afterslice_beforeslice_whenpartition

遍历和操作: map/collectflat_map/collect_concateach_conseach_sliceeach_with_indexeach_with_objectzip

查询: any?all?none?one?include?find/detectfind_index

转换: to_ato_htallycycleentries/to_enum

惰性: lazyeager

完整的 Enumerable 提供约 50 个方法。掌握它们意味着你不再需要手动编写大量的 for 循环和 if 条件。用声明式的方式描述"我要什么",而不是"怎么得到"。

本章要点

  • include Enumerable 只需实现 each,获得 50+ 集合方法
  • lazy 实现惰性求值,可以安全操作无限序列
  • minmax_by 一次遍历同时找到最小和最大值
  • chunk_while 根据相邻元素关系分组
  • slice_after 按标记将序列分段
  • grep/grep_v 使用 === 进行正向/反向匹配
  • tally 统计元素频次,to_h 将键值对转为 Hash
  • Enumerable 将命令式循环转换为声明式表达式,代码更短、更安全
  • 运行 hello advance enumerable 查看完整示例