本節書摘來自異步社群《面向對象設計實踐指南:Ruby語言描述》一書中的第8章,第8.2節組合成Parts對象,作者【美】Sandi Metz,更多章節内容可以通路雲栖社群“異步社群”公衆号檢視。
8.2 組合成Parts對象
面向對象設計實踐指南:Ruby語言描述
很明顯,零件清單會包含一長串的單個零件。現在應該添加表示單個零件的類了。單個零件的類名顯然應該為Part。不過,當你已擁有一個Parts類時,引入Part類會讓交談變得很困難。當同樣的這個名字已經用于指代單個的Parts對象時,使用“parts”一詞來指代一堆的Part對象很容易讓人感到困惑。不過,前面的措辭說明了一種會順帶引起交流問題的技術。當在讨論Part和Parts時,你可以在類名之後帶上“object”一詞,如有必要還可以使用複數的“object”。
你可以在一開始就避免出現這種交流問題,方法是選擇不同的類名。但其他的名字可能沒那麼好的表現力,并且很可能引入新的溝通問題。這種“Parts/Part”情形很常見,需要正面對待。選擇這些類名稱需要一次準确的交流,這才是其自身追求的目标。
是以,有一個Parts對象,它可能包含多個Part對象,就這麼簡單。
8.2.1 建立Part
圖8-4展示了一張新的時序圖,它說明的是Bicycle與其Parts對象之間,以及Parts對象同其Part對象之間的會話。Bicycle會将spares發送給Parts,接着Parts對象會将needs_spare發送給每一個Part.

以這種方式對設計進行更改,會要求建立新的Part對象。那個Parts對象現在由Part對象組合而成,如圖8-5裡的類圖所示。在直線上靠近Part的“1..*”所表示的是:一個Parts擁有一個及以上的Part對象。
引入新的Part類,可以大大簡化已有的Parts類。它現在已變成了一個簡單的包裹器,将一組Part對象包裹在一起。Parts可以過濾Part對象清單,并傳回那些需要備件的Part對象。下面的代碼展示了三個類:現有的Bicycle類,更新後的Parts類和新引入的Part類。
1 class Bicycle
2 attr_reader :size, :parts
3
4 def initialize(args ={})
5 @size = args[:size]
6 @parts = args[:parts]
7 end
8
9 def spares
10 parts.spares
11 end
12 end
13
14 class Parts
15 attr_reader :parts
16
17 def initialize(parts)
18 @parts = parts
19 end
20
21 def spares
22 parts.select {|part| part.needs_spare}
23 end
24 end
25
26 class Part
27 attr_reader :name, :description, :needs_spare
28
29 def initialize(args)
30 @name = args[:name]
31 @description = args[:description]
32 @needs_spare = args.fetch(:needs_spare, true)
33 end
34 end
有了三個類之後,你便可以建立單個的Part對象。下面的代碼建立了多個不一樣的零件,并将每一個儲存在某個執行個體變量裡。
1 chain =
2 Part.new(name: 'chain', description: '10-speed')
3
4 road_tire =
5 Part.new(name: 'tire_size', description: '23')
6
7 tape =
8 Part.new(name: 'tape_color', description: 'red')
9
10 mountain_tire =
11 Part.new(name: 'tire_size', description: '2.1')
12
13 rear_shock =
14 Part.new(name: 'rear_shock', description: 'Fox')
15
16 front_shock=
17 Part.new(
18 name: 'front_shock',
19 description: 'Manitou',
20 needs_spare: false)
單個的Part對象可以被組合成Parts。下面的代碼将公路自行車的Part對象組合成了适合公路自行車的Parts。
1 road_bike_parts =
2 Parts.new([chain, road_tire, tape])
當然,你也可以跳過這個中間步驟,在建立Bicycle時簡單、迅速地建構Parts對象,如下面第4~6行和第22~25行所示。
1 road_bike =
2 Bicycle.new(
3 size: 'L',
4 parts: Parts.new([chain,
5 road_tire,
6 tape]))
7
8 road_bike.size # -> 'L'
9
10 road_bike.spares
11 # -> [#<Part:0x00000101036770
12 # @name="chain",
13 # @description="10-speed",
14 # @needs_spare=true>,
15 # #<Part:0x0000010102dc60
16 # @name="tire_size",
17 # etc ...
18
19 mountain_bike =
20 Bicycle.new(
21 size: 'L',
22 parts: Parts.new([chain,
23 mountain_tire,
24 front_shock,
25 rear_shock]))
26
27 mountain_bike.size # -> 'L'
28
29 mountain_bike.spares
30 # -> [#<Part:0x00000101036770
31 # @name="chain",
32 # @description="10-speed",
33 # @needs_spare=true>,
34 # #<Part:0x0000010101b678
35 # @name="tire_size",
36 # etc ...
正如從上面的第8~17行和第27~34行所看到的,這種新的代碼編排很有效,并且其行為跟原來的那個Bicycle層次結構幾乎完全一樣。這裡有一點差别,即Bicycle原有的spares方法會傳回一個散清單,而新的spares方法傳回的是一個Part對象數組。
雖然有也可以把這些對象當作是Part的執行個體,但是組合是要告訴你把它們當作扮演Part角色的對象。它們不一定是Part類類型,隻需表現得像即可。也就是說,它們必須響應name、description和needs_spare。
8.2.2 讓Parts對象更像一個數組
這段代碼也可以工作,但很明顯還有改進的空間。時間倒退片刻,請想想Bicycle裡的parts和spares。感覺這些消息應該傳回相同的内容,然而回過頭來一看,這些對象的表現方式并不相同。當你向每一個零件詢問其大小時,會發生什麼事情呢?一起來看看。
在下面的第1行,spares開心地報告它的size為3。然而,在向parts問同樣的問題時,實際情況卻并非如此,如第2~4行所示。
1 mountain_bike.spares.size # -> 3
2 mountain_bike.parts.size
3 # -> NoMethodError:
4 # undefined method 'size' for #<Parts:...>
第1行可以工作,因為spares會傳回一個數組(由Part對象組成),且Array能夠明白size。第2行失敗的因為在于parts會傳回Parts執行個體,而它對size并不了解。
隻要你擁有這種代碼,類似的失敗會不斷纏繞着你。這兩個事物看起來都很像數組。你不可避免地會把它們當成這個樣子,盡管事實上恰好對了一半,但其結果就會像是踩在諺語常說的“院子裡的釘耙”上。那個Parts對象并不像數組,所有把它當作數組的嘗試都會失敗。
往Parts裡添加size方法,可以快速地解決眼前這個問題。實作一個方法,将size委托給實際的數組,這是件很簡單的事情。如下所示。
1 def size
2 parts.size
3 end
不過,這種更改開始會讓Parts類走下坡路。如果這樣做,那麼過不了多久你就會想要Parts對each做出響應,接着響應sort,然後響應Array裡的其他所有事情。永無止境!越讓Parts像數組,你會越期望它是一個數組。
也許Parts就是一個數組,雖然它多了一點額外的行為。你可以讓它成為一個數組。下面這個示例展示了一個新版的Parts類。現在它是作為Array的一個子類。
1 class Parts < Array
2 def spares
3 select {|part| part.needs_spare}
4 end
5 end
上面這段代碼直截了當地表達了這樣一個思想,即Parts是Array的特殊化。在完美的面向對象語言裡,該解決方案完全正确。不幸的是,Ruby語言還不夠完美,并且這個設計隐藏着一個缺陷。
下面這個示例可以說明這一問題。當Parts成為Array的子類時,它繼承了Array的所有行為。這種行為包括了像“+”那樣的方法,這個方法會将兩個數組連接配接在一起,并且傳回第三個。下面的第3、4行展示了這樣一個過程:“+”将兩個現有的Parts執行個體結合在一起,并将結果儲存到combo_parts變量。
這個似乎可以工作:combo_parts現在會包含正确的零件數量(第7行)。然而,事情明顯不正确。如第12行所示,combo_parts無法回答其spares。
這個問題的根源暴露在第15~17行。盡管“+”連接配接的對象是Parts執行個體,但“+”所傳回的對象即是Array執行個體,而Array并不明白spares是什麼回事。
1 # Parts從Array繼承了'+',
2 # 是以你可以将兩個Parts相加。
3 combo_parts =
4 (mountain_bike.parts + road_bike.parts)
5
6 # '+'肯定會對Parts進行組合
7 combo_parts.size # -> 7
8
9 # 不過'+'所傳回的那個對象
10 # 并不了解'spares'
11 combo_parts.spares
12 # -> NoMethodError: undefined method 'spares'
13 # for #<Array:...>
14
15 mountain_bike.parts.class # -> Parts
16 road_bike.parts.class # -> Parts
17 combo_parts.class # -> Array !!!
結果表明:在Array裡,有許多方法都會傳回新的數組,并且不幸的是,這些方法會傳回新的Array類執行個體,而不是那個新子類的執行個體。Parts類仍然會誤導人,而你隻是将一個問題變換成另外一個。一旦你失望地發現Parts并沒有實作size,那麼你現在可能會驚訝地發現:将兩個Parts加在一起會傳回一個讓spares無法了解的結果。
你已看過了三種不同的Parts實作。第一種實作隻響應了spares和parts消息。它不像數組,它隻是包含一個數組。第二種Parts實作添加了size。它隻是做了一點細微的改進,并傳回了其内部的數組大小。最後那個Parts實作了Array子類,是以其外在表現就像是一個數組,但如上面的示例子所展示的,Parts執行個體仍然會表現出意想不到的行為。
現在已很明顯,并沒有完美的解決方案。是以,現在要做一個艱難的決定。盡管它不能響應size,但原來的Parts實作可能已經夠好了。如果是這樣,那麼你可以接受它缺乏類似數組一樣的行為,并恢複到該版本。如果你需要size,而size不存在,那麼最好是隻添加這一個方法。是以,第二個實作可接受。如果你能容忍出現錯誤混淆的問題,或者你非常确定你永遠不會遇到它們,那麼成為Array的子類并安靜地走開也具有意義。
在複雜性和可用性之間的中間區域的某個地方,會有下面這樣的解決方案。下面的Parts類将size和each委托給了它的@parts數組,并包含Enumerable,以獲得公共的周遊和檢索方法。Parts的這個版本并沒有Array的所有行為,但它宣稱的所有事情至少都可以工作。
1 require 'forwardable'
2 class Parts
3 extend Forwardable
4 def_delegators :@parts, :size, :each
5 include Enumerable
6
7 def initialize(parts)
8 @parts = parts
9 end
10
11 def spares
12 select {|part| part.needs_spare}
13 end
14 end
将“+”發送給自己的Parts的執行個體會導緻NoMethodError異常。不過,由于Parts現在可以響應size、each以及所有的Enumerable消息,并且當你錯誤地将它當作是一個實際的數組時會合理地引發錯誤,是以這段代碼已很不錯了。下面的示例表明spares和parts現在都可以響應size。
1 mountain_bike =
2 Bicycle.new(
3 size: 'L',
4 parts: Parts.new([chain,
5 mountain_tire,
6 front_shock,
7 rear_shock]))
8
9 mountain_bike.spares.size # -> 3
10 mountain_bike.parts.size # -> 4
又多了一版可工作的Bicycle、Parts和Part類。你現在應該重新考慮一下這個設計。
本文僅用于學習和交流目的,不代表異步社群觀點。非商業轉載請注明作譯者、出處,并保留本文的原始連結。