ActiveRecord で lazy を活用する #ruby #rails

株式会社ジラフ
7 min readSep 8, 2017

--

by Mr.Lujan

桂です。

今日は、Ruby 2.0 で実装された Enumerator#lazy の話をしようと思います。知る人ぞ知るイケてるメソッドなのですが、これを ActiveRecord と組み合わせて、怠惰プログラマーへの第一歩を踏み出したいと思います。

TL;DR

  • 怠惰な#lazy の紹介
  • #lazy を無理やり ActiveRecord で利用
  • #lazy で無理やり実践編

lazy の紹介

Array#mapArray#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)

とやれば取得できます。しかし、これは以下の理由で、件数が多いと死亡確定です。

  1. 全ての Number レコードを取得している
  2. その全てのレコードに対して #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_eachid の昇順でしか探索できないので、 数字が大きいものから順番に取得したい場合などには使えません。ActiveRecord と組み合わせて、こんなに #lazy が適任な場面はそうそうないのですが、機会があればぜひとも使ってみたいですね。

別の事例: 条件を変えてやり直す

あるカテゴリ (Category) に紐づく商品(Item)を取得する例を考えます。できれば 12 件取得したいのですが、カテゴリによっては 12 件もなかったりします。その場合は条件を広げて、共通の親カテゴリを持つ兄弟カテゴリの商品で補うことにします。商品に紐づくカテゴリは一つとします。

さて、あるカテゴリ category に紐づく商品は、

primary_query = category.items

category の兄弟カテゴリに紐づく商品は、CategoryAncestry で実装している場合、

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_querysize 分を充足できた場合は secondary_query は実行されないのです。

結び

怠惰についての話は以上です。 #lazy がうまく使えるととても気持ちいいので、ぜひともみなさんも怠惰プログラマーを目指してみてください。

告知

株式会社ジラフでは、CtoC新サービス「スママ」を開発するRailsエンジニアを待ち望んでます

ぜひぜひ応募、お願いします!

--

--