針對一個特定問題的解決,如果事先不知道需要多少個對象,或者它們的持續時間有多長,那麼也不知道如何保存那些對象。既然如此,怎樣才能知道那些對象要求多少空間呢?事先上根本無法提前知道,除非進入運行期。
在面向對象的設計中,大多數問題的解決辦法似乎都有些輕率——只是簡單地創建另一種類型的對象。用於解決特定問題的新型對象容納了指向其他對象的句柄。當然,也可以用數組來做同樣的事情,那是大多數語言都具有的一種功能。但不能只看到這一點。這種新對象通常叫作“集合”(亦叫作一個“容器”,但AWT在不同的場合應用了這個術語,所以本書將一直沿用“集合”的稱呼。在需要的時候,集合會自動擴充自己,以便適應我們在其中置入的任何東西。所以我們事先不必知道要在一個集合裡容下多少東西。只需創建一個集合,以後的工作讓它自己負責好了。
幸運的是,設計優良的OOP語言都配套提供了一系列集合。在C++中,它們是以“標准模板庫”(STL)的形式提供的。Object Pascal用自己的“可視組件庫”(VCL)提供集合。Smalltalk提供了一套非常完整的集合。而Java也用自己的標准庫提供了集合。在某些庫中,一個常規集合便可滿足人們的大多數要求;而在另一些庫中(特別是C++的庫),則面向不同的需求提供了不同類型的集合。例如,可以用一個矢量統一對所有元素的訪問方式;一個鏈接列表則用於保證所有元素的插入統一。所以我們能根據自己的需要選擇適當的類型。其中包括集、隊列、散列表、樹、堆棧等等。
所有集合都提供了相應的讀寫功能。將某樣東西置入集合時,采用的方式是十分明顯的。有一個叫作“推”(Push)、“添加”(Add)或其他類似名字的函數用於做這件事情。但將數據從集合中取出的時候,方式卻並不總是那麼明顯。如果是一個數組形式的實體,比如一個矢量(Vector),那麼也許能用索引運算符或函數。但在許多情況下,這樣做往往會無功而返。此外,單選定函數的功能是非常有限的。如果想對集合中的一系列元素進行操縱或比較,而不是僅僅面向一個,這時又該怎麼辦呢?
辦法就是使用一個“繼續器”(Iterator),它屬於一種對象,負責選擇集合內的元素,並把它們提供給繼承器的用戶。作為一個類,它也提供了一級抽象。利用這一級抽象,可將集合細節與用於訪問那個集合的代碼隔離開。通過繼承器的作用,集合被抽象成一個簡單的序列。繼承器允許我們遍歷那個序列,同時毋需關心基礎結構是什麼——換言之,不管它是一個矢量、一個鏈接列表、一個堆棧,還是其他什麼東西。這樣一來,我們就可以靈活地改變基礎數據,不會對程序裡的代碼造成干擾。Java最開始(在1.0和1.1版中)提供的是一個標准繼承器,名為Enumeration(枚舉),為它的所有集合類提供服務。Java 1.2新增一個更復雜的集合庫,其中包含了一個名為Iterator的繼承器,可以做比老式的Enumeration更多的事情。
從設計角度出發,我們需要的是一個全功能的序列。通過對它的操縱,應該能解決自己的問題。如果一種類型的序列即可滿足我們的所有要求,那麼完全沒有必要再換用不同的類型。有兩方面的原因促使我們需要對集合作出選擇。首先,集合提供了不同的接口類型以及外部行為。堆棧的接口與行為與隊列的不同,而隊列的接口與行為又與一個集(Set)或列表的不同。利用這個特征,我們解決問題時便有更大的靈活性。
其次,不同的集合在進行特定操作時往往有不同的效率。最好的例子便是矢量(Vector)和列表(List)的區別。它們都屬於簡單的序列,擁有完全一致的接口和外部行為。但在執行一些特定的任務時,需要的開銷卻是完全不同的。對矢量內的元素進行的隨機訪問(存取)是一種常時操作;無論我們選擇的選擇是什麼,需要的時間量都是相同的。但在一個鏈接列表中,若想到處移動,並隨機挑選一個元素,就需付出“慘重”的代價。而且假設某個元素位於列表較遠的地方,找到它所需的時間也會長許多。但在另一方面,如果想在序列中部插入一個元素,用列表就比用矢量劃算得多。這些以及其他操作都有不同的執行效率,具體取決於序列的基礎結構是什麼。在設計階段,我們可以先從一個列表開始。最後調整性能的時候,再根據情況把它換成矢量。由於抽象是通過繼承器進行的,所以能在兩者方便地切換,對代碼的影響則顯得微不足道。
最後,記住集合只是一個用來放置對象的儲藏所。如果那個儲藏所能滿足我們的所有需要,就完全沒必要關心它具體是如何實現的(這是大多數類型對象的一個基本概念)。如果在一個編程環境中工作,它由於其他因素(比如在Windows下運行,或者由垃圾收集器帶來了開銷)產生了內在的開銷,那麼矢量和鏈接列表之間在系統開銷上的差異就或許不是一個大問題。我們可能只需要一種類型的序列。甚至可以想象有一個“完美”的集合抽象,它能根據自己的使用方式自動改變基層的實現方式。