無限リストを map 可能にする Enumerable#lazy

書いた人:原 悠

はじめに

本記事では、Ruby 2.0 で導入された Enumerable#lazy というメソッドおよび、その返り値である Enumerator::Lazy について解説します。*1

関連チケット:

Enumerable#lazy とは

Enumerable#lazy は「map や select などの lazy 版を提供するためのメソッドである」というのが一般的な説明です。が、「map や select などの lazy 版を提供するための名前空間を提供するメソッド」というのがより正確な説明になります。人に聞かれたらこちらの答え方をすることで、Ruby 上級者として一目置かれることができるでしょう。

以下ではまず「map、select の lazy 版とは何か?」について説明し、そのあと「名前空間を提供するとはどういう意味か」について解説します。

map、select の lazy 版とは

Ruby の map メソッドは、配列などの Enumerable なオブジェクトに対して、各要素にブロックを適用した結果を返すメソッドです。

p [1, 2, 3].map{|x| x * 2}
#=> [2, 4, 6]

map はとても便利なメソッドですが、一つ弱点がありました。それは、無限リストを map しようとすると、無限の長さの配列を作ろうとしていつまでも結果が帰ってこないということです。

p (1..Float::INFINITY).map{|x| x * 2}
#=> ...

「そりゃそうだ」と思われたかもしれません。無限リストを map しようと思ったことなんて無いって? ところが、関数型言語、特に Haskell のような遅延評価が基本の言語では、無限リストを map するのはごく一般的なプログラミングスタイルです。Ruby においても、後述するようなケースで map の遅延評価版は役に立つことがあります。

「オブジェクトの列」を表す、Enumerator

では、もし無限リストを map できるようなメソッド(名前を lazy_map としましょう)があったとしたら、どのような動作をするのでしょうか。

Ruby には Enumerator という、遅延評価を行うためのクラスがあるので、lazy_map はそれを返せばいいはずです。Enumerator は「オブジェクトの列」を表すクラスで、Enumerator.new にブロックを渡すことで生成できます。 ブロックには yielder という特殊なオブジェクトが渡され、これに << メソッドで値を渡すことで、列の要素であるオブジェクトを定義します。

例として、Enumerator を使った FizzBuzz プログラムを書いてみました。

fizzbuzz = Enumerator.new{|yielder|
  1.upto(Float::INFINITY) do |n|
    case
    when n % 15 == 0 then yielder << "FizzBuzz"
    when n % 5 == 0 then yielder << "Buzz"
    when n % 3 == 0 then yielder << "Fizz"
    else yielder << n.to_s
    end
  end
}
fizzbuzz.each do |str|
  puts str
end

変数 fizzbuzz には Enumerator オブジェクトが入っていて、each メソッドによって FizzBuzz な文字列を "1" から順に取り出すことができます。

Enumerator は Enumerable を include しているため、map、select、take など Enumerable のメソッドがすべて使えます。上のプログラムだと出力が無限に実行されてしまうので、最初の 100 行だけ表示するようにしましょう。

fizzbuzz.first(100).each do |str|
  puts str
end

身近な Enumerator

Enumerator は上記のようにして生成できますが、Ruby の組み込みメソッドにも返り値として Enumerator を返すものがあります。each や each_line、each_byte など、each がつくメソッドはだいたい、ブロックを省略するとEnumerable が返るようになっています。

例として、ファイルの最初の 10 行を表示するプログラムを書いてみました。

File.open("log.txt") do |f|
  puts f.each_line.first(10)
end

IO#each_line はブロックを省略すると、ファイルの各行を順に yield するような Enumerator を返します。Enumerator に対しては Enumerable のメソッドが使えるので、first メソッドで最初の 10 個を取り出すことができます。

ここで大切なことが3つあります:

1. each_line はファイルを全部読み込まない
IO#each_line は渡されたファイルの行を全て読み込むのではなく、値が必要とされたときに初めて読み込みを行います。このため上のプログラムは、たとえ log.txt のサイズが数 GB だったとしても、最初の 10 行だけを読み込むので高速です。
2. each_line を each_byte に変えるとバイト単位になる
上のプログラムを少し変えて、ファイルの最初の 10 行ではなく、最初の 10 バイトを表示するにはどうすれば良いでしょうか。IO クラスには each_byte という似たようなメソッドがあるため、上の each_line を each_byte に置き換えるだけで目的の動作をするようになります。Enumerator が「Enumerable な、オブジェクトの列」という抽象化を提供しているので、行単位でも、バイト単位でも同じインターフェイスで扱うことができるのです。
3. (余談) 2.0ではIO#linesは非推奨
lazyと直接の関係はないので余談ですが、Ruby 2.0ではStringのlines, chars, bytes, codepointsがEnumeratorではなく配列を返すようになりました(#6670)。それに伴い、IOのlines等は非推奨になっています。lazyを試すときはlinesではなくeach_lineメソッドの方を使用してください。

Enumerator と map

では map メソッドの話に戻りましょう。map が配列を返すと困るケースがある、という話でした。例として、ファイルから「特定の条件を満たす最初の 10 行」を表示するプログラムを考えてみます。これは、map メソッドと select メソッドを使ってこんな風に書けば良いでしょう。

File.open("log.txt") do |f|
  puts f.each_line.map{|line| ...(lineを加工する)...}
  .select{|line| ...(条件にあうlineならtrueを返す)...}
  .first(10)
end

ところがこのプログラムは map メソッドを使っているため、log.txt が巨大な場合にファイル全体を読み込んで配列にしてしまいます。せっかく each_line がファイルの読み込みを遅延してくれているのに、です。

そこで、map や select に似ているけど、配列ではなく Enumerator を返す lazy_map、lazy_select というメソッドがあると考えてみましょう。すると、こんなプログラムが書けるようになります。

File.open(path) do |f|
  puts f.each_line.lazy_map{|line| ... (line を加工する)... }
  .lazy_select{|line| ...(条件にあう line なら true を返す)... }
  .first(10)
end

上とほぼ同じですが、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 では以下のように書けるようになりました。

File.open(path) do |f|
  puts f.each_line.lazy.map{|line| ...(line を加工する)...}
  .select{|line| ...(条件にあう line なら true を返す)...}
  .first(10)
end

Enumerable#lazy と Enumerator::Lazy によって、lazy_map や lazy_select など大量の名前を追加するのではなく、「lazy」というメソッド名をひとつ追加するだけで lazy 版の map や select などが使えるようになっています。これが、冒頭で書いた「名前空間を提供する」という文の意味です。

使用例

Enumerable#lazy を使うと、いままで map でできなかった「無限に続く列」や「巨大な列」、そして「終わりの分からない列」を対象として、map や select などの慣れ親しんだインターフェイスで操作できるようになります。「終わりの分からない列」というのは、例えばネットワーク越しにストリーミングされてくるデータです。例えば Twitter の public timeline をストリーミングするようなメソッドがあったとしたら、それらのツイートの一部を取り出して表示するプログラムは以下のように書けます。

TwitterPublicTimeline.each.lazy.map{|json| ...(json を加工する)...}
.select{|tweet| ...(条件にあうツイートなら true を返す)...}
.each{|tweet| p tweet}

もしも lazy 版の map・select がなかったとしたら、このプログラムは以下のように直さなければなりません。

TwitterPublicTimeline.each do |json|
  tweet = ...(json を加工する)...
  next unless ...(条件)...
  p tweet
end

(ここまで書いといて何ですが) こっちの方が分かりやすい、という人もいるかも知れません。短いプログラムなら実際そうかも知れません。しかし、lazy を利用したプログラムが「列を加工する (map) 」「列を検索する (select) 」など、Enumerable の列を操作する API を有効利用しているのに対し、下のプログラムはそれらを再実装しています。

もし TwitterPublicTimeline.each が、ツイートを 1 つずつ渡すのではなく、一度で取得できたツイートを何個かまとめて渡すような API であったとしましょう。lazy 版のプログラムであれば、.map を .flat_map に置き換えるだけで、API の変更に対応できます。

TwitterPublicTimeline.each.lazy.flat_map{|json| ...(json を加工する)...}
.select{|tweet| ...(条件にあうツイートなら true を返す)...}
.each{|tweet| p tweet}

lazy を使わない場合は……どうするんでしょうね。ループを二重にするのかな? 何にしろ、簡単に行かないことは確かです。ストリーミングデータのようなちょっと特殊な「列」を map や select で操作するプログラミングスタイルは、人によっては馴染みがないかも知れませんが、挑戦してみると新しい世界が見えてきますよ。

まとめ

Ruby 2.0 で導入されたメソッド Enumerable#lazy は、map や select メソッドなどの lazy 版を、「.lazy.map」のような形で提供します。lazy 版 map は配列ではなく Enumerator::Lazy を返すため、「無限に続く列」「巨大な列」「終わりの分からない列」などに対しても、map や select などの統一されたインターフェイスで操作を行うことができます。

著者について

原 悠 (株式会社ネットワーク応用通信研究所)。松江市在住 5 年目。もうすぐ 6 年目に入る。最近のマイブームは Minecraft。


*1 導入されたのは 「Enumerable#lazyとEnumerator::Lazy」であって、「Enumerable::Lazy」は誤記なので注意してください