【Ruby】オブジェクト思考設計実践ガイドを読み始めた#8章
コンポジションでオブジェクトを組み合わせる
読むペースが落ちてきましたが、オブジェクト指向設計実践ガイドを読み進めています。
今回は、8章コンポジション周りの忘備録になります。
先にまとめると
- コンポジションを使うことで、外部に役割を移譲させることができる
- 適切な大きさでオブジェクトを切って、コンポジションの関係を持たせることで、アプリの見通しがよくなる
- 継承とダックタイプとコンポジションの使い分け
is-a
の関係には継承behaves-like-a
の関係にはダックタイプhas-a
の関係にはコンポジション
コンポジションって何?
has_a
の関係でオブジェクト同士を繋ぐ方法をコンポジションといいます。
UMLとかだと下のような感じで、自転車が部品を持つようなことをいいいます。
コンポジションしたオブジェクトの階層構造を作る
上の図だと部品をさらに細分化して、Parts
オブジェクトからは、
Arrayのような感じでPartを返すことも可能です。
Bicycleが保持している部品のスペアを取得するようなコードを考えると下記のような感じになります
Partsはコレクションみたいな感じで実装していく感じになります。
class Bicycle attr_accessor :parts def initialize(parts) @parts = parts end def spare_parts parts.spare end end class Parts attr_accessor :parts def initialize @parts = parts end def spare parts.map {|part| part.spare } end end class Part attr_accessor :name, :stock, :description def initialize(name, stock, description) @name = name @stock = stock @description = description end def spare { name: name, stock: stock, description: description } end end
コレクションオブジェクトを配列ぽく振舞わせる
上のPartsオブジェクトですが、いくつか不便なところが出てきます。
BicycleからPartの数(size)を取得するときに単純に書いてしまうとエラーが発生します。
part_a = Part.new('hoge', 1, "hoge part") part_b = Part.new('fuga', 1, "fuga part") part_array = [part_a, part_b] parts = Parts.new(part_array) bicycle = Bicycle.new(parts) # Partsオブジェクトから取得するのはOK parts.parts.size # 2 # サイズを取得しようするとエラーが出る bicycle.parts.size # undefined method `size' for #<Parts:0x00007f7fb1147890> (NoMethodError)
Bicycleオブジェクトが保持しているのはPartsオブジェクトです。
対して、Partsオブジェクトが保持しているのは、Partの配列です。
配列(Array)はsize
を返すことができるので、エラーを書くことはありません。
しかし、Partsオブジェクトはsize
を実装していないので、
要求された物を返すことができないのです。
Forwardable
という物を利用してみます。
Forwardableモジュール
Rubyに標準で添付されているオブジェクトで、
「指定されたメソッドを、特定のオブジェクトに実行させる」ことができるようになります。
class Foo extend Forwardable def_delegators :@item, :method_1, :method_2 end
method_1
とmethod_2
のようなメソッドができて、@item
に対して、
呼び出すような感じになります。
先ほどのPartsに対して適用させるとこのようなコードになります。
enumerable
をインクルードして、each
、size
をメソッドとして持つようにしています。
require 'forwardable' class Parts extend Forwardable # Enumableのうち、sizeとeachを使えるようにする def_delegators :@parts, :size, :each include Enumerable attr_accessor :parts def initialize(parts) @parts = parts end def spare parts.map {|part| part.spare } end end
これで、BicycleからPartsに対して、size
メソッドを呼び出せるようになります。
Factoryオブジェクトを活用する
もっとリファクタをしていくために、Factoryオブジェクトを利用してみます。
Partsオブジェクトを生成するPartsFactory
を作成します。
module PartsFactory def self.build( config, part_class = Part, parts_class = Parts ) parts_class.new( confit.collect { |part_config| part_class.new( name: part_config[:name], description: part_config[:description] ) } ) end end part_config = [ { name: 'fuga', description: 'fuga description' }, { name: 'hoge', description: 'hoge description' } ] parts = PartsFactory.build(part_config)
継承とコンポジションの選択
ロール(役割)を移譲させる方法に継承という選択もあります。
移譲させる際に、継承とコンポジション2つの選択があった場合には、
コンポジションを選択することを優先した方がいいです。
継承はコンポジションと比較して、依存に対するコストが大きいものとなります。
(階層構造の上から下まで依存しているため、自然とコストが高くなる感じだと思います。)
継承を選択するメリットとコスト
継承を利用した場合、オープンクローズド
なコードを得やすいことがメリットになります。
階層構造は、拡張にはopen
であり、修正にはclosed
(変更の影響がサブクラスのみに影響)なコードになります。
上のメリットは、適切に階層構造が設計された場合にのみ、得られるメリットであり、
適切な設計がされていない継承には下記のコストが伴います。
- 継承が適さない問題に対して、誤って継承を選択してしまう
- 継承が妥当だとしても、他のプログラマーにとよって全く予期していなかった目的のために利用される可能性があること
- 上から下まで(階層構造)の依存が集まるので修正の影響が大きい
コンポジションの利点
コンポジションはそれぞれのオブジェクトが独立して、動作するので階層構造に依存しません。
下記のようなメリットを得ることができます。
- 小さなオブジェクトに分けて作成していくので、責任が単純明快、見通しがいい
- 明確に定義されたインタフェース(メソッド)を介して、アクセスが可能
- 再利用性が高いオブジェクトを作成しやすい
使い分けのまとめ
- オブジェクト同士の関係に「Xは〜である(is-a)」の関係が生まれる時に、継承を使った方がいい
- 互いに関係しないオブジェクトが同じロールを担いたい「〜〜のように振舞う(behaves-like-a)」の場合はダックタイプを使う
- あるオブジェクトが別のオブジェクトを保持する「XはYを持つ(has-a)」の場合にはコンポジションを使う