iT邦幫忙

2023 iThome 鐵人賽

DAY 16
0

組合(composition) 是指將不同的小部分結合成一個複雜的整體,整體也僅是一部分,並非代表全部,以音樂作為比喻,音樂是由音符組合而成,但音樂不僅是由音符組合而成。

以軟體設計的角度來看,可以使用物件導向的組合技巧來將簡單、獨立的物件組合成更大、更複雜的整體。在組合過程中,較大的物件與其元件(零件)之間建立了「含有什麼」的關係。

還記得嗎?物件間透過 介面 進行通訊,較大的物件即是一種角色,而它會與任何扮演相關角色的物件進行合作。

在 Bicycle 新增 Parts 組合

Bicycle類別負責回應spares訊息,這則訊息傳回一個備件清單,自行車有多個零件,因此自行車與零件之間的關係就像是組合。

更新 Bicycle 類別

轉換Bicycle類別使用組合技巧:

  • 目標是將Bicycle類別轉換為使用組合而不是繼承的方式。
  • 原來的Bicycle類別負責回應spares訊息,現在將這個責任委派給一個新的Parts物件。
  • 建立了一個用於容納所有自行車零件的Parts類別,將備件訊息委派給Parts物件。
  • Bicycle與Parts之間建立了組合關係,Bicycle包含一個Parts物件。在這條線的 Parts那端有數字「1」,它表示每一個Bicycle都只有一個Parts物件。

https://ithelp.ithome.com.tw/upload/images/20230916/20145409AkiWW3yGpE.jpg

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

建立 Parts 層次結構

Bicycle裡移除的parts行為轉移到一個新的Parts層次結構,Parts擁有兩個子類別:RoadBikePartsMountainBikeParts

https://ithelp.ithome.com.tw/upload/images/20230916/20145409c5rrDTydN6.jpg

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變數,另外,只要擁有的是RoadBikePartsMountainBikeParts,自行車都可以正確地回應其sizespares

組合 Parts 物件

要表示個別零件,需要創建一個Part類別,Part類別用於代表個別零件的特性和行為。由於已經存在一個名為Parts的類別,引入Part類別可能會造成溝通問題,在命名上要區分單複數。

零件淸單會包含一長串的個別零件,現在要開始增加能夠表示個別零件的類別,簡單地說我們的目標是有一個Parts物件,它可能包含多個Part物件。

建立 Part

  • Bicycle會傳送sparesParts,接著Parts物件會傳送needs_spare給每一個Part
  • 在直線上靠近Part的「1..」表示一個Parts擁有一個或更多的Part物件。

https://ithelp.ithome.com.tw/upload/images/20230916/20145409jNSTntlZnT.jpg

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的方式有下面兩種:

  1. 有了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])
    
  2. 建立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物件陣列。

讓 Parts 物件更像是一個陣列

照理說Bicyclepartsspares都應該傳回相同的內容,然而物件所回傳的並不相同。

  • spares會傳回一個陣列(由Part物件組成),且Array能夠明白size
  • parts會傳回Parts實例,但它對size並不理解,因此造成以下錯誤。
mountain_bike.spares.size # -> 3
mountain_bike.parts.size
# -> NoMethodError:
# undefined method 'size' for #<Parts:...>

因此,我們必須做以下修改:

  • extendForwardable模組,這允許你將某些方法的呼叫委派給@parts實例變數中的物件。這樣做可以讓Parts類別具有sizeeach方法,這些方法實際上是由 @parts物件提供的
  • include Enumerable,這表示你可以在Parts上使用像select這樣的 Enumerable方法。
  • 使用了Parts類別來包裝一個陣列,該陣列包含了自行車的零件(例如chainmountain_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

建立 Parts

回顧上方程式碼(4~7行)。Part物件存放在chainmountain_tire等變數裡面。 在應用程式裡的某個地方,會有物件必須要知道如何建立這些Part物件。而在上方的程式碼(4~7行),這種知識泄漏將特定物件的建立細節暴露給其他部分,不是一個理想的設計方式。

建立 PartsFactory

這邊會使用到工廠模式,協助我們建立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",
#.        等等.....

借助 PartsFactory

OpenStruct的彈性比Struct更大,它的初始化參數是一個hash,可以把屬性當做method來處理,直接指定屬性內容、直接讀取。(關於OpenStructStruct的差異與使用,大家可以參考這篇文章

修改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

現在,它會傳回一個PartsParts含有一個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物件,透過組合的方式來運作,今天,先組完PartsPart;明天,我們繼續組Bicycle

參考資料:


上一篇
Day15 CH7使用模組共用角色行爲(下)
下一篇
Day17 CH8組合物件(下)
系列文
入坑 RoR 必讀 - Ruby 物件導向設計實踐30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言