在 Ruby 中的 Enumerable 模块 ( 上 ) 文章中,我们讲解了 Enumerable
模块的一些基础知识和一些基础的用法。那些未尽事宜,只能留在这个章节来讲解了。
本章节,我们主要介绍以下几个知识点
Enumerator::Generator
类Enumerator::Yielder
类Enumerator::Lazy
类
Enumerator::Generator 类
实例化新枚举器的另一种方法是使用 Enumerator::new
方法
enumerator = Enumerator.new do |yielder| yielder.yield 21 yielder << 42 end p enumerator # => #<Enumerator: #<Enumerator::Generator:0x01>:each> res = enumerator.map { |n| n * 2 } p res # => [42, 84]
上面这个示例中,Enumerator::new
方法返回一个枚举器实例,其中,使用 Enumerator::Generator
实例作为数据源,each
方法则作为默认数据消费者
其实,Ruby 内置的 Enumerator::Generator
类不应该由开发人员直接操作。这是一个内部类,其实例有一个非常特殊的任务
这个任务就是保存传递给 Enumerator::new
方法的块 - 作为 Proc
- 在 Enumerator 类的返回实例中。
然后 Ruby 将执行此 Proc
来构建提供给数据消费者方法的数据源 - 前一个示例中 #map
块的参数 n
需要注意的是,Enumerator::Generator
包含了 Enumerable 模块。因此,它实现了自己的 each
方法
现在,我们把注意力移到 Enumerator::new
块的 yielder 参数上
Enumerator::Yielder 类
yielder
参数是内置的 Enumerator::Yielder
类的一个实例
该类负责使用 Enumerator::Yielder#yield
和 Enumerator::Yielder#<<
方法构建和提供数据源。
每次调用这些方法时,都会动态构建和提供数据源
e = Enumerator.new do |yielder| yielder << 1 yielder << 2 end e.map { |n| n * 2 } # => [2, 4]
让我们一步一步地描述在 e.map
迭代期间发生的事情。
- 第一次迭代期间,yielder 将值
1
作为传递给e.map
方法调用的块的n
参数 - 然后,yielder 产生值 2 作为传递给 e.map 方法调用的块的 n 参数
由此,数据就是这样被创建并提供给 map
枚举的每次迭代
Enumerator::Yielder#yield
和Enumerator::Yielder#<<
方法之间的唯一区别是,前者返回 nil,后者返回 self
Enumerator::Lazy 类 惰性枚举类
当我们链接多个枚举时会发生什么?
(1..100).map {|n| n * 2}.first(10)
上面的示例中,first(10)
枚举将会在 map {|n| n * 2}
枚举返回的集合之前开始枚举
也就是说,总共会发生 ~110
枚举,其中的 10 次用于 first(10)
,剩下的 100
则由 map
方法枚举产生
从表面上看,这太可怕了,因为我们实际想要的就是就是先执行 map
然后再执行 first
,也就是总共 100 迭代
那么如何解决这个性能问题呢?
答案是使用 Enumerator::Lazy
类
(1..100).lazy.map {|n| n * 2}.first(10)
Enumerable#lazy
方法返回 Enumerator::Lazy
类的实例
该类包含 Enumerable
模块中,且重新定义了几乎所有方法
在详细讨论惰性枚举的概念之前,让我们先看看以下基准测试
require 'benchmark/ips' Benchmark.ips do |x| x.config(:time => 5, :warmup => 2) x.report("Enumerations") do (1..100_000).map {|n| n * 2}.first(10) end x.report("Lazy Enumerations") do (1..100_000).lazy.map {|n| n * 2}.first(10) end x.compare! end
输出结果如下
$> ruby benchmark.rb Warming up -------------------------------------- Enumerations 16.000 i/100ms Lazy Enumerations 13.679k i/100ms Calculating ------------------------------------- Enumerations 162.247 (± 1.2%) i/s - 816.000 in 5.030263s Lazy Enumerations 142.999k (± 0.8%) i/s - 724.987k in 5.070187s Comparison: Lazy Enumerations: 142999.2 i/s Enumerations: 162.2 i/s - 881.37x slower
我们可以看到,使用惰性枚举而不是一般的枚举可以大大提高速度
那么,Enumerator::Lazy 如何工作? 又是如何提高运行速度的 ?
(1..100_000).lazy.map {|n| n * 2}.first(10)
Enumerable#lazy
方法返回的 Enumerator::Lazy
实例调用了 Enumerator::Lazy#map
方法。
这种方法 - 几乎是这个类的所有方法 - 以特定的方式起作用。每次迭代都遵循以下执行流程:
- 获取 1..100 数据源的第一个值
1
并将它传递给{| n | n * 2}
块。 - 获取块的返回值,并通过
yielder
将其传递给下一个枚举first(10)
这种机制称为 「 枚举链 」
但是为什么这会让 Ruby 避免遍历整个集合?
因为在惰性枚举中,最终的枚举 - 在我们的例子中是 first(10)
- 负责控制枚举运行的时间。
因此,它足够聪明地说:「 我已经有了足够的数据。所以我们可以停止枚举链 」