前の記事に対する回答がどこからも得られなかったので(当り前だ)、記事を書き直して自分で回答・解説してみます(泣)

Rubyのバグだと思ったら自分がバグだった ─ Enumerator編 ─



一見問題無さそうな以下のコードにはバグがあります。

def step(init, step=1)
  Enumerator.new do |y|
    loop { y << init; init += step }
  end
end

odd = step(1, 2)

odd.next # => 1
odd.next # => 3
odd.next # => 5

これはNumeric#stepを使った以下のコードと同じ挙動を期待したものですが、実は正しく動作しないんです。

odd = 1.step(Float::MAX.to_i, 2)

odd.next # => 1
odd.next # => 3
odd.next # => 5

バグがどこだか分かりますか?

お時間のある方はちょっと考えてみてくださいね。下に解説を書いておきます。もしかしたら、こんなことは常識なのかもしれませんけど。





















─ 解説 ─

まず、先のstepメソッドでrewindすると、バグがあることがわかります。

def step(init, step=1)
  Enumerator.new do |y|
    loop { y << init; init += step }
  end
end

odd = step(1, 2)

odd.next # => 1
odd.next # => 3
odd.next # => 5
odd.rewind
odd.next # => 5
odd.next # => 7

そう、rewindしないんです。今度はtakeしてみます。

odd = step(1, 2)

odd.next # => 1
odd.next # => 3
odd.next # => 5
odd.take(3) # => [5, 7, 9]
odd.next # => 11
odd.next # => 13

takeの開始位置がオブジェクトの先頭ではなく現在のカーソル位置から始まり、次のnextがtakeの次の値になっています。本来は次のようにならなければいけません。

odd = step(1, 2)

odd.next # => 1
odd.next # => 3
odd.next # => 5
odd.take(3) # => [1, 3, 5]
odd.next # => 7
odd.next # => 9

このバグはinitの値をEnumeratorのブロック内で一旦受けてループに渡すことで解消できます。

def step(init, step=1)
  Enumerator.new do |y|
    current = init
    loop { y << current; current += step }
  end
end

odd = step(1, 2)

odd.next # => 1
odd.next # => 3
odd.next # => 5
odd.take(3) # => [1, 3, 5]
odd.next # => 7
odd.next # => 9
odd.rewind
odd.next # => 1
odd.next # => 3

このバグは、rewindやtake(eachに依存するメソッド)を呼ぶと、Enumerator生成時に渡したブロックがその度にcallされることにより起きます。つまりrewindやtakeしたときに、ブロック変数yで参照されるyielderオブジェクトには、最初に渡された初期値ではなく、イテレーションの最後の値が与えられてしまうのです。

Enumeratorの実装

Enumeratorの外部イテレータとしての機能は、Fiberで実装されているそうです。自分はCが読めないのでRubiniusの実装を参考に、簡易版Enumerator(Enu)を書いてみました。

rubinius/kernel/common/enumerator.rb at master · rubinius/rubinius

class Enu
  include Enumerable
  def initialize(obj=nil, &blk)
    @obj = obj || Generator.new(&blk)
    reset
  end

  def each(&blk)
    @obj.each(&blk)
  end
  
  def reset
    @fiber = Fiber.new do
      @obj.each { |e| Fiber.yield(e) }
      raise StopIteration, "iteration has ended"
    end
  end

  def next
    @fiber.resume
  end

  def rewind
    reset
  end

  class Yielder
    def initialize(&blk)
      @proc = blk
    end
    
    def yield(*args)
      @proc[*args]
    end
    alias :<< :yield
  end

  class Generator
    def initialize(&blk)
      @proc = blk
    end

    def each(&blk)
      @proc[Yielder.new(&blk)]
    end
  end
end

if __FILE__ == $0
  def step(init, step=1)
    Enu.new do |y|
      current = init
      loop { y << current; current += step }
    end
  end

  odd = step(1, 2)

  odd.next # => 1
  odd.next # => 3
  odd.next # => 5
  odd.take(3) # => [1, 3, 5]
  odd.next # => 7
  odd.next # => 9
  odd.rewind
  odd.next # => 1
  odd.next # => 3
end

ブロックの呼び出し関係がちょっと複雑ですが、Enumerator.newにブロックが渡されたときには、そのブロック引数yにyielderオブジェクトをセットし、ブロック内のYielder#<<でFiber.yield(Fiberを生成するEnu#reset内で実装)が呼ばれるようになります。そしてrewindしたときはresetが呼ばれその中でGenerator#eachが起動されます。またtakeしたときはEnu#eachを介してやはりGenerator#eachが起動されます。Generator#eachはyielderを引数としてEnuに渡したブロックをcallするのです。

何いってるか分かりますかね?説明がアレですね。コードを追ったほうが早いかもしれません。

ちょっとハマったので、記事にしてみました。



blog comments powered by Disqus
ruby_pack8

100円〜で好評発売中!
M'ELBORNE BOOKS