Hatena::ブログ(Diary)

まめめも このページをアンテナに追加 RSSフィード

2007-11-29

[] 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:組み込み変数増やすのは時代に逆行してる感もあるけど。

ささだささだ 2007/12/01 13:57 組み込み変数を加えるのはちょっとなぁ.ちなみに,Fiber と同じ表現力を持ってるような気がするけど,何が足りない感じ?

ku-ma-meku-ma-me 2007/12/01 14:15 組み込み変数なのは確かに微妙ですが、1.9 なら結構効率的に実装できそう (妄想?) だし、どうでもよさげな組み込み変数はすでにいっぱいあることだし、需要を考えればアリかなぁと。記号は $# かな。Fiber との表現力云々は [ruby-dev:31798] のスレッドの話です。

nagachikanagachika 2012/05/20 01:08 とても古い記事へのコメントで恐縮です。「Enumerable#zip の引数に渡せる」というのは Enumerator の「機能」として挙げるには少し特別扱いされすぎではないですか? Enumerator が Enumerable を include しているから zip の引数に渡せるというだけで、本文中の例も Enumerator にするまでもなく配列をそのまま渡しても同じ結果が得られます。またリンク先の FizzBuzz の例も Enumerator ではなく配列を渡していると思います。 Enumerable#zip の現在の実装をみると内部で to_enum で Enumerator 化しているので、zip と Enumerator がまったく無関係ではないですけど。
なぜ急にこんなことを書いているかというと、今 Enumerator::Lazy のことを調べていて、その前提として Enumerator の機能を調べようとしてこの記事に辿りついたものの zip についての部分がどういう意図で書かれたのかわからなかったからです。id:ku-ma-me さんが書いていることですし、わたしが何か見落してるか現在だと意味がなくなっているけど当時は違いがあったのかなぁって思っていますが、当時のソースをみてもよくわかりませんでした。

nagachikanagachika 2012/05/20 01:14 あ、ごめんなさい。リンク先の FizzBuzz の件は cycle で Enumerator 化されていました orz。無限リストを表現するために Enumerator を使っているのでした。
無限リストとして使えるというのは Enumerator の機能と言えると思うのですが、やっぱり zip だけ取り上げているのはなんでなのかなーというのは疑問です。

ku-ma-meku-ma-me 2012/05/20 22:53 5 年前に何を思ってこれを書いたか正確に思い出すのは無理ですが、おそらく、Enumerator#next を内部的に呼んでる組み込みメソッドが zip くらいだったから、せっかくなので紹介してみた。というだけじゃないかなあ。確かに「Enumerator の機能」というのには違和感がありますね。

ひょっとしたら、Enumerator がコアに組み込まれた際に追加された機能にフォーカスしているのかも。無限リストとして使えること自体はもともと lib/enumerator.rb が提供していた機能だから言ってないとか。

どっちにしても、そう思って書いたという記憶がはっきりあるわけではないので、想像に過ぎません。すみません。

nagachikanagachika 2012/05/20 23:06 ありがとうございます。そういえば Enumerator 自体は組み込まれる前から添付ライブラリとして存在していたんでしたね。確かに zip の実装を更に遡ってみるともっと前は to_ary で配列に変換できなかったら引数を受け付けていなかったので、Enumerator が組み込みになった時に合わせて対応されたということで、同じイベントとして認識されたのかもしれませんね。
お返事ありがとうございました。

スパム対策のためのダミーです。もし見えても何も入力しないでください
ゲスト


画像認証