LoginSignup
19
9

More than 3 years have passed since last update.

Enumerable を include する代わりに Enumerator オブジェクトを活用する

Last updated at Posted at 2016-04-15

概要

コレクションを扱うクラスに Enumerable モジュールを include することはままあります。each メソッドをオーバライドするだけで、 Enumerable#each_with_index, Enumerable#map, Enumerable#inject といった便利な定番メソッドが使えるようになるので便利です。そのため、僕自身もよく Enumerable モジュールを使っていました。

以下の GroupedCharacters クラスは Enumerable モジュールを include する例です。

# アニメキャラクターを作品ごとにグルーピングするためのクラス
class GroupedCharacters
  include Enumerable

  attr_reader :characters

  def initialize(*characters)
    @characters = characters
  end

  def each
    characters.group_by(&:anime).each do |anime, characters|
      names = characters.map(&:name).join(', ')

      yield(anime, names) if block_given?
    end
  end
end

Character = Struct.new(:anime, :name)

grouped =
  GroupedCharacters
    .new(
      Character.new('まどマギ', '佐倉 杏子'),
      Character.new('まどマギ', '美樹 さやか'),
      Character.new('<物語>シリーズ', '阿良々木 暦'),
      Character.new('<物語>シリーズ', '忍野 忍'),
      Character.new('ひだまりスケッチ', 'ゆの')
    )
grouped.each_with_index { |(anime, names), i| puts("(#{i + 1}) #{anime}: #{names}") }
(1) まどマギ: 佐倉 杏子, 美樹 さやか
(2) <物語>シリーズ: 阿良々木 暦, 忍野 忍
(3) ひだまりスケッチ: ゆの

しかし、ある日偶然 Stop including Enumerable, return Enumerator instead (Enumerable を include するのをやめて、代わりに Enumerator を返そう) という記事を見つけました。この記事の冒頭にはこのように書かれています。

Many times I have seen people including Enumerable module into their classes. But I cannot stop thinking that in many cases having methods such as each_with_index or take_while or minmax and many others that are available in Enumerable are not core responsibility of the class that is including them itself.

意訳すると以下のとおりです。

Enumerable モジュールを include することはよくある。そうすることで each_with_index, take_while, min, max など数多くのメソッドが使用可能になる。しかし、それらのメソッドは include したクラスの主な責務ではないのではないか。

僕は「なるほど、一理あるな」と思いました。コレクションを扱うクラスとはいえ、それ自身に each_with_indexmap, inject を定義するのではなく、外部イテレータである Enumerator オブジェクトに委譲する方がよりスマートだと考えなおしたのです。餅は餅屋です。

そこで、記事のアドバイスをもとに前述の例を以下のように書き換えました。

require 'forwardable'

class NewGroupedCharacters
  extend Forwardable

  attr_reader :characters
  # map など Enumerable で定義されているメソッドは each に委譲する。
  def_delegators :each, *Enumerable.instance_methods(false)

  def initialize(*characters)
    @characters = characters
  end

  def each
    return enum_for(:each) unless block_given?

    characters.group_by(&:anime).each do |anime, characters|
      names = characters.map(&:name).join(', ')

      yield(anime, names)
    end
  end
end

Character = Struct.new(:anime, :name)

new_grouped =
  NewGroupedCharacters
    .new(
      Character.new('まどマギ', '佐倉 杏子'),
      Character.new('まどマギ', '美樹 さやか'),
      Character.new('<物語>シリーズ', '阿良々木 暦'),
      Character.new('<物語>シリーズ', '忍野 忍'),
      Character.new('ひだまりスケッチ', 'ゆの')
    )

new_grouped.each_with_index { |(anime, names), i| puts("(#{i + 1}) #{anime}: #{names}") }

NewGroupedCharacters クラスでは Object#enum_for を使って、each をブロックなしで呼んだ場合に Enumerator オブジェクトを返すようにしています。Enumerator は Enumerable を継承しているため each_with_indexmap, inject などのメソッドが使える上、さらに Enumerator#with_index などの便利なメソッドを持っています。

# new_grouped.each.each_with_index { |(anime, names), i| puts("(#{i + 1}) #{anime}: #{names}") } と同じ。
new_grouped.each.with_index(1) { |(anime, names), i| puts("(#{i}) #{anime}: #{names}") }

ちなみに最初に例示した GroupedCharacters#each の書き方では、each をブロックなしで読んだ場合の返り値が Hash オブジェクト (@characters.group_by の返り値) となってしまうので with_index が使えませんでした。

enum_for は初めて使ったのですが、便利ですね!おかげで 以前投稿したネタ記事 の最後で言及している「自作クラスの each をブロックなしで呼んだ場合に Enumerator を返したいが、スマートに書くにはどうすればいいのか」という悩みを解決することができました。

追記 (2019/07)

Kernel#DelegateClass を使いましょう。

class GroupedCharacterNames < DelegateClass(Array)
  attr_reader :characters

  def initialize(*characters)
    @characters = characters
    super(grouped_character_names)
  end

  private

  def grouped_character_names
    characters.group_by(&:anime).map do |anime, characters|
      names = characters.map(&:name).join(', ')

      [anime, names]
    end
  end
end

Character = Struct.new(:anime, :name)

grouped_character_names =
  GroupedCharacterNames
    .new(
      Character.new('まどマギ', '佐倉 杏子'),
      Character.new('まどマギ', '美樹 さやか'),
      Character.new('<物語>シリーズ', '阿良々木 暦'),
      Character.new('<物語>シリーズ', '忍野 忍'),
      Character.new('ひだまりスケッチ', 'ゆの')
    )

grouped_character_names
  .each_with_index do |(anime, names), i|
    puts("(#{i + 1}) #{anime}: #{names}")
  end

参考

19
9
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
9