Tech Racho エンジニアの「?」を「!」に。
  • 開発

Ruby: EnumerableをincludeするよりEnumeratorを返そう(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

画像は元サイトからの引用です。

Ruby: EnumerableをincludeするよりEnumeratorを返そう(翻訳)

自分のクラスにEnumerableモジュールをincludeする人をむやみやたらと見かけます。しかし私としては多くの場合、Enumerableで使えるeach_with_indexやらtake_whileやらminmaxといったメソッドを自分のクラスの中で使えるようにすることがそのクラスの責務の中心にあるとは考えにくいのです。

私ならそのような場合、Java方式でやるのが好みです。つまり、コレクションでEnumerableのさまざまな便利メソッドのどれかひとつを呼び出す必要のあるユーザー向けに、外部のEnumeratorを提供してあげるのです。ここで自問自答すべきは「そのクラスはコレクションなのか?」です。イエスなら、include Enumerableするのははまったくもって当然です。しかしクラスそのものがコレクションではなく、それ以外の何かを含む可能性のあるクラスだったり、コレクションを提供するクラスであるならば、外部のEnumeratorこそがおそらく解決策となることでしょう。

標準ライブラリ

定番中の定番であるArray#eachメソッドをブロックなしで呼び出すと、次のようにEnumeratorオブジェクトが1つ返されます。

e = [1,2,3].each
# => #<Enumerator: [1, 2, 3]:each>

新しい要素を手動でフェッチすることもできます。

e.next
# => 1

e.next
# => 2

e.next
#=> 3

e.next
# StopIteration: iteration reached an end

Enumeratorがかたじけなくも提供してくれるEnumerable系メソッドのひとつを使うこともできます。

e = [1,2,3].each

e.partition{|x| x % 2 == 0}
# => [[2], [1, 3]]

Enumeratorを作る

Enumeratorの作り方は3とおりあります(訳注: #to_enumenum_forは少なくともRuby 2.5ではObjectのメソッドです)。

  • Kernel#to_enum
  • Kernel#enum_for
  • Enumerator.new

しかしMRIの実装を覗いてみると、 #to_enum#enum_forの実装が同じであることがわかります。

rb_define_method(rb_mKernel, "to_enum", obj_to_enum, -1);
rb_define_method(rb_mKernel, "enum_for", obj_to_enum, -1);

rb_define_method(rb_cLazy, "to_enum", lazy_to_enum, -1);
rb_define_method(rb_cLazy, "enum_for", lazy_to_enum, -1);

上のコードは以下で参照できます。

今度はrubyspecを覗いてみると、両者は完全に同一の振る舞いを期待されているので、本記事執筆時点では両者に違いはないと推測しています(訳注: 以下の2つがリンク切れだったため、翻訳時点のリンクに変更しています)。

すなわち、これらを使うコード例ではいつでも両者を交換可能です。

#to_enum#enum_for

さて、#to_enum#enum_forにはどんな使いみちがあるのでしょうか?どちらも、引数をyieldする任意のメソッドを元にEnumeratorを作成できます。#eachメソッドを元にEnumeratorを作成する手法はよく使われます(今更ですが)。

a = [1,2,3]
enumerator = a.to_enum(:each)

実際の使い方については後述します。

Enumerator.new

こちらの方法については、上述とは対照的にRuby docに詳しく書かれているので、そこからそのままコピペします(訳注: 原文はRuby 2.1ドキュメントへのリンクでしたが2.5に変更しました: 内容は同じです)。

渡されたブロックによって繰り返しが定義される。ブロックパラメータとして渡される「yielder」オブジェクトは値のyieldに使える。

fib = Enumerator.new do |y|
  a = b = 1
  loop do
    y << a
    a, b = b, a + b
  end
end

fib.take(10) # => [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

オプションパラメータを用いて、サイズをlazyに算出する方法を指定できる。値、または呼び出し可能オブジェクトのいずれかを使える。

以下は私のコード例です。

polish_postal_codes = Enumerator.new(100_000) do |y|
  100_000.times do |number|
    code    = sprintf("%05d", number)
    code[1] = code[1] + "-"
    y.yield(code)
  end
end

polish_postal_codes.size    # => 100000
                            # どの要素も算出せずに返す

polish_postal_codes.take(3) # => ["00-000", "00-001", "00-002"]

Enumerator.newする理由

もちろん、(Arrayなどの)コレクションを返すとパフォーマンス上の理由で困難が生じたり実行不可能になるような場合は、Enumeratorを返すのが最も理にかなっています。IO#each_byteIO#each_charなどがそうです。

注意すべき点は?

実際にはそれほどありません。自分のメソッドが値をyieldするのであれば、ブロックが渡されない場合には常に#to_enumでそのメソッド自身を元にEnumeratorを作成するだけにしておきましょう(上述のとおり#enum_forでも挙動は同一です)。ややこしそうに思えるかもしれませんがそんなことはありません。次のコード例をご覧ください。

require 'digest/md5'

class UsersWithGravatar
  def each
    return enum_for(:each) unless block_given? # ここで魔法が炸裂!!

    User.find_each do |user|
      hash  = Digest::MD5.hexdigest(user.email)
      image = "http://www.gravatar.com/avatar/#{hash}"
      yield user unless Net::HTTP.get_response(URI.parse(image)).body == missing_avatar
    end
  end

  private

  def missing_avatar
    @missing_avatar ||= begin
      image_url = "http://www.gravatar.com/avatar/fake"
      Net::HTTP.get_response(URI.parse(image_src)).body
    end
  end
end

私たちが手がけているSuper Startupは数百万ものユーザーを抱えており、うち数千人がgravatarを使っています。それらを一切合切1個のarrayに押し込めて返したくありませんよね?しかしご心配なく。return enum_for(:each) unless block_given?という魔法のようなワンライナーのおかげで、全データを計算せずにこのコレクションを共有できます。

これは本当に役に立つことがあります。特に、呼び出し元で一部しか必要とされていない場合に有用です。

class PutUsersWithAvatarsOnFrontPage
  def users
    @users ||= UsersWithGravatar.new.each.take(20)
  end
end

呼び出し元でちょいとばかり#lazyな処理を行いたい場合にも便利です。

UsersWithGravatar.
  new.
  each.
  lazy.
  select{|user| FacebookFriends.new(user).has_more_than?(10) }.
  and_what_not # ...

おや、ついlazyの話をしてしまいましたが、このくらいにしておきましょう。というのもlazyにはまったく別の良記事があるからです。

まとめ

Ruby標準ライブラリの振る舞いを一貫させるために、yieldする自作メソッドにブロックが渡されないときにはどうかEnumeratorを返してやってください。

return enum_for(:your_method_name_which_is_usually_each) unless block_given?`

たったこれだけで完了です。

自分で作るクラスにEnumerableが必要であるとは限りません。Enumeratorを返すだけで済むならそれに越したことはありません。

もっと知りたい方へ

本記事を気に入っていただけた方は、Arkencyのニュースレターの購読をお願いします。開発者を悪い意味で驚かせないRailsアプリを構築するための弊社の日々の取り組みのノウハウを届けします。

弊社の最新刊『Domain-Driven Rails』もぜひどうぞ。特に、巨大で複雑なRailsアプリを扱ってる方に有用です。


blog.arkency.comより

関連記事

Ruby: ループには一時変数ではなくEnumerableを使おう(翻訳)

Ruby 2.5: Enumerableの新機能: トリプルイコール`===`と述語メソッドの合わせ技(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。