オブジェクトの集まりを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のメソッドが使えないので、size
やdelete
, sample
などArray由来のメソッドをインスタンス変数@cards
に転送させるコードを定義しておきます。
このようにすると+
のような想定外のメソッドが使われてもその場でNoMethodErrorが発生するためすぐに気づくことができますね。
とはいえ、利用するメソッド全てを転送させるコードを書くのは大変なので、標準ライブラリのforwardable
を使うようにすると簡単にかけるようになります。
Forwardableで委譲をまとめて定義する
Forwardable
をextendすると使えるようになるクラスメソッドの def_delegators
で 手札オブジェクトに対するsize
やdelete
, sample
を全てインスタンス変数の@cards
に転送します。
require "forwardable" # トランプの手札 class Hand extend Forwardable def_delegators :@cards, :size, :delete, :sample, :select ... end
これで先ほどのコードと同じことが実現できるので、基本的にはこのように書く方が良いと思います。
Enumerableをincludeする
簡単に委譲できるとはいっても、使いたいメソッドをその都度def_delegators
の引数に追加していくのは面倒です。
ただし、select
やmap
, 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_delegators
でeach
を@cards
に転送するようにしています。
select
は委譲していませんが、Enumerableをincludeしているのでeach
を経由して先ほどと同じように使えるようになっています。
Arrayを継承する方法と、Forwardable
, Enumerable
を活用する方法の2つを紹介してきました。
個人的にはカードと手札のように意味的に「同じ要素の集合 = 全体」となるような構造の場合はArrayを継承する方法も良いと思いますが、実際にはEnumerableをincludeしているケースが多いような気がします。
その他の場合、例えばロボットとパーツのように「異なる要素の組み合わせ = 全体」となるような構造の場合には、Arrayを継承するよりは集約を使った方が意味的にも正しい気がします。
このような工夫でコードの読みやすさや再利用性が変わってくると思うので、気をつけていきたいですね(自戒を込めて)。
*1:単純化のためにジョーカーの存在は無視します。
丸山 礼 (記事一覧)
サービス開発課でCloud Automatorを開発しています。