【Ruby】オブジェクトの集合を1つのオブジェクトとして扱う

記事タイトルとURLをコピーする

オブジェクトの集まりを1つのオブジェクトとして扱いたいことってありますよね。

たとえば、トランプのようなカードゲームプログラムを書くときに、一枚一枚のカードはそれぞれオブジェクトにするとして、 プレイヤーの「手札」や「山札」はどのように扱えば良いのだろう...というような話です。
もちろん、標準の配列(Array)にしても良いのですが、たとえば山札には「カードを配る」手札なら「一枚捨てる」「絵柄で検索する」など、独自のメソッドをもたせたい場合にはやはり独自クラスを作りたくなります。
独自クラスを作ることで、それぞれのオブジェクトに対する操作や問い合わせがメソッドとして一箇所にまとまるので、メンテナンス性が良くなることが大きなメリットです。
ばらばらに散らばった複数の関数を組み合わせて「カードを配る」操作を実現するよりも、山札オブジェクトに対して「カードを配れ」と一言だけ命令する方がオブジェクト指向的ですよね。

今回はそんなトピックについて主な解決策を紹介します。

まずはCardオブジェクトを素直に書いてみます。*1

# トランプのカード
class Card
  # マーク。ハート, スペード, クラブ, ダイヤのどれか。
  SUITS = [:club, :diamond, :heart, :spade].freeze
  
  attr_reader :number, :suit
  
  def initialize(number:, suit:)
    raise ArgumentError, "Invalid suit: #{suit}" unless SUITS.include?(suit)
    raise ArgumentError, "Invalid number: #{number}" unless (1..13).cover?(number)
  
    @number = number
    @suit = suit
  end
  
  def ==(other)
    return false unless other.is_a?(Card)
  
    number == other.number && suit == other.suit
  end
end
  
card = Card.new(number: 1, suit: :club)
puts card.number # ==> 1
puts card.suit   # ==> club

このカードクラスの集まりをどんなオブジェクトにするか考えます。

Arrayを継承する

RubyやRubyのような動的なオブジェクト指向言語に慣れていない方はまず最初にArrayを継承することを思いつくかもしれません。

# トランプの手札
class Hand < Array
  # カードを一枚ランダムに捨てる
  def discard(card = nil)
    delete(sample)
  end
    
  # 引数で指定されたスート(絵柄)で絞り込む
  def search_by_suit(suit)
    select { |card| card.suit == suit }
  end
end
  
card = Card.new(number: 1, suit: :club)
card2 = Card.new(number: 2, suit: :diamond)
card3 = Card.new(number: 3, suit: :heart)
  
hand = Hand.new([card, card2, card3])
  
puts hand.size # 3
hand.discard
puts hand.size # 2

Arrayを継承するだけで簡単に手札っぽいオブジェクトが作れました。
ただし、この方法はsize, delete, sampleなどのArrayの便利メソッドを特に指定なしに使える反面、全てのArrayのインスタンスメソッドを継承してしまうため若干やりすぎなところもあります。

例えばArrayから継承されたメソッドは結果としてArrayを返すメソッドがあり、派生先のオブジェクトとの互換性が失われてしまうという問題があります。
具体的には+メソッドで次のような問題が起こります。

new_hand = hand + Hand.new
puts new_hand.class # Array
new_hand.discard    # NoMethodError

HandオブジェクトとHandオブジェクトを+で繋げるとなんとArrayになってしまい、Hand独自のメソッドであるdiscardが使えなくなってしまうということが起こりました。

このような問題を回避する方法としては、+のようにArrayを返してしまうメソッドを再定義するか、後述のように集約を使う方法が考えられます。

集約を使う

集約とはあるオブジェクトに別のオブジェクトを所有・参照させることです。
例えば次のように書きます。

# トランプの手札
class Hand
  def initialize(cards)
    @cards = cards
  end
  
  def size(*args)
    @cards.size(*args)
  end
  
  def sample(*args)
    @cards.sample(*args)
  end
  
  def delete(*args)
    @cards.delete(*args)
  end
  
  # カードを一枚ランダムに捨てる
  def discard
    delete(sample)
  end
  
  # 引数で指定されたスートで絞り込む
  def search_by_suit(suit)
    select { |card| card.suit == suit }
  end
end

今回は初期化メソッドを定義し、インスタンス変数@cardsにcardを要素とするarrayを持たせておきます。
ただしこのままではArrayのメソッドが使えないので、sizedelete, sampleなどArray由来のメソッドをインスタンス変数@cardsに転送させるコードを定義しておきます。
このようにすると+のような想定外のメソッドが使われてもその場でNoMethodErrorが発生するためすぐに気づくことができますね。

とはいえ、利用するメソッド全てを転送させるコードを書くのは大変なので、標準ライブラリのforwardableを使うようにすると簡単にかけるようになります。

Forwardableで委譲をまとめて定義する

Forwardableをextendすると使えるようになるクラスメソッドの def_delegatorsで 手札オブジェクトに対するsizedelete, sampleを全てインスタンス変数の@cardsに転送します。

require "forwardable"
  
# トランプの手札
class Hand
  extend Forwardable
  def_delegators :@cards, :size, :delete, :sample, :select
  
   ...
end

これで先ほどのコードと同じことが実現できるので、基本的にはこのように書く方が良いと思います。

Enumerableをincludeする

簡単に委譲できるとはいっても、使いたいメソッドをその都度def_delegatorsの引数に追加していくのは面倒です。
ただし、selectmap, find, reject, reduceなどの検索・走査系のメソッドはEnumerableをincludeすることで一括で設定できます。

# トランプの手札
class Hand
  extend Forwardable
  include Enumerable
  
  def_delegators :@cards, :size, :delete, :sample, :each 
  
  ...
end

Enumerableをincludeすると上記のような検索・走査系メソッド59種類が一気に使えるようになります。
これらのメソッドは全てeachに依存しているので、Handオブジェクトはeachにだけ返答できるようにしておけばOKです そこで今回はdef_delegatorseach@cardsに転送するようにしています。
selectは委譲していませんが、Enumerableをincludeしているのでeachを経由して先ほどと同じように使えるようになっています。


Arrayを継承する方法と、Forwardable, Enumerableを活用する方法の2つを紹介してきました。
個人的にはカードと手札のように意味的に「同じ要素の集合 = 全体」となるような構造の場合はArrayを継承する方法も良いと思いますが、実際にはEnumerableをincludeしているケースが多いような気がします。
その他の場合、例えばロボットとパーツのように「異なる要素の組み合わせ = 全体」となるような構造の場合には、Arrayを継承するよりは集約を使った方が意味的にも正しい気がします。
このような工夫でコードの読みやすさや再利用性が変わってくると思うので、気をつけていきたいですね(自戒を込めて)。

トランプタワーを作る人のイラスト

*1:単純化のためにジョーカーの存在は無視します。

丸山 礼 (記事一覧)

サービス開発課でCloud Automatorを開発しています。