概要
コレクションを扱うクラスに 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_index
や map
, 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_index
や map
, 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
は初めて使ったのですが、便利ですね!おかげで [以前投稿したネタ記事] (http://qiita.com/QUANON/items/4ca81a9f5c78c4d7565f) の最後で言及している「自作クラスの 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