Enumerator::Lazy
を使うとメモリ効率の良いプログラムが書ける。
けれどあまり実際のコードで見かけないので、実際に使えそうな例を上げたい。
動作確認環境
Ruby 2.4.1
activerecord 5.1.4
sqlite3 1.3.13
まずはよく見るEnumerator::Lazyの例
(1..Float::INFINITY).lazy.select { |n| n % 3 == 0 }.first(5) # => [3, 6, 9, 12, 15]
無限リストから3の倍数の最初の5つを取得するコード。
これだけだと実用性は感じないけれど、 next
を使えば何かのgeneratorとしては使えそう。
def generator
(1..Float::INFINITY).lazy.select { |n| n % 3 == 0 }
end
gen = generator()
5.times do
p gen.next
end
ActiveRecordのfind_eachと組み合わせる例
find_each
はブロックを渡すと each
のように1件ずつループ処理するが、裏では batch_size
件ずつDBからデータを取得してくれる。
(↓の例のDBにデータを用意する部分は http://qiita.com/xkumiyu/items/2ecee242b7e6e6c6d9a1 のコードを使わせて頂きました。)
source 'https://rubygems.org'
gem 'activerecord'
gem 'sqlite3'
require "active_record"
# データベースへの接続
ActiveRecord::Base.establish_connection(
adapter: 'sqlite3',
database: ':memory:'
)
# スキーマの設定
class InitialSchema < ActiveRecord::Migration[5.1]
def self.up
create_table :users do |t|
t.string :name
end
end
def self.down
drop_table :users
end
end
# マイグレーション
InitialSchema.migrate(:up)
class User < ActiveRecord::Base
end
# モデルを生成
20.times do |i|
User.create(name: "user-#{i}")
end
ActiveRecord::Base.logger = Logger.new(STDOUT)
# ↓ここから本題
User.find_each(batch_size: 10) do |user|
p user.name
end
実行すると↓のように10件ごとにDBからデータ取得していることがわかる。
また、
User.find_each(batch_size: 10) do |user|
p user.name
end
の部分を
def users
User.find_each(batch_size: 10)
end
users.each do |user|
p user.name
end
にしても結果は同じになる。
しかし、
def users
User.find_each(batch_size: 10)
end
users.map(&:name).each do |name|
p name
end
にすると↓のように(クエリは10件ずつ取得するものだが)最初にすべて取得してしまう。
これは map(&:name)
の部分で User
インスタンスをその名前に変換する部分では10件ごとのデータ取得を行いなから変換されているが、すべての変換処理が終わってから .each do |name|
でループに入るため。
これではせっかくの find_each
が台無しである。
そこで lazy
を使う。
def users
User.find_each(batch_size: 10).lazy
end
最後に .lazy
を付けただけだが、結果は↓のように最初と同じようになる。
これで users
メソッドを利用する側では特に何も気にせずに map
や select
等の操作が行える。
例えば、
users.map(&:name).each_slice(2) do |names|
p names
end
とすれば、データ取得は10件ごと、処理は2件ごと、という挙動になる。
自前でEnumerator::Lazyを生成する例
.lazy
がいつでも使えれば良いが、そのままでは使えない場合もある。
↓に find_in_batches
を使って find_each(batch_size: 10).lazy
を再実装した例を挙げる。
class Source
def each
User.find_in_batches(batch_size: 10) do |users|
yield users
end
end
end
def users
src = Source.new
Enumerator::Lazy.new(src) do |yielder, users|
users.each { |user| yielder << user }
end
end
users.map(&:name).each do |name|
p name
end
実行結果は↓のように find_each(batch_size: 10).lazy
の場合と同じである。
ここで、 Source#each
の中の User.find_in_batches
を、pagingでデータ取得する外部APIだと思ってほしい。
すると、外部データをlazyにmapやeachできるインターフェースが実装できたことになる。
また、ファイルやネットワークからのデータ読み込みで .lazy
が使えない場合も上記の例を少し変更すれば対応できると思う。
(もちろん .lazy
が使えるなら使った方が良い。)
まとめ
このように Enumerator::Lazy
を使用すると「必要になったら取得する」という動作が簡単に実装できる。