組合(composition) 是指將不同的小部分結合成一個複雜的整體,整體也僅是一部分,並非代表全部,以音樂作為比喻,音樂是由音符組合而成,但音樂不僅是由音符組合而成。
以軟體設計的角度來看,可以使用物件導向的組合技巧來將簡單、獨立的物件組合成更大、更複雜的整體。在組合過程中,較大的物件與其元件(零件)之間建立了「含有什麼」的關係。
還記得嗎?物件間透過 介面 進行通訊,較大的物件即是一種角色,而它會與任何扮演相關角色的物件進行合作。
Bicycle
類別負責回應spares
訊息,這則訊息傳回一個備件清單,自行車有多個零件,因此自行車與零件之間的關係就像是組合。
轉換Bicycle
類別使用組合技巧:
Bicycle
類別轉換為使用組合而不是繼承的方式。Bicycle
類別負責回應spares
訊息,現在將這個責任委派給一個新的Parts
物件。Parts
類別,將備件訊息委派給Parts
物件。Bicycle
與Parts之間建立了組合關係,Bicycle
包含一個Parts
物件。在這條線的 Parts
那端有數字「1」,它表示每一個Bicycle
都只有一個Parts
物件。Bicycle
現在要負責三件事:知道其size
、持有其Parts
並回應其spares
,這個轉換簡化了Bicycle
的職責。
class Bicycle
attr_reader :sizez :parts
def initialize(args={})
@size = args[:size]
@parts = args[:parts]
end
def spares
parts.spares
end
end
Bicycle
裡移除的parts
行為轉移到一個新的Parts
層次結構,Parts
擁有兩個子類別:RoadBikeParts
和MountainBikeParts
。
class Parts
attr_reader :chain, :tire_size
def initialize(args={})
@chain = args[:chain] || default_chain
@tire_size = args[:tire_size] || default_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
class RoadBikeParts < Parts
attr_reader :tape_color
def post_initialize(args)
@tape_color = args[:tape_color]
end
def local_spares
{tape_color: tape_color}
end
def default_tire_size
'23'
end
end
class MountainBikePairts < Parts
attr_reader :front_shockz :rear_shock
def post_initialize(args)
@front_shock = args[:front_shock]
@rear_shock = args[:rear_shock]
end
def local_spares
{rear_shock: rear_shock}
end
def default_tire_size
'2.1'
end
end
road_bike =
Bicycle.new(
size: 'L',
parts: RoadBikeParts.new(tap_color: 'red'))
road_bike.size # -> 'L'
road_bike.spares
# -> {:tire_size=>"23",
# :chain=>"10-speed",
# :tape_color=>"red"}
mountain_bike =
Bicycle.new (
size: 'L',
parts: MountainBikeParts.new(rear_shock: 'Fox'))
mountain_bike.size # -> 'L'
mountain_bike.spares
# -> {:tire_size=>"2.1",
# :chain=>"10-speed",
# :rear_shock=>"Fox"}
除了類別名稱不同,同時移除了size
變數,另外,只要擁有的是RoadBikeParts
或 MountainBikeParts
,自行車都可以正確地回應其size
和spares
。
要表示個別零件,需要創建一個Part
類別,Part
類別用於代表個別零件的特性和行為。由於已經存在一個名為Parts
的類別,引入Part
類別可能會造成溝通問題,在命名上要區分單複數。
零件淸單會包含一長串的個別零件,現在要開始增加能夠表示個別零件的類別,簡單地說我們的目標是有一個Parts
物件,它可能包含多個Part
物件。
Bicycle
會傳送spares
給Parts
,接著Parts
物件會傳送needs_spare
給每一個Part
。Part
的「1..」表示一個Parts
擁有一個或更多的Part
物件。Part
類別可以簡化現有的Parts
類別,Parts
類別現在已變成一個簡單的包裝器,將一組Part
物件包裝起來,並且過濾Part
物件清單,傳回需要備件的Part
物件。
目前現有的Bicycle
類別,更新後的Parts
類別以及新引入的Part
類別如下:
class Bicycle
attr_reader :size, :parts
def initialize(args ={})
@size = args[:size]
@parts = args[:parts]
end
def spares
parts.spares
end
end
class Parts
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]
@needs_spare = args.fetch(:needs_spare, true)
end
end
組合Parts
的方式有下面兩種:
有了Bicycle
, Parts
, Part
這三個類別之後,我們便可以自由建立個別的 Part
物件:
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)
個別的Part
物件可以再被組合成一個Part
。
road_bike_parts = Parts.new([chain, road_tire, tape])
建立Bicycle
時直接建構Parts
物件
road_bike =
Bicycle.new(
size: 'L',
parts: Parts.new([chain, road_tire, tape]))
road_bike.size # -> 'L'
road_bike.spares
#-> [#<Part:0x00000101036770
#. @name="chain",
#. @description="10-speed",
#. @needs_spare=true>,
#. #<Part:0x0000010102dc60
#. @name="tire_size",
#. 等等....
mountain_bike =
Bieyele.new(
size: 'L',
parts: Parts.new([chain, mountain_tire, front_shock,
rear_shock]))
mountain_bike.size # -> 'L'
moimtain_bike.spares
#-> [#<Part:0x00000101036770
#. @name="chain”,
#. @description="10-speed",
#. @needs_spare=true>,
#. #<Part:0x0000010101b678
#. @name=" tire_size",
#. 等等....
差異:
Part
物件,Bicycle
原有的spares
方法會傳回一個散列表Bicycle
時直接建構Parts
物件,spares
方法則會傳回一個Part
物件陣列。照理說Bicycle
的parts
和spares
都應該傳回相同的內容,然而物件所回傳的並不相同。
spares
會傳回一個陣列(由Part
物件組成),且Array
能夠明白size
。parts
會傳回Parts
實例,但它對size
並不理解,因此造成以下錯誤。mountain_bike.spares.size # -> 3
mountain_bike.parts.size
# -> NoMethodError:
# undefined method 'size' for #<Parts:...>
因此,我們必須做以下修改:
Forwardable
模組,這允許你將某些方法的呼叫委派給@parts
實例變數中的物件。這樣做可以讓Parts
類別具有size
和each
方法,這些方法實際上是由 @parts
物件提供的include Enumerable
,這表示你可以在Parts
上使用像select
這樣的 Enumerable
方法。Parts
類別來包裝一個陣列,該陣列包含了自行車的零件(例如chain
、mountain_tire
等)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
mountain_bike =
Bicycle.new (
size: 'L ',
parts: Parts.new([chain, mountain_tire, front_shock, rear_shock]))
mountain_bike.spares.size # -> 3
mountain_bike.parts.size # -> 4
回顧上方程式碼(4~7行)。Part
物件存放在chain
、mountain_tire
等變數裡面。 在應用程式裡的某個地方,會有物件必須要知道如何建立這些Part
物件。而在上方的程式碼(4~7行),這種知識泄漏將特定物件的建立細節暴露給其他部分,不是一個理想的設計方式。
這邊會使用到工廠模式,協助我們建立PartsFactory
的模組:
工廠模式
PartsFactory模組
建立新的PartsFactory
模組,它負責接收配置信息並製造出Parts
物件。
命名build
方法並接收三個參數:
config
:這是一個陣列,其中包含了零件的描述信息,每個元素都是一個子陣列,包含零件的名稱、描述和是否需要備件。part_class
:這是一個可選的參數,用於指定要使用的零件類別,預設值為 Part。parts_class
:這是另一個可選的參數,用於指定要使用的部件集合類別,預設值為 Parts。由於PartsFactory
瞭解config
的內部結構,所以config
可以被指定為陣列而非散列表。使用config.collect
迭代config
陣列的每個元素,並為每個零件創建一個 part_class
的實例。在這個過程中,從子陣列中提取零件的名稱、描述和需要備件的信息。
這個PartsFactory
用於建立Parts
物件,並且能夠根據提供的config
陣列創建零件,並將它們添加到Parts
物件中。此方法提高了程式碼的可維護性和靈活性,因為它把配置知識集中在一個地方,並且允許在不修改程式碼的情況下調整Parts
物件的建立過程。
module PartsFactory
def self.build(config,
part_class = Part,
parts_class = Parts)
parts_class.new(
config.collect {|part_config|
art_class.new(
name: part_config[0],
description: part_config[1],
needs_spare: part_config.fetch(2, true))})
end
end
既然有了PartsFactory
,那麼你就可以使用上面所定義的設定陣列輕鬆地建立新的 Parts
。在PartsFactory
與新的設定陣列相結合之後,它會將所有建立有效Parts
所需要的知識隔離起來。
road_parts = PartsFactory.build(road_config)
# -> [#<Part:0x00000101825b70
#. @name="chain",
#. @description="10-speed",
#. @needs_spare=true>,
#. #<Part:0x00000101825b20
#. @name= "tire_size",
#. 等等.....
Mountain_parts = PartsFactory.build(mountain_config)
# -> [#<Part:0x0000010181ea28
#. @name="chain",
#. @description="10-speed",
#. @needs_spare=true>,
#. #<Part:0x0000010181e9d8
#. @name="tire_size",
#. 等等.....
OpenStruct
的彈性比Struct
更大,它的初始化參數是一個hash
,可以把屬性當做method來處理,直接指定屬性內容、直接讀取。(關於OpenStruct
與Struct
的差異與使用,大家可以參考這篇文章)
修改PartsFactory
,使用OpenStruct
來取代扮演Part
角色的物件,如此你便能夠清除掉Part
的所有痕跡。
require 'ostruct'
# OpenStruct class不包含在原本Core物件當中,因此需要先require
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
現在,它會傳回一個Parts
,Parts
含有一個OpenStruct
物件陣列,而且每一個物件都扮演了Part
角色。
mountain_parts = PartsFactory.build(mountain_config)
# -> <Parts:0x000001009ad8b8 @parts=
#. [#<OpenStruct name="chain",
#. description="10-speed",
#. needs_spare=true>,
# #<OpenStruct name="tire_size",
#. description="2.1",
#. 等等.....
有沒有覺得組合
的非常有趣?從Bicycle
需求出發而有了Parts
物件,透過組合的方式來運作,今天,先組完Parts
與Part
;明天,我們繼續組Bicycle
參考資料: