Enumerator#with_indexの落とし穴

[ruby-list:45744] 無限ループの回数の取得にてKernel#loopによる無限ループでループの回数を知りたいという質問があった。

で、俺はイテレータをwith_index化する魔法のメソッドであるEnumerator#with_indexを使えばいいじゃんと思って試してみたらエラー。ありゃ、Kernel#loopってブロック必須なのか。ブロックなしでEnumerator返すんだと思ってたよ。

loop.with_index do |i|
end
# ~> -:1:in `loop': no block given (LocalJumpError)
# ~> 	from -:1

[ruby-list:45747] Re: 無限ループの回数の取得でEnumerator返せよと提案したし、まつもとさんも賛成していた

しかし、これは単純な話ではなくて、なかださんから[ruby-list:45751] Re: 無限ループの回数の取得にてEnumerator#with_indexの仕様が「yieldされたものの最後にindexを追加するというものではなく、yieldされたものとindexの二つをyieldする」というものだと知らされた。ということは、ブロックを取らないイテレータにwith_indexを渡すと、nilが入り込むってことだよな。

じゃあEnumeratorを返すKernel#loopを作成して試してみようっと。本家Kernel#loopはStopIterationとかをサポートしているのでlesser_loopというメソッド名で。
ちなみに「return to_enum(メソッド名) unless block」はブロック無しイテレータでEnumeratorを返すおまじないだ。

def lesser_loop(&block)
  return to_enum(:lesser_loop) unless block
  block.call while true
end

i = 0
lesser_loop do
  i                             # => 0, 1, 2, 3, 4, 5
  break if i >= 5
  i += 1
end

このKernel#lesser_loopでwith_indexをつけてみると、

lesser_loop.with_index do |i| # !> shadowing outer local variable - i
  i                             # => nil
  break if i >= 5
end
# ~> -:20:in `block in <main>': undefined method `>=' for nil:NilClass (NoMethodError)
# ~> 	from -:4:in `call'
# ~> 	from -:4:in `lesser_loop'
# ~> 	from -:18:in `with_index'
# ~> 	from -:18:in `<main>'

ありゃ、エラーなるわ。

lesser_loop.with_index do |_,i| # !> shadowing outer local variable - i
  i                             # => 0, 1, 2, 3, 4, 5
  break if i >= 5
end

たしかにnilが乱入してるよ。これはキモい><

Enumerable#mapやEnumerable#find等ブロックパラメータを取るメソッドと併用するのが普通だから気付かんかった。落とし穴だね。

ブロックパラメータを取らないイテレータの場合は特別にindexのみを取るように仕様変更したら、どうなんだろう…ってブロックパラメータを取らないメソッドにwith_indexを適用している人いる??


おいおい、bladeのタイトルがはてなじゃ化けるぞ。さっさと直してくれよぉ。