全件取得したら重すぎて死んでしまうようなAPIの返り値を絞り込んで取得したい(Lazy Enumratorの話)

※ Ruby2.0以上の話です。

ときにActiveRecord::Relationが便利なのは、実際にto_aされるまでSQLが発行されないことですよね。SQLが発行されるまではいろいろな条件をインスタンス内に保持しておいてくれます。全件取得してインスタンス化してから絞り込む、なんてしていたら死んでしまいますからね。これ、無限に要素がある配列から特定条件の要素のみ10個取り出したい、というときでも似たようなことできませんかね?

Enumerable#lazyを使えばできるよ

redditのWhats you’re favorite ruby trick or quirk that most people don’t know about.というスレでも話題になっていたのですが、例えば

  • API経由でとあるサイトの記事が取得できるとする。
  • 記事はすっごくたくさんある。全部取得とかしたら死ねる。
  • 条件付きで指定した数の記事だけ抜き出すようなメソッドが欲しい。

としたときに、「こんな風に書けばできるよ!」という例が紹介されていました。

def all_posts
  return to_enum(__callee__) unless block_given?

  while api.has_next_post?
    yield api.next_post
  end
end

all_posts.lazy.take_while{ |p| p.date <= 1.week.ago }.take(10)
# => 一週間の間の記事が10件取得できる

to_enum(__callee__)あたりが超トリッキーですね。__callee__は現在のメソッド名(この例の場合は:all_posts)をあらわし、to_enumはEnumuratorクラスのインスタンスを返します。ここでto_enum(__callee__)と、引数をつけて書くと、

  • Enumerableで定義されているメソッド(mapとかselectとか)を呼び出したときに引数に指定されたメソッドを呼び出す。
  • つまりイテレータがまわる度に上記の例だと__callee__つまりall_postsメソッドが呼び出される。
  • イテレータがまわる度にall_postsが呼ばれるので、呼ばれる度にyield api.next_postが実行されることになる(こういうの、コルーチンと言うらしい)。api.has_next_post?がfalseになると処理が止まる。

という感じになります。

ここで普通に一週間の間の記事を10件取得しようとして

all_posts.take_while{|post| post.date <= 1.week.ago}.take(10)

なんてしてしまうと全記事取得してしまって永久に処理が終わらないのですが、

all_posts.lazy.take_while{|post| post.date <= 1.week.ago}.take(10)

とすることで、全記事取得することなく遅延評価を行いつつ、一週間の間の記事を10件取得してくれるようになるのです。

確かにAPI呼び出しを条件付きで行うときにかなり便利なテクだなーと思いました。

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