Enumerator とブロックの省略

1.9 では Enumerator が組み込みになり、大きく拡張されています。ついでにブロックの省略に対する考え方にも影響があります。結構重大な変更のわりに、この話はあまり議論や周知がされていないような気がしたので、現状の Enumerator について、その機能と問題点をまとめてみました。

Enumerator の機能

まず、each や map など、イテレータっぽいメソッドをブロックなしで呼び出すと Enumerator が得られます。

p [1,2,3].each  #=> #<Enumerable::Enumerator:0xb7d38260>
p [1,2,3].map   #=> #<Enumerable::Enumerator:0xb7d38210>

Object#to_enum または enum_for を使って、指定したメソッドによる Enumerator を明示的に作ることもできます。

p [1,2,3].to_enum(:each)  #=> #<Enumerable::Enumerator:0xb7d51350>

Enumerator には (僕が知る限り) 3 つの機能があるようです。

外部イテレータとして使える

最も大きな機能です。大きな機能だけど、どのくらい使えるのかはよくわかりません。Python 派には受けがいい?

e = [1,2,3].each
# 順に取り出す
p e.next  #=> 1
p e.next  #=> 2
# 巻き戻し
e.rewind
# 順に取り出す
p e.next  #=> 1
p e.next  #=> 2
p e.next  #=> 3
# 全部取り出した後、次を取り出そうとしたら例外
p e.next  #=> iteration reached at end (StopIteration)

これにあわせて、Kernel#loop のブロックの中で例外 StopIteration が発生したら loop を終了することになっています。

p loop { raise StopIteration }  #=> nil

e = [1,2,3].each
loop { p e.next } #=> 1, 2, 3
Enumerable#with_index が使える

最も多くの人が待ち望んでいた機能です。each には each_with_index がありましたが、これが each 以外のイテレータでも使えるようになります。

p ["a","b","c"].map.with_index {|v, i| v + i.to_s }
  #=> ["a0", "b1", "c2"]
p [2,1,0].find.with_index {|v, i| v == i }  #=> 1
Enumerable#zip の引数に渡せる

最も地味な機能です。zip の引数に Enumerable を渡すことができます。

e1 = [4,5,6].each
e2 = [7,8,9].each
[1,2,3].zip(e1, e2) do |a, b, c|
  p [a, b, c]  #=> [1, 2, 3], [4, 5, 6], [7, 8, 9]
end

Enumerable#cycle と合わせるといい感じ? 参考: これを使って Fizzbuzz を書いた例

Enumerator の問題点

「ブロック省略 → Enumerator を返す」の convention が従来と非互換

ブロックの有無は block_given? によってメソッド側で判断できるので、ブロックの有無によってメソッドの挙動を変えることができました。File.open や String#gsub が典型例です。

# ファイルハンドラを明示的に close する
fh = File.open("foo"); puts fh.read; fh.close
# ファイルハンドラを暗示的に close する
File.open("foo") {|fh| puts fh.read }

# b か c か d を * に置き換える
"abcde".gsub(/[bcd]/, "*")  #=> "a***e"
# b か c か d をその大文字に置き換える
"abcde".gsub(/[bcd]/) {|s| s.upcase }  #=> "aBCDe"

つまり、ブロックを省略したときの挙動はそのメソッドの設計者が判断していました。ですが、「イテレータっぽいメソッドはブロック省略時に Enumerator を返す」という作法が発生することで、この決定権が制限されます。また、イテレータっぽいメソッドを自分で定義するとき、この作法に従うためのおまじないを手動で入れる必要があるのも面倒です。

module Enumerable
  # 奇数番目の値だけ列挙する
  def leapfrog
    # おまじない
    return to_enum(:leapfrog) unless block_given?

    f = false
    each {|x| yield x if f = !f }
  end
end

e = [1,2,3,4,5].leapfrog; loop { p e.next }  #=> 1, 3, 5
「ブロック省略 → Enumerator を返す」とすべきメソッドの基準がわからない

Enumerable#grepcount 、all? 、any? 、take_while などは、僕の感覚では十分イテレータっぽいメソッドなんですが、「ブロック省略 → Enumerator を返す」となっていません。つまり [1,2,3].count.with_index {|v, i| ... } とはできません。(追記: count と take_while は対応されました。)これらのメソッドがもともとブロックの省略に意味を与えていたためでしょうか。

p ["a","b","c","b","a"].grep("b")                #=> ["b", "b"]
p ["a","b","c","b","a"].grep("b") {|x| x + "." } #=> ["b.", "b."]

p ["a","b","c","b","a"].count("b")             #=> 2
p ["a","b","c","b","a"].count {|x| x != "b" }  #=> 3

p [true, true, true].all?   #=> true
p [true, false, true].all?  #=> false

でも Enumerable#map や zip もブロック省略に意味を与えていたのですが、なぜかこれらは仕様変更され「ブロック省略 → Enumerator を返す」となっています。よくわかりません。

メソッドチェインするとイテレータの意味が消える
a = [1,2,3]
p a.reject!.map {|x| x == 2 } #=> [false, true, false]
p a                           #=> [1, 2, 3]

reject! の意味がどこにも現れません。じゃあどうなるべきかというと、よくわからないんですけど。なにか well-defined でないような気分になります。

個人的な意見

上の記述も十分個人的な意見の入った書き方になっていますが、一応ちゃんと書きます。
利点の内訳が with_index が 9 割、外部イテレータが 1 割という印象*1ですが、ほとんど with_index のためだけにはちょっと大げさな道具だと思っています。もちろん with_index に相当する機能は欲しいけど、名前長いし、現在のイテレータの回数を返す組み込み変数とかの方が嬉しそうです *2
あと、「ブロック省略 → Enumerator を返す」という convention は従来と非互換なメソッド設計の制約になるので、ちょっとだけ反対です。

*1:外部イテレータが Fiber と同じ表現力を持ってたら 4 割くらいに急上昇だけど。

*2:組み込み変数増やすのは時代に逆行してる感もあるけど。