書いた人:原 悠
本記事では、Ruby 2.0 で導入された Enumerable#lazy というメソッドおよび、その返り値である Enumerator::Lazy について解説します。1
関連チケット:
Enumerable#lazy は「map や select などの lazy 版を提供するためのメソッドである」というのが一般的な説明です。が、「map や select などの lazy 版を提供する__ための名前空間を提供するメソッド__」というのがより正確な説明になります。人に聞かれたらこちらの答え方をすることで、Ruby 上級者として一目置かれることができるでしょう。
以下ではまず「map、select の lazy 版とは何か?」について説明し、そのあと「名前空間を提供するとはどういう意味か」について解説します。
Ruby の map メソッドは、配列などの Enumerable なオブジェクトに対して、各要素にブロックを適用した結果を返すメソッドです。
map はとても便利なメソッドですが、一つ弱点がありました。それは、無限リストを map しようとすると、無限の長さの配列を作ろうとしていつまでも結果が帰ってこないということです。
「そりゃそうだ」と思われたかもしれません。無限リストを map しようと思ったことなんて無いって? ところが、関数型言語、特に Haskell のような遅延評価が基本の言語では、無限リストを map するのはごく一般的なプログラミングスタイルです。Ruby においても、後述するようなケースで map の遅延評価版は役に立つことがあります。
では、もし無限リストを map できるようなメソッド(名前を lazy_map としましょう)があったとしたら、どのような動作をするのでしょうか。
Ruby には Enumerator という、遅延評価を行うためのクラスがあるので、lazy_map はそれを返せばいいはずです。Enumerator は「オブジェクトの列」を表すクラスで、Enumerator.new にブロックを渡すことで生成できます。 ブロックには yielder という特殊なオブジェクトが渡され、これに << メソッドで値を渡すことで、列の要素であるオブジェクトを定義します。
例として、Enumerator を使った FizzBuzz プログラムを書いてみました。
変数 fizzbuzz には Enumerator オブジェクトが入っていて、each メソッドによって FizzBuzz な文字列を “1” から順に取り出すことができます。
Enumerator は Enumerable を include しているため、map、select、take など Enumerable のメソッドがすべて使えます。上のプログラムだと出力が無限に実行されてしまうので、最初の 100 行だけ表示するようにしましょう。
Enumerator は上記のようにして生成できますが、Ruby の組み込みメソッドにも返り値として Enumerator を返すものがあります。each や each_line、each_byte など、each がつくメソッドはだいたい、ブロックを省略するとEnumerable が返るようになっています。
例として、ファイルの最初の 10 行を表示するプログラムを書いてみました。
IO#each_line はブロックを省略すると、ファイルの各行を順に yield するような Enumerator を返します。Enumerator に対しては Enumerable のメソッドが使えるので、first メソッドで最初の 10 個を取り出すことができます。
ここで大切なことが3つあります:
では map メソッドの話に戻りましょう。map が配列を返すと困るケースがある、という話でした。例として、ファイルから「特定の条件を満たす最初の 10 行」を表示するプログラムを考えてみます。これは、map メソッドと select メソッドを使ってこんな風に書けば良いでしょう。
ところがこのプログラムは map メソッドを使っているため、log.txt が巨大な場合にファイル全体を読み込んで配列にしてしまいます。せっかく each_line がファイルの読み込みを遅延してくれているのに、です。
そこで、map や select に似ているけど、配列ではなく Enumerator を返す lazy_map、lazy_select というメソッドがあると考えてみましょう。すると、こんなプログラムが書けるようになります。
上とほぼ同じですが、lazy_map は「加工後の各行」を順に yield するような Enumerator を返し、ファイルの読み込みを遅延します。lazy_select も同様です。こうであれば、ファイルの読み込みは必要な 10 行目のところまでで済み、メモリを使い尽くすことなくプログラムが終了します。
このように嬉しいケースのある lazy 版の map メソッドですが、問題となったのはメソッド名でした。Enumerable モジュールには、lazy 版がほしくなるようなメソッドが map、select、reject、drop、… とたくさんあります。これら全てについて「lazy_map」や「lazy_select」などのメソッドを追加していくと、Enumerable モジュールのメソッドが一気に増えてしまいます。
そこで筆者がふと思いついたのが、(多少トリッキーですが) .lazy を付けるとモードが切り替わるという API でした。まず、Enumerable モジュールに lazy というメソッドを追加して、これを呼ぶと Enumerator::Lazy という特殊なクラスのインスタンスを返すようにします。Enumerator::Lazy は Enumerator とほぼ同じですが、「map」や「select」など一部のメソッドのみ、lazy 版の動作をするように上書きされています。
こうすることによって、上のプログラムは、Ruby 2.0 では以下のように書けるようになりました。
Enumerable#lazy と Enumerator::Lazy によって、lazy_map や lazy_select など大量の名前を追加するのではなく、「lazy」というメソッド名をひとつ追加するだけで lazy 版の map や select などが使えるようになっています。これが、冒頭で書いた「名前空間を提供する」という文の意味です。
Enumerable#lazy を使うと、いままで map でできなかった「無限に続く列」や「巨大な列」、そして「終わりの分からない列」を対象として、map や select などの慣れ親しんだインターフェイスで操作できるようになります。「終わりの分からない列」というのは、例えばネットワーク越しにストリーミングされてくるデータです。例えば Twitter の public timeline をストリーミングするようなメソッドがあったとしたら、それらのツイートの一部を取り出して表示するプログラムは以下のように書けます。
もしも lazy 版の map・select がなかったとしたら、このプログラムは以下のように直さなければなりません。
(ここまで書いといて何ですが) こっちの方が分かりやすい、という人もいるかも知れません。短いプログラムなら実際そうかも知れません。しかし、lazy を利用したプログラムが「列を加工する (map) 」「列を検索する (select) 」など、Enumerable の列を操作する API を有効利用しているのに対し、下のプログラムはそれらを再実装しています。
もし TwitterPublicTimeline.each が、ツイートを 1 つずつ渡すのではなく、一度で取得できたツイートを何個かまとめて渡すような API であったとしましょう。lazy 版のプログラムであれば、.map を .flat_map に置き換えるだけで、API の変更に対応できます。
lazy を使わない場合は……どうするんでしょうね。ループを二重にするのかな? 何にしろ、簡単に行かないことは確かです。ストリーミングデータのようなちょっと特殊な「列」を map や select で操作するプログラミングスタイルは、人によっては馴染みがないかも知れませんが、挑戦してみると新しい世界が見えてきますよ。
Ruby 2.0 で導入されたメソッド Enumerable#lazy は、map や select メソッドなどの lazy 版を、「.lazy.map」のような形で提供します。lazy 版 map は配列ではなく Enumerator::Lazy を返すため、「無限に続く列」「巨大な列」「終わりの分からない列」などに対しても、map や select などの統一されたインターフェイスで操作を行うことができます。
原 悠 (株式会社ネットワーク応用通信研究所)。松江市在住 5 年目。もうすぐ 6 年目に入る。最近のマイブームは Minecraft。
導入されたのは 「Enumerable#lazyとEnumerator::Lazy」であって、「Enumerable::Lazy」は誤記なので注意してください ↩