Ruby [译] Ruby 2.0 Works Hard So You Can Be Lazy

shangrenzhidao · 2015年01月08日 · 最后由 spike76 回复于 2018年01月05日 · 4335 次阅读

原文地址:click

Ruby 2.0 的新特征 --- lazy enumerator 看起开很神奇。它可以让你遍历无穷多的一组值,然后拿出你想要的。至少在枚举这方面,它把函数式编程的惰性求值的概念引入 Ruby 中。

比如在 1.9 或更早期的 Ruby 版本中,你将会走进一个无穷的循环来遍历整个无穷的 range:

# code01
range = 1..Float::INFINITY
p range.collect { |x| x*x }.first(10)

=> endless loop!

code01 中 调用 collect 开始了一个无尽的循环,后面的 first 方法永远不会被执行。但是如果升级到了 Ruby 2.0 使用 Enumerator#lazy 方法,你就可以避免这种无穷循环的情况,得到你需要的值:

# code02
range = 1..Float::INFINITY
p range.lazy.collect { |x| x*x }.first(10)

=> [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

那么 整个惰性求值是如何工作的呢?Ruby 又如何知道我只需要 10 个值,在 code02 中我仅仅调用了一个 lazy, 就完成了我的目的。

好像魔法一样,但其实你调用 lazy 的时候 Ruby 内部做了相当复杂的工作。为了给你你所需要的值,Ruby 自动创建了使用了许多不同类型的 Ruby 内部对象。如同生产车间的重装备,这些对象在一起协作用正确的方法处理从无限 range 中输入进来的值。这些对象是什么,它们做了什么,又是如何配合的?我们来探究一下。

The Enumerable module: 许多不同调用 each 的方法

当我在调用 collect 的时候,正在使用 Enumerable module. 你可能知道,这个 module 包含一系列的方法,如:select, detect, any? 等许多方法,这些方法以不同的方式处理值。 在内部,所有的这些方法都是在目标对象或者接收者上调用 each 来工作的:

你可以把 Enumerable 方法当做一系列不同类型的机器来一不同的方式操作数据,所有都通过 each 方法。

Enumerable 是急切的 (这里翻译的不好,想不出更好的词了)

许多 Enumerable 的方法,包括 collect, 返回数组。由于 Array 也包含 ( Mixin ) 了 Enumerable module 并且响应 each 方法,你可以把不同的 Enumerable 的方法链到一起:

在我上面的代码中,Enumerable#first 方法在 collect 方法的产生的结果上调用了 each, 数组又是在 range 上调用了 each 生成的。

一个重要的细节需要注意:Enumerable#collect 和 first 都是急切的,就是说它们会在新的数组返回前处理 each 返回的所有的值。在我例子中,第一个 collect 处理 range 返回的所有的值并保存到 第一个 Array 中. 再看 step 2 部分,first 处理 第一个数组 (图中间的 Array) 所有的值,放入到第二个数组 (图右):

这就是真正导致无穷 range 的没有结尾的循环原因。因为 Range#each 一直会返回数值,那么 Enumerable#collect 就不能完成,Enumerable#first 就不会有机会被执行。

The Enumerator object: 推迟列举

有一个有趣的技巧:调用 Enumerable module 的方法不提供 block, 例如,假设我 在 range 上调用 collect 方法,但不提供 block:

# code03
range = 1..10
enum = range.collect
p enum
=> #<Enumerator: 1..10:collect>

Ruby 准备了一个对象 可以供你后来使用,它可以列举整个 range, 叫做 "Enumerater". code03 从打印的内窥字符串中可以看出,Ruby 保存一个引用,这个引用中内容是接收者 (1..10) 和 enumerable 方法的名字,本例中就是 collect, 被保存到了 enumerator 对象中。

以后我想要迭代 range 并且把里面的值放到数组中,只需要在这个 enumerator 上调用 each:

# code04
p enum.each { |x| x * x }
=> [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

还有一些别的方法使用 enumerators, 比如反复使用 next, 今天就不讨论了。

Enumerator::Generator - 为枚举生成新的值

在前面的例子我用 Range 对象生成了一组值。然而,Enumerator 类使用 block 提供了更加灵活的方式来生成值。看下面的例子:

# code05
enum = Enumerator.new  do |y|
  y.yield 1
  y.yield 2
end

inspect 看看这到底是什么样的 enumerator:

# code06
p enum

=> #:each>


>不难看出, Ruby 创建了一个新的 enumerator 对象, 它包含一个内部对象(Enumerator::Generator) 的引用, 而且在该对象上设置了each 方法的调用. 从内部看, generator 对象将我提供的 block 转换为一个 proc 对象然后保存起来:

 ![](https://l.ruby-china.com/photo/2015/5106261439ba5c40dd0b9aeada98aab3.png)

> 当我使用 Enumerator 对象时, Ruby 将会调用这个 generator 内部的 proc, 为 enumeration 获取值:

```ruby
# code07
p enum.collect { |x| x*x }

=> [1, 4]

换句话说,对于迭代来说,Enumerator::Generator 对象才是数据的源头,它产生值并且将这些值传递。

Enumerator::Yielder - 允许一个 block 去 yield 另外一个

仔细观察上面的代码,有些奇怪,我起初用 block 创建了 Enumerator 对象:

enum = Enumerator.new do |y|
  y.yield 1
  y.yield 2
end

yield 产生的值给了我调用 each(collect) 提供的 block:

p enum.collect { |x| x*x }

=> [1, 4]

换言之,enumerator 以某种方式允许你从一个 block 向另外一个传递值:

当然 Ruby 并不是这么做的。block 之间是不能像这样互相传值的。其实的技巧好是 使用了一个内部对象 - Enumerator::Yielder, 使用 y 参数传入 block 中:

enum = Enumerator.new do |y|
  y.yield 1
  y.yield 2
end

这里 y 参数很容易漏掉,但是如果你再次读一下上面的 block 代码,事实上,我没有我没有使用 yield 产生并且传递值,我只是在 y 对象上调用了 yield 方法,y 对象是 Ruby 内建类 Enumerator::Yielder 的实例。看下面的实验:(irb)

$irb
y = Enumerator::Yielder.new { |str| puts str  }
 => #<Enumerator::Yielder:0x007fbf0a282550>

y.yield "test"
test
=> nil

** 这里我 (译者) 补充一下,我觉得在 Enumerator.new 中把 inpsect 一下 y 会更能说明问题 **

$irb
enum = Enumerator.new do |y| 
  p y
end
=> #<Enumerator: #<Enumerator::Generator:0x007fbf0a2de580>:each>

enum.each {}
=> #<Enumerator::Yielder:0x007fbf0a1ab8e8>

也就是说,y 对象就是 Enumerator::Yielder 的实例,block 中只不过是个简单的方法调用而已,不涉及传值与否的问题

yielder 拿到我想要 enumerator 产生的值,通过 yield 方法,将这些值传递给目标 block. 作为 Ruby 开发者,除了 yield 方法我一般不会去与 yielder 和 generator 进行交互,在内部它们被 enumerator 使用。在 enumerator 调用 each 时,它会使用这两个对象生成 和传递值:

Enumerators 产生数据,Enumerable 方法使用数据

回顾一下,目前我在 Ruby 看到的 enumeration 大体上模式是这样的:

*** Enumerator 对象产生数据 *** Enumerable 使用数据 (消耗数据)

从右至左,enumerable 方法调用来请求数据; 从左至右,enumerator 对象通过 把数据 yield 给 block 来提供数据。

Enumerator::Lazy - 把所有的放到一起

Ruby 2.0 使用 Enumerator::Lazy 对象实现了惰性求值。特别之处在于,它扮演了 2 种角色:enumerator 和 enumerable. 它调用 each 从 enumeration 数据源中获取数据,然后 yield 数据给剩下的数据源:

既然 Enumerator::Lazy 承担了这两个任务,那么就可以把这些方法链到一起产生一个单独的 enumeration. 所以下面的代码就说得通了:

range = 1..Float::INFINITY
p range.lazy.collect { |x| x*x }.first(10)
=> [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

调用 lazy 产生了一个 Enumerator::Lazy 对象。在第一个对象上调用 first 时,Enumerator::Lazy#collect 返回第二个对象:

可以看到 第二个 Enumerator::Lazy (被 Enumerator::Lazy#collect 创建) 也调用了 x*x 这个 block.

Enumerator#Lazy 到底时怎么做到的?服务于数据生产者和数据消费者,Enumerator::Lazy 以一种特殊的方式来使用 generator 和 yielder. generator 首先调用 each 获取数据,然后把每个值传入到一个特别的 block 中:

深入看一下上面的示意图的 block, 这个 block 实现了 Enumerator::Lazy#collect 方法.(其他 lazy 方法 使用了不同的 block). Ruby 内部是用 C 实现的,下面是 Ruby 来模拟:

do |y, value|
  result = yield value
  y.yield result
end

这段代码中,block 接收两个参数 --- yielder(y) 和一个 value, 然后它把 value 传给了另外一个 block, 实际上就是我例子中的 x*x. 然后 Enumerator::Lazy#collect block 调用 yielder, 把结果传给 enumeration 剩余的部分。

这是 ruby 中惰性求值的关键,数据源的每个值都会传递到我的 block 中,然后结果马上会传给我的 enumeration 链中剩下的部分。这个 enumeration 并不着急去遍历全部,Enumerator::Lazy#collect 不会把值放入 array, 而是,通过重复的调用 yield, 每次在#Enumerator::Lazy 对象的调用链上传递一个值。如果我把一系列的 Enumerator::Lazy#collect( 或者其他 lazy 方法 ) 链到一起,每个值会通过链从我的一个 block 到下一个,每次一个值:

** 需要强调的是每次传递一个值,看下面我个人做的实验 **

(1..10).lazy.collect { |x| print x, "B "  }.collect { |x| print x , "A " }.first 10

 # 1B A 2B A 3B A 4B A 5B A 6B A 7B A 8B A 9B A 10B A

惰性求值:在后面执行代码

为什么这个调用链是惰性的?为什么可以让 ruby 避免了无穷的循环,只给我需要的值?答案是 enumeration 链的最后的方法,我例子中的 first(10), 控制了这个迭代可以允许多长:

range = 1..Float::INFINITY
p range.lazy.collect { |x| x*x }.first(10)

=> [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

在 Enumerable#first 接受足够的 value 之后,我的例子是 10, 它会用抛异常的方式停止迭代。

换言之,调用链最右边的代码,控制了执行流,Enumerable#first 通过在 lazy enumerator 上调用 each 开始迭代,获取足够的值以后抛出异常停止迭代。

==============================================================

有不恰当或者理解错误的地方,希望朋友们指正。:D

图挂了好多

#1 楼 @42thcoder 不好意思啊,我修改一下

看到多图必须先顶一下

很好的文章,支持,翻译的也很好

#4 楼 @rockliu 第一次翻译技术文章,发现很多词汇 还是 推敲不好,才疏学浅,还得继续锻炼,不当之处请多多指教。

先谢谢楼主的翻译,之前正在看英文版,结果一搜另一个问题就找到中文版😆 。 然后给楼主提个建议:“Enumerable 是急切的 (这里翻译的不好,想不出更好的词了)”。 建议楼主使用“贪婪”这个词,正则表达式中的“贪婪匹配”,rails 中的“贪婪加载”,对应的英文也是 eager

需要 登录 后方可回复, 如果你还没有账号请 注册新账号