ruby

External iteration is broken but internal iteration works


The following code, which abuses Enumerator.feed to do something coroutine-ish, works fine:

class Foo
  def initialize
    @enum = Enumerator.new do |yielder|
      @yielder = yielder

      plus_ones = enum_for(:each_plus_one)
      plus_ones.each do |n|
        puts n
      end
    end
    @enum.next
  end

  def <<(n)
    @enum.feed(n)
    @enum.next
  end

  def each_plus_one
    loop do
      n = @yielder.yield
      yield n + 1
    end
  end
end

foo = Foo.new
foo << 1
foo << 2
foo << 3
$ ruby foo.rb
2
3
4

However, if I try to use external iteration instead, I get a confusing error:

class Foo
  def initialize
    @enum = Enumerator.new do |yielder|
      @yielder = yielder

      plus_ones = enum_for(:each_plus_one)
      loop do
        puts plus_ones.next
      rescue StopIteration
        break
      end
    end
    @enum.next
  end

  ...
$ ruby foo.rb
nil
foo.rb:27:in 'block in Foo#each_plus_one': undefined method '+' for nil (NoMethodError)

      yield n + 1
              ^
    from <internal:kernel>:168:in 'Kernel#loop'
    from foo.rb:25:in 'Foo#each_plus_one'
    from foo.rb:in 'Enumerator#each'

Why does this happen?


Solution

  • @yielder.yield seems to call Fiber.yield:

    Enumerator.new do |y|
      @y = y
      loop { y.yield }
    end.next
    @y << 1
    # => 'Enumerator::Yielder#<<': attempt to yield on a not resumed fiber (FiberError)
    
    Fiber.yield
    # => 'Fiber.yield': attempt to yield on a not resumed fiber (FiberError)
    

    That means you can't call @yielder.yield from inside another enumerator's next call, as you won't return back to main fiber but back to that next call.

    The problem is where underlying fibers yield back to:

    # when using `each`
    f = Fiber.new do
      loop do
        p Fiber.yield       # this returns back to resuming fiber (what `@yielder.yield` does)
      end
    end.tap(&:resume)       # here in the main fiber waiting for next resume
    f.resume(10)            #=> 10
    
    # when using `next`
    f = Fiber.new do
      enum = Fiber.new do
        loop do
          Fiber.yield       # this returns back to resuming fiber
        end
      end
      loop do
        p enum.resume       # here in Enumerator fiber (`plus_ones.next`)
        #                   # then loop, bouncing between yield and resume, no waiting/blocking
      end
    end.tap(&:resume)       #=> loops forever
    

    To make it work you can transfer from enum_for fiber to main fiber and feed/next @enum:

    class Foo
      def initialize
        @main_fiber = Fiber.current
    
        @enum = Enumerator.new do |yielder|
          @enumerator_fiber = Fiber.current
          loop do
            p yielder.yield
          end
        end
        @enum.next
    
        @fiber = Fiber.new do
          plus_ones = enum_for(:each_plus_one)
          loop do
            n = plus_ones.next
            @enum.feed n
            @enum.next
          end
        end
        @fiber.transfer # lets you transfer back to main fiber
    
        # # Or like this:
        # @enum = Enumerator.new do |yielder|
        #   plus_ones = enum_for(:each_plus_one)
        #   loop do
        #     yielder << plus_ones.next
        #   end
        # end
        # @fiber = Fiber.new { loop { p @enum.next } }.tap(&:transfer)
      end
    
      def <<(n)
        @enum_for_fiber.transfer n
      end
    
      def each_plus_one
        @enum_for_fiber = Fiber.current
        loop do
          n = @main_fiber.transfer
          yield n + 1
        end
      end
    end
    
    foo = Foo.new
    foo << 1       #=> 2
    foo << 2       #=> 3
    foo << 3       #=> 4