頑張るときはいつも今

自称エンジニアのゴリラです。日々精進。

【Ruby】オブジェクト思考設計実践ガイドを読み始めた#8章

コンポジションでオブジェクトを組み合わせる

読むペースが落ちてきましたが、オブジェクト指向設計実践ガイドを読み進めています。

今回は、8章コンポジション周りの忘備録になります。

先にまとめると

  • コンポジションを使うことで、外部に役割を移譲させることができる
  • 適切な大きさでオブジェクトを切って、コンポジションの関係を持たせることで、アプリの見通しがよくなる
  • 継承とダックタイプとコンポジションの使い分け
    • is-aの関係には継承
    • behaves-like-aの関係にはダックタイプ
    • has-aの関係にはコンポジション

コンポジションって何?

has_aの関係でオブジェクト同士を繋ぐ方法をコンポジションといいます。

UMLとかだと下のような感じで、自転車が部品を持つようなことをいいいます。

f:id:wa_football_1120:20200426162552p:plain

コンポジションしたオブジェクトの階層構造を作る

上の図だと部品をさらに細分化して、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_1method_2のようなメソッドができて、@itemに対して、 呼び出すような感じになります。

先ほどのPartsに対して適用させるとこのようなコードになります。

enumerableをインクルードして、eachsizeをメソッドとして持つようにしています。

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)」の場合にはコンポジションを使う