Rubyのlazy Enumeratorで巨大ファイルを効率的に操作する方法
Rubyが強力で柔軟な言語とされる理由の中心には、Enumerator(列挙子)の存在があります。さらに「lazy(遅延)」Enumeratorを使えば、非常に大きなコレクションでも効率的に処理できるようになります。
実は、ファイルとは「行」や「文字」の大きなコレクションにすぎません。つまり、lazy Enumeratorを使えば、ファイルに対して非常に興味深く強力な操作が可能になるのです。
そもそもEnumeratorとは?
eachのようなメソッドを使うたびに、実はEnumeratorが生成されています。[1,2,3].map { ... }.reduce { ... }のようにメソッドをチェーンできるのはこのためです。以下の例を見てください。eachを呼び出すとEnumeratorが返され、それを使って他の繰り返し処理を行えます。
# このコードはRuby公式ドキュメントから引用
# https://ruby-doc.org/core-2.2.0/Enumerator.html
enumerator = %w(one two three).each
puts enumerator.class # => Enumerator
enumerator.each_with_object("foo") do |item, obj|
puts "#{obj}: #{item}"
end
# foo: one
# foo: two
# foo: three
lazy Enumeratorは巨大コレクションのための機能
通常のEnumeratorには、巨大なコレクションを扱う際に問題があります。呼び出した各メソッドがコレクション全体を走査しようとするためです。次のコードを実行すると、その様子を確認できます。
# このコードは処理が終わらずハングするので、ctrl-cで終了してください
(1..Float::INFINITY).reject { |i| i.odd? }.map { |i| i*i }.first(5)
rejectメソッドは永遠に終わりません。無限のコレクションの走査は決して完了しないからです。
しかし、ほんの少しの変更でコードは完璧に動作します。単純にlazyメソッドを呼び出すだけで、Rubyは賢く振る舞い、計算に必要な分だけの繰り返し処理を行います。この場合必要なのはわずか10回程度。無限よりずっと小さい数字ですね。
(1..Float::INFINITY).lazy.reject { |i| i.odd? }.map { |i| i*i }.first(5)
#=> [4, 16, 36, 64, 100]
『白鯨』6,000部分のテキストで実験
これらのファイル操作テクニックを試すには、大きなファイルが必要です。「lazyにしなかった場合の失敗」が一目瞭然になるほど大きなファイルです。
Project Gutenbergから『白鯨(Moby Dick)』をダウンロードし、100部分を含むテキストファイルを作成しました。しかし、まだ十分な大きさではありませんでした。そこで約6,000部まで増量しています。おそらく今のところ、『白鯨』6,000部分のテキストファイルを持っているのは世界で私だけでしょう。なんとも謙虚になる出来事です。話が逸れました。

『白鯨』をダウンロードして数千回複製し、実験用の大きなファイルを作成しました。シンタックスはbashではなくfishシェルです。今でもfishを使っているのは私くらいかもしれません。
ファイルからEnumeratorを取得する方法
ここで紹介するのは、知らないうちに使ったことのあるかもしれない、クールなRubyのトリックです。Rubyでは、コレクションを走査するほとんどのメソッドは、ブロックを渡さずに呼び出すとEnumeratorオブジェクトを返します。これはどういう意味でしょうか?
次の例を見てください。ファイルを開き、each_lineで各行を出力できます。しかし、ブロックなしで呼び出すと、Enumeratorが返されます。注目すべきメソッドはeach_line、each_char、each_codepointの3つです。
File.open("moby.txt") do |f|
# ファイル内の各行を出力
f.each_line do |l|
puts l
end
# こちらも各行を出力するが、`each_line`が返した
# Enumeratorに対して`each`を呼び出して実行している
f.each_line.each do |l|
puts l
end
end
この2つの例はほぼ同じに見えますが、2番目の書き方こそが驚異的なパワーへの鍵となります。
ファイルのEnumeratorを活用する
ファイル内の全行を「含む」Enumeratorを手に入れれば、あとは通常のRuby配列と同じように行を自在に加工できます。いくつか例を挙げましょう。
file.each_line.each_with_index.map { |line, i| "Line #{ i }: #{ line }" }[3, 10]
file.each_line.select { |line| line.size == 9 }.first(10)
file.each_line.reject { |line| line.match /whale/i }
とても便利ですが、これらの例には共通の大きな問題があります。どれも繰り返し処理の前にファイル全体をメモリに読み込んでしまうのです。『白鯨』6,000部分のファイルとなると、その遅延は歴然です。
ファイルの行を遅延読み込みする
巨大なテキストファイルから「whale」という単語の最初の10件を探すなら、10件目以降を探し続ける必要はありません。幸い、RubyのEnumeratorにそう指示するのは非常に簡単です。lazyキーワードを使うだけです。
以下の例では、遅延読み込みを活用して、かなり高度な処理を実現しています。
File.open("moby.txt") do |f|
# "whale"を含む最初の3行を取得
f.each_line.lazy.select { |line| line.match(/whale/i) }.first(3)
# ファイルの先頭に戻る
f.rewind
# 最初の3行に行番号を付けて取得
f.each_line.lazy.each_with_index.map do |line, i|
"LINE #{ i }: #{ line }"
end.first(3)
f.rewind
# "whale"を含む最初の3行と、それぞれの行番号を取得
f.each_line.lazy.each_with_index.map { |line, i| "LINE #{ i }: #{ line }" }.select { |line| line.match(/whale/i) }.first(3)
end
ファイル以外にも使える
ソケット、パイプ、シリアルポート――これらはすべてRubyではIOクラスとして表現されます。つまり、どれにもeach_line、each_char、each_codepointメソッドが備わっています。したがって、このテクニックはすべてに応用可能です。かなり便利ですね!
魔法ではない
残念ながら、lazy Enumeratorが高速化をもたらすのは、実行したいタスクがファイル全体の読み込みを必要としない場合のみです。たとえば、本の最後のページにしか登場しない単語を探すなら、結局は本全体を読まなければなりません。ただしその場合でも、このアプローチがEnumeratorを使わない方法より遅くなることはありません。
-
Excelファイルが重くなるのはなぜ?考えられる7つの原因と解決策を徹底解説
Excelで作業していると、処理や起動に時間がかかる巨大なファイルに悩まされることがよくあります。開くのに時間がかかるだけでなく、作業中も簡単な操作の実行に時間を要し、最悪の場合はフリーズしてしまうこともあります。Excelファイルが大きくなる原因は複数あります。「なぜ自分のExcelファイルはこんなに重いのか?」と疑問に思っている方のために、主な原因とそれぞれの解決策を詳しく解説します。 Excelファイルが大きくなる7つの原因と解決策 Excelファイルが肥大化する理由はさまざまです。Excelは用途が非常に幅広いため、使われ方によって原因も異なります。ここでは、ファイルサイズを削減する
-
Windows 10でISOファイルを扱う方法|マウントからディスクへの書き込みまで解説
ISOファイルとは、かつて光学メディアに収録されていたデータ一式をそのままアーカイブしたディスクイメージのことです。現在、ソフトウェアの配布にCDやDVDが使われることはめったになくなりましたが、大容量のソフトウェアをダウンロードする際のコンテナ形式としては、今なおISOが広く利用されています。MicrosoftもWindows 10のインストールイメージをISO形式で公開しており、その代表的な例といえるでしょう。 Windowsは、Windows 8の登場以降、ISOファイルへの対応が大きく強化されています。サードパーティ製ソフトウェアを一切使わなくても、ISOファイルをファイルシステムにマ