天天看點

《面向對象設計實踐指南:Ruby語言描述》—第8章 8.2節組合成Parts對象

本節書摘來自異步社群《面向對象設計實踐指南: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.

《面向對象設計實踐指南:Ruby語言描述》—第8章 8.2節組合成Parts對象

以這種方式對設計進行更改,會要求建立新的Part對象。那個Parts對象現在由Part對象組合而成,如圖8-5裡的類圖所示。在直線上靠近Part的“1..*”所表示的是:一個Parts擁有一個及以上的Part對象。

《面向對象設計實踐指南:Ruby語言描述》—第8章 8.2節組合成Parts對象

引入新的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類。你現在應該重新考慮一下這個設計。

本文僅用于學習和交流目的,不代表異步社群觀點。非商業轉載請注明作譯者、出處,并保留本文的原始連結。