Ruby 用 Ruby 的 Enumerator 实现的流

cichol · 2015年06月04日 · 最后由 yanguango 回复于 2015年06月04日 · 2999 次阅读

啃 SICP 到第三章,想试试将无穷流应用在 Ruby 里,顺便研究一下 Enumerator 的用法。

Enumerator 可以直接用下面的代码创建:

e = Enumerator.new do |yielder|
  yielder.yield 1
  yielder.yield 2
  loop do
    yielder.yield 3
  end
end

e.next # => 1
e.next # => 2
e.next # => 3
e.next # => 3

这段代码挺好懂,但也有些诡异。可以看到每次执行 e.next 会要求 enumerator 返回一个值,这个值就是 proc 里的代码 yield 出来的值。因为 proc 里有个死循环,所以这就是一个无穷流,会 yield 出 1,2,3,3,3,3,3……的序列。

每次 yield 完毕之后 proc 的代码执行会暂停,等待下次调用 e.next 之后才会继续执行,这里不涉及返回什么的,就是单纯的控制跳转。

不过这和方法对块参数的 yield 很不一样,一般用 yield 是把控制权从方法移交到块里,块里 yield 又可以移交到哪里呢?

查了一些资料,这种 yield 和 Fiber 的 yield 很相似,但是 Enumerator 本质上是不是用 Fiber 构建的还不清楚。

f = Fiber.new do
  Fiber.yield 1
  Fiber.yield 2
end

f.resume # => 1
f.resume # => 2

Fiber 是一种可控的 Thread,控制权可以通过 yield 和 resume 随意转移,yield 之后 Fiber 就会被冻结,等待 resume 的唤醒。

Enumerator 大概搞明白之后,就开始 Stream 类的构建。

class Stream
  def initialize &p
    @enumerator = Enumerator.new &p
    @evaluated = 0
    @values = []
  end

  def [] n
    if n >= @evaluated
      (@evaluated..n).each do |n|
        @evaluated += 1
        @values[n] = @enumerator.next
      end
    end
    @values[n]
  end

  def first n = 1
    n == 1 ? self[0] : (0..n-1).map{|i| self[i]}
  end

  # y << 等价于 y.yield
  def rest n = 1
    Stream.new do |y|
      loop do
        y << self[n]
        n += 1
      end
    end
  end

  def map &p
    Stream.new do |y|
      n = 0
      loop do
        y << p.call(self[n])
        n += 1
      end
    end
  end

  def accumulate &p
    Stream.new do |y|
      n = 0
      loop do
        y << (0..n).collect{|n| self[n]}.reduce(&p)
        n += 1
      end
    end
  end

end

Enumerator 只是一段不断向前执行的代码,所以要把每次执行的结果保存起来,并提供索引访问的方法,才是一个真正的序列。而且缓存可以避免每次访问,Eumerator 都要从头开始计算。

我把.next 求出来的值存到一个数组里,并保存现在计算到第几项,如果访问已经计算过的项就从 values 数组中返回,否则就进行计算。这就实现了 lazy evaluation(或者说 Enumerator 本身就是 lazy 的)。

之后还要提供一些处理流的方法,比如 map,这些方法也应该返回 lazy 的流,所以方法体都是流的创建。

实际上这里有很多重复的代码,还有频繁地赋值,但是我还没想到该怎么去抽象。

试试按书上的做法去计算圆周率:

# 根据π/4 = 1 - 1/3 + 1/5 - 1/7 + ...

# seq 是 1, -1/3, 1/5, -1/7, ... 的流
seq = Stream.new do |y|
  n = 1
  a = 1
  loop do
    y << a * (1.0 / n)
    n = n + 2
    a = -a
  end
end

#sum是seq n项和的流
sum = seq.accumulate{|s,x| s + x}
#把sum每项扩大4倍就是pi的流
pi = sum.map{|x| x*4}

pi.first(10) # => [4.0, 2.666666666666667, 3.466666666666667, 2.8952380952380956, 3.3396825396825403, 2.9760461760461765, 3.2837384837384844, 3.017071817071818, 3.2523659347188767, 3.0418396189294032]

#但是这个流收敛得很慢,用书上给的欧拉加速收敛的方法变形一遍
def eular_transform s
  Stream.new do |y|
    n = 1
    loop do
      s0, s1, s2 = s[n-1], s[n], s[n+1]
      y << s2 - ((s2 - s1) ** 2) / (s0 + -2 * s1 + s2)
      n += 1
    end
  end
end

(eular_transform pi).first(10) # => [3.166666666666667, 3.1333333333333337, 3.1452380952380956, 3.13968253968254, 3.1427128427128435, 3.1408813408813416, 3.142071817071818, 3.1412548236077655, 3.1418396189294033, 3.141406718496503]

#快了不少,对变形后的流还可以不断地应用变形,实现更快的加速
def accelerate s
  Stream.new do |y|
    loop do
      s = eular_transform s
      y << s.first
    end
  end
end

(accelerate pi).first(10) # => [3.166666666666667, 3.142105263157895, 3.141599357319005, 3.1415927140337785, 3.1415926539752927, 3.1415926535911765, 3.141592653589778, 3.1415926535897953, 3.141592653589795, NaN]

Ruby 实现计算出的结果和 Scheme 是完全一致的,但是 Ruby 的实现总是有点怪,因为 Scheme 里的流本质上是链表,而我做出来的是数组。这样就没办法递归地去创建流,比如下面这样:

;ones是生成1, 1, 1, ...的流,把首项设为1,把下一项指向自身,这样访问下一项就是访问自身的首项

(define ones
  (cons-stream 1 ones))

不过流的本质就是迭代,scheme 是用一个 proc 去迭代,ruby 是用 loop 去迭代,ruby 把 Enumerator 写成一段迭代的代码,还是很酷的。

实际上单纯用 lambda 也是可以实现 Enumerator 的构造的,只是没有 Fiber 那样 magic:

class Enum
  def initialize &p
    @enumerator = p.call
  end

  def next
    @enumerator.call
  end
end

e = Enum.new do
  n = 0
  ->{ n += 1 }
end

p e.next  # => 1

联动: [译] Ruby 2.0 Works Hard So You Can Be Lazy Why do we need fibers

Enumerator 和 Fiber 其实就是 Coroutine

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