This page looks best with JavaScript enabled
⚠️

『オブジェクト志向設計実践ガイド』8章

 ·   ·  ☕ 7 分で読めます
✏️

8章

コンポジションとは

組み合わせた全体が、単なる部品の集合以上となるように、個別の部品を複雑な全体へと組み合わせる(コンポーズする)行為

組み合わせることで、何らかの意味を持つ。

関係性が
is a なら継承(分類的派生)
has a ならコンポジション(パーツにばらす)

Partsクラスにコンポーズする

スタートはこれ

Bicycle独自の「パーツ」を別クラスに隔離し、注入するようにする。sizeは自転車のサイズなのでそのまま残す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class Bicycle
  attr_reader :size, :parts

  def initialize(args)
    @size = args[:size]
    @parts = args[:parts]
  end

  def spares
    parts.spares
  end
end

class Parts # Bicycleからごっそり抜いただけ
  attr_reader :chain, :tire_size

  def initialize(args={})
    @chain = args[:chain]
    @tire_size = args[:tire_size]
    post_initialize(args)
  end

  def spares
    {
      tire_size: tire_size,
      chain: chain
    }.merge(local_spares)
  end

  def default_tire_size
    raise NotImplementedError
  end

  def post_initialize(args)
    nil
  end

  def local_spares
    {}
  end

  def default_chain
    '10-speed'
  end
end

# BicycleではなくPartsがそれぞれ独自のクラスを持つようになった(BicycleはRoadもMountainも共通)
class RoadBikeParts < Parts
  # 以前のRoadBikeと同じ
end

class MountainBikeParts < Parts
  # 以前のMountainBikeと同じ
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
road_bike =
  Bicycle.new(
    size: 'L',
    parts: RoadBikeParts.new(tape_color: 'red')
  )

mountain_bike =
  Bicycle.new(
    size: 'M',
    parts: MountainBikeParts.new(rear_shock: 'Fox')
  )

こんな感じになる。

PartsクラスをさらにPartの集合とする

Partsオブジェクトが複数のPartを保持するという前提。

road_bike.sparesが呼ばれたときこんな感じにしたい。

Bicycle→Parts「sparesをくださーい」
Parts(了解。でも、どの部品が必要なのかわからないな)←ここを単一責任にできるのがGood
Parts→Part「あなたはsparesを作るのに必要かどうか教えてくださーい」
各Part→Parts「私は必要ですよー / 不要ですよー」
Parts(よし、じゃあ必要なものだけでsparesを組み立てよう)
Parts→Bicycle「ほい、これがsparesですよー」
Bicycle「よしよし、じゃあこれをまるっと返せばいいな」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Bicycle # Bicycleクラスの中身は変わっていない
  attr_reader :size, :parts

  def initialize(args)
    @size = args[:size]
    @parts = args[:parts]
  end

  def spares
    parts.spares
  end
end

class Parts # Partの集合を束ねるだけのクラスになった
  attr_reader :parts

  def initialize(parts)
    @parts = parts
  end

  def spares
    parts.select { |part| part.needs_spare }
  end
end

class Part
  attr_reader :name, :description, :needs_spare

  def initialize(args)
    @name = args[:name]
    @description = args[:description] # 感覚的にはdescriptionよりvalueとかのほうがしっくりくる
    @needs_spare = args.fetch(:needs_spare, true)
  end
end

各部品を定義する

1
2
3
4
5
6
chain = Part.new(name: 'chain', description: '10-speed')
road_tire = Part.new(name: 'tire_size', description: '23')
tape = Part.new(name: 'tape_color', description: 'red')
mountain_tire = Part.new(name: 'tire_size', description: '2.1')
rear_shock = Part.new(name: 'rear_shock', description: 'Fox')
front_shock = Part.new(name: 'front_shock', description: 'Manitou', needs_spare: false)

Bicycleを定義する

1
2
3
4
5
6
road_bike = Bicycle.new(size: 'L', parts: Parts.new([chain, road_tire, tape]))

road_bike.spares
=> [#<Part:0x00007feaac0f6358 @description="10-speed", @name="chain", @needs_spare=true>,
 #<Part:0x00007feaa8f00948 @description="23", @name="tire_size", @needs_spare=true>,
 #<Part:0x00007feaad8652a0 @description="red", @name="tape_color", @needs_spare=true>]

変化ポイント:
sparesがオブジェクトではなく配列を返すようになった。

この配列に入るオブジェクトの条件:
name, description, needs_spareに応答できることだけ
(ここではPartのインスタンスだけど、そうである必要はない。ダックタイプ的な感じで考える)

ところで、Partsって具体的に何なの?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
[50] pry(main)> road_bike.spares
=> [#<Part:0x00007feaac0f6358 @description="10-speed", @name="chain", @needs_spare=true>,
 #<Part:0x00007feaa8f00948 @description="23", @name="tire_size", @needs_spare=true>,
 #<Part:0x00007feaad8652a0 @description="red", @name="tape_color", @needs_spare=true>]
[47] pry(main)> road_bike.spares.size
=> 3

[49] pry(main)> road_bike.parts
=> #<Parts:0x00007feaa8eb9570
 @parts=
  [#<Part:0x00007feaac0f6358 @description="10-speed", @name="chain", @needs_spare=true>,
   #<Part:0x00007feaa8f00948 @description="23", @name="tire_size", @needs_spare=true>,
   #<Part:0x00007feaad8652a0 @description="red", @name="tape_color", @needs_spare=true>]>
[48] pry(main)> road_bike.parts.size
NoMethodError: undefined method `size' for #<Parts:0x00007feaa8eb9570>
from (pry):133:in `__pry__'
end

road_bike.sparesはArrayだけど、road_bike.partsはPartsオブジェクト!!!(sizeへの応え方を知らない)
Partsは配列的な感じになることが決まっているんだから、Arrayと同じように扱いたいよなあ。

①メソッドを生やす

1
2
3
4
5
6
class Parts
  # 省略
  def size
    parts.size
  end
end

→いやゴリ押しが過ぎるだろう。
それ、他にも使いたいメソッドが増えたら同じように付け足すのかよ。

②Arrayを継承させる

1
2
3
4
5
class Parts < Array
  def spares
    select { |part| part.needs_spare }
  end
end

これで、Arrayにあるメソッドは全部使えるよ!やったねたえちゃん!
→メソッドで弄り回していると、そのオブジェクトのクラスがArrayになってしまって、Partsっぽい振る舞いをするくせにPartsのメソッドが使えなくなってしまう……。
本当はメソッドでいじった後もPartsクラスになってほしいのに。

③Enumerableをincludeする

繰り返しを行なうクラスのための Mix-in。このモジュールのメソッドは全て each を用いて定義されているので、インクルードするクラスには each が定義されていなければなりません。
module Enumerable (Ruby 2.7.0 リファレンスマニュアル)

eachでできることが大抵できるモジュール。
そのものを別のオブジェクトに変化させることはない。

Iteratorパターンというらしい。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
require 'forwardable'

class Parts
  extend Forwardable
  def_delegators :@parts, :size, :each # @partsオブジェクトから:size, :eachを委譲する
  # Enumerableを使うにはeachに応答できることが条件なので
  include Enumerable

  def initialize(parts)
    @parts = parts
  end

  def spares
    select { |part| part.needs_spare }
  end
end

module Forwardable (Ruby 2.7.0 リファレンスマニュアル)
Forwardable#def_delegators (Ruby 2.7.0 リファレンスマニュアル)

1
mountain_bike = Bicycle.new(size: 'L', parts: Parts.new([chain, front_shock, rear_shock]))

Partsを製造する

各部品の定義の仕方、すごい雑然としていない??
例えば、

  • どのパーツを作るのか
  • 組み合わせは?

どんな風にでも作れてしまうので、Partを作るための規範を作りましょう。

このクラスの唯一の目的が、ほかのクラスのインスタンスの作成であることです。オブジェクト指向設計では、このようなオブジェクトに「ファクトリー」という名前をつけています。
p.78

というわけで、3章と同じようにファクトリーとするmoduleを作る。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
module PartsFactory
  def self.build(config, part_class = Part, parts_class = Parts)
  # Part, Partsはメソッド内に直書きすることもできるけど、結合を疎にするためにこっちで定義している
    parts_class.new(
      config.collect { |part_config|
        part_class.new(name: part_config[0], description: part_config[1], needs_spare: part_config.fetch(2, true))
      }
    )
  end
end

Part用のデータはこんな感じでまとめて定義しておけば、PartsFactory.buildの引数として流し込むだけでいい。
(順番に依存しているのはいいのか……??)
【はてなポイント】これってどこで定義するの?ベタ書きだったら同じじゃない?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
road_config = [
  ['chain', '10-speed'],
  ['tire_size', '23'],
  ['tape_color', 'red']
]

mountain_config = [
  ['chain', '10-speed'],
  ['tire_size', '2.1'],
  ['front_shock', 'Manitou', false],
  ['rear_shock', 'Fox']
]

[10] pry(main)> road_parts = PartsFactory.build(road_config)
=> #<Parts:0x00007fe4a6de7000
 @parts=
  [#<Part:0x00007fe4a6de70c8 @description="10-speed", @name="chain", @needs_spare=true>,
   #<Part:0x00007fe4a6de7078 @description="23", @name="tire_size", @needs_spare=true>,
   #<Part:0x00007fe4a6de7028 @description="red", @name="tape_color", @needs_spare=true>]>

ところでですけど、Partって大した要素をクラスとして持たないのでは?作ったら終わりだし、ただのパーツだし……。
ということで、Partは解雇です。代わりに派遣のOpenStructさんをお呼びしました。

StructとOpenStructの違い

Struct

初期化時に順番を指定して引数を渡す。

1
2
3
4
[1] pry(main)> dog = Struct.new("Dog", :name, :age)
=> Struct::Dog # ここでまずクラスを作る
[2] pry(main)> fred = dog.new("fred", 5)
=> #<struct Struct::Dog name="fred", age=5>

dog = Struct.new("Dog", :name, :age)で作成したクラスは下記と同義。

1
2
3
4
5
6
7
8
class Dog
  attr_accessor :name, :age

  def initialize(name, age) # だからハッシュで渡せない
    @name = name
    @age = age
  end
end

OpenStruct

初期化時にハッシュを撮って属性を引き出す。
ハッシュまたはsetterで値を渡す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[1] pry(main)> hoge = OpenStruct.new
=> #<OpenStruct>
[2] pry(main)> hoge.foo = 'foo!' # setterは定義していないけど渡せる。渡せば受け取ってくれる。
=> "foo!"
[3] pry(main)> hoge.foo
=> "foo!"

[5] pry(main)> huga = OpenStruct.new(foo: 'foo?')
=> #<OpenStruct foo="foo?">
[6] pry(main)> huga.foo
=> "foo?"

class Struct (Ruby 2.7.0 リファレンスマニュアル)
class OpenStruct (Ruby 2.7.0 リファレンスマニュアル)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
require 'ostruct'

module PartsFactory
  def self.build(config, parts_class = Parts)
    parts_class.new(
      config.collect { |part_config|
        create_part(part_config) # 切り出し
      }
    )
  end

  def self.create_part(part_config) # self.buildと同階層なので、こっちもselfを付ける
    # Partの代わりにOpenStructになった
    OpenStruct.new(name: part_config[0], description: part_config[1], needs_spare: part_config.fetch(2, true))
  end
end
1
2
3
4
5
6
[14] pry(main)> road_parts = PartsFactory.build(road_config)
=> #<Parts:0x00007fe4aab5cbd8
 @parts=
  [#<OpenStruct name="chain", description="10-speed", needs_spare=true>,
   #<OpenStruct name="tire_size", description="23", needs_spare=true>,
   #<OpenStruct name="tape_color", description="red", needs_spare=true>]>

Partという名前はなくなったけど、ロールとしてはPartと同等の働きをするので問題なし。

ファクトリメソッド | Ruby デザインパターン | 酒と涙とRubyとRailsと

最終的な形

まとめただけ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class Bicycle
  attr_reader :size, :parts

  def initialize(args)
    @size = args[:size]
    @parts = args[:parts]
  end

  def spares
    parts.spares
  end
end

require 'forwardable'
class Parts
  extend Forwardable
  def_delegators :@parts, :size, :each
  include Enumerable

  def initialize(parts)
    @parts = parts
  end

  def spares
    select { |part| part.needs_spare }
  end
end

require 'ostruct'
module PartsFactory
  def self.build(config, parts_class = Parts)
    parts_class.new(
      config.collect { |part_config|
        create_part(part_config)
      }
    )
  end

  def self.create_part(part_config)
    OpenStruct.new(name: part_config[0], description: part_config[1], needs_spare: part_config.fetch(2, true))
  end
end

# 独自なのは以下で定義する。
road_config = [
  ['chain', '10-speed'],
  ['tire_size', '23'],
  ['tape_color', 'red']
]

mountain_config = [
  ['chain', '10-speed'],
  ['tire_size', '2.1'],
  ['front_shock', 'Manitou', false],
  ['rear_shock', 'Fox']
]

こうすることで、RoadBikeとMountainBikeそれぞれの差異はconfigだけで設定できるようになる。

特殊なコンポジション:集約

「(個)を(塊)にコンポーズする。」という言い方(のはず)。

どちらも「has-a」の関係。
コンポジション:上位がなくなるとなくなる
集約:上位がなくなってもなくならない

Share on

END
END
@aiandrox

 
目次