桂です。
今日は、Ruby 2.0 で実装された Enumerator#lazy
の話をしようと思います。知る人ぞ知るイケてるメソッドなのですが、これを ActiveRecord と組み合わせて、怠惰プログラマーへの第一歩を踏み出したいと思います。
TL;DR
- 怠惰な
#lazy
の紹介 #lazy
を無理やり ActiveRecord で利用#lazy
で無理やり実践編
lazy の紹介
Array#map
や Array#select
などのコレクション操作は、しばしばする必要のない処理で無駄なリソースを消費してしまいます。
> [1, 2, 3, 4, 5].map do |n|
* puts n
> n * n
> end.take(2)
1
2
3
4
5
=> [1, 4]
この例では、後半の [3, 4, 5]
に対する処理は全く必要ありません。 この処理が行われるのは、 #map
を呼び出した時点、すなわち #take
を呼び出す前に、全要素について処理を完了してしまっているためです。このように、次の処理に移行する前にすべての処理を完了してしまうものを正格評価といいます。
ここで、 #lazy
の出番です。 Enumerable
インスタンスに対して #lazy
メソッドを呼び出すと、 Enumerator::Lazy
インスタンスに変身します。
> [1, 2, 3, 4, 5]
=> [1, 2, 3, 4, 5]> [1, 2, 3, 4, 5].lazy
=> #<Enumerator::Lazy: [1, 2, 3, 4, 5]>
Enumerator::Lazy
に対して行う操作は、直ちには実行されません。これを正格評価に対して遅延評価と言います。
> lazy_ops = [1, 2, 3, 4, 5].lazy.map do |n|
* puts n
> n * n
> end.take(2)
=> #<Enumerator::Lazy: #<Enumerator::Lazy: #<Enumerator::Lazy: [1, 2, 3, 4, 5]>:map>:take(2)>
#to_a
を呼ぶと、必要な処理だけが実行されます。
> lazy_ops.to_a
1
2
=> [1, 4]
[3, 4, 5]
に対する処理を行わなくなりました。このように、 #lazy
をうまく使うと、無駄な処理を避けながらスマートなコレクション処理を行うことができます。
lazy meats ActiveRecord
SQL は、無駄な処理を避けたいものの筆頭です。一度に大量にレコードを取得するとメモリを圧迫しますし、処理内容によっては、取得したレコードの一部しか使わず、その殆どを捨てるハメになったりします。
ここでは、ある条件を満たすレコードを数件だけ取得する例を考えてみましょう。SQL で条件付けができるものは SQL だけで行うべきですが、条件が複雑な場合はそれができないことがあります。
ここに、 Number
モデルがあるとしましょう。素数かどうかを判定する Number#prime?
メソッドが定義されています。このモデルに対して、データベースを参照し、素数のレコードを 5 件取得するという問題を考えます。
素朴にやるなら、 Array#select
を使って
Number.all.select(&:prime?).take(5)
とやれば取得できます。しかし、これは以下の理由で、件数が多いと死亡確定です。
- 全ての
Number
レコードを取得している - その全てのレコードに対して
#prime?
を実行している
ここで #lazy
を使うと、素数が 5 件見つかった段階で処理を終了してくれるので、 2. の問題を解決できます。
Number.all.lazy.select(&:prime?).first(5)
ここで、 .first(n)
は正格評価のメソッドで、 .take(n).to_a
と同等です。
ところで、 ActiveRecord には #find_each
というイケてるメソッドがあります。一見普通のループながら、任意のバッチサイズでデータベースからレコードを取得し、メモリ効率と実行速度の両立を図ります。これで 1. の問題を解決できます。
primes = []
Number.find_each do |number|
if number.prime?
primes << number
break if primes.size >= 5
end
end
でもこれ、ダサいですよね? 少なくとも、生粋のワンライナーである私が許せるコードではありません。
ここで #lazy
が再登場します。 #find_each
の返り値は Enumerator
であり、 Enumerable
の眷属ですから、 #lazy
をつなげて怠惰にすることができます。
Number.find_each.lazy.select(&:prime?).first(5)
このように書くと、一度に取得するレコードは 1000 件に制限され、かつ、 #prime?
も、 5 件目を選択したらそれ以降は実行されません。イケメン!怠惰最高!
注意点として、 #find_each
は id
の昇順でしか探索できないので、 数字が大きいものから順番に取得したい場合などには使えません。ActiveRecord と組み合わせて、こんなに #lazy
が適任な場面はそうそうないのですが、機会があればぜひとも使ってみたいですね。
別の事例: 条件を変えてやり直す
あるカテゴリ (Category
) に紐づく商品(Item
)を取得する例を考えます。できれば 12 件取得したいのですが、カテゴリによっては 12 件もなかったりします。その場合は条件を広げて、共通の親カテゴリを持つ兄弟カテゴリの商品で補うことにします。商品に紐づくカテゴリは一つとします。
さて、あるカテゴリ category
に紐づく商品は、
primary_query = category.items
category
の兄弟カテゴリに紐づく商品は、Category
を Ancestry で実装している場合、
secondary_query = Item.joins(:category).merge(category.siblings))
のように取得できます。(イメージしやすいように実例を挙げているだけで、内容に意味はありません)
ここで、天下り的に、魔法のメソッドを定義します。
def fetch_or_fallback(size, *queries)
queries
.lazy
.flat_map { |query| query.limit(size) }
.first(size)
end
これを、
fetch_or_fallback(12, primary_query, secondary_query)
のように実行すると、一個目のクエリで 12 件とれた場合はそのまま終了し、とれなかった場合は二個目のクエリで補完する挙動を実現できます。
何が起こっているのでしょうか。
まず、前提として ActiveRecord もそれ自身、必要になるまでクエリを実行しないという、遅延評価の性質を持っています。上のコードでは、 #lazy
を活用して、クエリが必要になるタイミングを遅延しています。
Enumerable#flat_map
は、与えたブロックが返す Enumerable
(のようなもの) を全ての要素について収集・結合して一つの Array
を作るメソッドです。
> (1..4).flat_map { |i| [i] * i }
=> [1, 2, 2, 3, 3, 3, 4, 4, 4, 4]
Enumerator::Lazy#flat_map
は、その遅延評価版で、必要になるまでブロックを実行しません。
> (1..4).lazy.flat_map { |i| puts i; [i] * i }.first(4)
1
2
3
=> [1, 2, 2, 3]
そのため、 primary_query
で size
分を充足できた場合は secondary_query
は実行されないのです。
結び
怠惰についての話は以上です。 #lazy
がうまく使えるととても気持ちいいので、ぜひともみなさんも怠惰プログラマーを目指してみてください。
告知
株式会社ジラフでは、CtoC新サービス「スママ」を開発するRailsエンジニアを待ち望んでます!
ぜひぜひ応募、お願いします!