B.2 列表
從很多方面來說,列表是最簡單也最自然的集合型別。框架中包含很多實現,具有各種功能 和效能特徵。一些常用的實現在哪裡都可以使用,而一些較有難度的實現則有其專門的使用場景。
B.2.1 List<T>
在大多數情況下, List<T> 都是列表的預設選擇。它實現了 IList<T> ,因此也實現了 ICollection<T> 、 IEnumerable<T> 和 IEnumerable 。此外,它還實現了非泛型的 ICollection 和 IList 介面,並在必要時進行裝箱和拆箱,以及進行執行時型別檢查,以保證新元素始終與 T 相容。
List<T> 在內部儲存了一個數組,它跟蹤列表的邏輯大小和後臺陣列的大小。向列表中新增 元素,在簡單情況下是設定陣列的下一個值,或(如果陣列已經滿了)將現有內容複製到新的更 大的陣列中,然後再設定值。這意味著該操作的複雜度為O(1)或O(n),取決於是否需要複製值。 擴充套件策略沒有在文件中指出,因此也不能保證——但在實踐中,該方法通常可以擴充為所需大小 的兩倍。這使得向列表末尾附加項為O(1)平攤複雜度(amortized complexity);有時耗時更多,但 這種情況會隨著列表的增加而越來越少。
你可以通過獲取和設定 Capacity 屬性來顯式管理後臺陣列的大小。 TrimExcess 方法可以 使容量等於當前的大小。實戰中很少有必要這麼做,但如果在建立時已經知道列表的實際大小, 則可將初始的容量傳遞給建構函式,從而避免不必要的複製。
從 List<T> 中移除元素需要複製所有的後續元素,因此其複雜度為O(nk),其中k為移除元 素的索引。從列表尾部移除要比從頭部移除廉價得多。另一方面,如果要通過值移除元素而不是 索引(通過 Remove 而不是 RemoveAt ),那麼不管元素位置如何複雜度都為O(n):每個元素都將 得到平等的檢查或打亂。
List<T> 中的各種方法在一定程度上扮演著LINQ前身的角色。 ConvertAll 可進行列表投影; FindAll 對原始列表進行過濾,生成只包含匹配指定謂詞的值的新列表。 Sort 使用型別預設的或 作為引數指定的相等比較器進行排序。但 Sort 與LINQ中的 OrderBy 有個顯著的不同: Sort 修改原 始列表的內容,而不是生成一個排好序的副本。並且, Sort 是不穩定的,而 OrderBy 是穩定的; 使用 Sort 時,原始列表中相等元素的順序可能會不同。LINQ不支援對 List<T> 進行二進位制搜尋: 如果列表已經按值正確排序了, BinarySearch 方法將比線性的 IndexOf 搜尋效率更高 ① 。
List<T> 中略有爭議的部分是 ForEach 方法。顧名思義,它遍歷一個列表,並對每個值都執行 某個委託(指定為方法的引數)。很多開發者要求將其作為 IEnumerable<T> 的擴充套件方法,但卻一 直沒能如願;Eric Lippert在其部落格中講述了這樣做會導致哲學麻煩的原因(參見http://mng.bz/Rur2)。 在我看來使用Lambda表示式呼叫 ForEach 有些矯枉過正。另一方面,如果你已經擁有一個要為列 表中每個元素都執行一遍的委託,那還不如使用 ForEach ,因為它已經存在了。① 二進位制搜尋的複雜度為O(log n),線性搜尋為O(n)。
B.2.2 陣列
在某種程度上,陣列是.NET中最低階的集合。所有陣列都直接派生自 System.Array ,也是 唯一的CLR直接支援的集合。一維陣列實現了 IList<T> (及其擴充套件的介面)和非泛型的 IList 、 ICollection 介面;矩形陣列只支援非泛型介面。陣列從元素角度來說是易變的,從大小角度 來說是固定的。它們顯示實現了集合介面中所有的可變方法(如 Add 和 Remove ),並丟擲 NotSupportedException 。
引用型別的陣列通常是協變的;如 Stream[] 引用可以隱式轉換為 Object[] ,並且存在顯式 的反向轉換 ① 。這意味著將在執行時驗證陣列的改變——陣列本身知道是什麼型別,因此如果先 將 Stream[] 陣列轉換為 Object[] ,然後再試圖向其儲存一個非 Stream 的引用,則將丟擲 ArrayTypeMismatchException 。
CLR包含兩種不同風格的陣列。向量是下限為0的一維陣列,其餘的統稱為陣列(array)。向 量的效能更佳,是C#中最常用的。 T[][] 形式的陣列仍然為向量,只不過元素型別為 T[] ;只有 C#中的矩形陣列,如 string[10, 20] ,屬於CLR術語中的陣列。在C#中,你不能直接建立非 零下限的陣列——需要使用 Array.CreateInstance 來建立,它可以分別指定下限、長度和元 素型別。如果建立了非零下限的一維陣列,就無法將其成功轉換為 T[] ——這種強制轉換可以通 過編譯,但會在執行時失敗。
C#編譯器在很多方面都內嵌了對陣列的支援。它不僅知道如何建立陣列及其索引,還可以在 foreach 迴圈中直接支援它們;在使用表示式對編譯時已知為陣列的型別進行迭代時,將使用 Length 屬性和陣列索引器,而不會建立迭代器物件。這更高效,但效能上的區別通常忽略不計。
與 List<T> 相同,陣列支援 ConvertAll 、 FindAll 和 BinarySearch 方法,不過對陣列來 說,這些都是 Array 類的以陣列為第一個引數的靜態方法。
回到本節最開始所說的,陣列是相當低階的資料結構。它們是其他集合的重要根基,在適當 的情況下有效,但在大量使用之前還是應該三思。Eric同樣為該話題撰寫了部落格,指出它們有“些 許害處”(參見http://mng.bz/3jd5)。我不想誇大這一點,但在選擇陣列作為集合型別時,這是一 個值得注意的缺點。
① 容易混淆的是,也可以將 Stream[] 隱式轉換為 IList<Object> ,儘管 IList<T> 本身是不變的。
B.2.3 LinkedList<T>
什麼時候列表不是list呢?答案是當它為連結串列的時候。 LinkedList<T> 在很多方面都是一個 列表,特別的,它是一個保持項新增順序的集合——但它卻沒有實現 IList<T> 。因為它無法遵 從通過索引進行訪問的隱式契約。它是經典的電腦科學中的雙向連結串列:包含頭節點和尾節點, 每個節點都包含對連結串列中前一個節點和後一個節點的引用。每個節點都公開為一個 LinkedListNode<T> ,這樣就可以很方便地在連結串列的中部插入或移除節點。連結串列顯式地維護其 大小,因此可以訪問 Count 屬性。
在空間方面,連結串列比維護後臺陣列的列表效率要低,同時它還不支援索引操作,但在連結串列中的 任意位置插入或移除元素則非常快,前提是隻要在相關位置存在對該節點的引用。這些操作的複雜 度為O(1),因為所需要的只是對周圍的節點修改前/後的引用。插入或移除頭尾節點屬於特殊情況, 通常可以快速訪問需要修改的節點。迭代(向前或向後)也是有效的,只需要按引用鏈的順序即可。
儘管 LinkedList<T> 實現了 Add 等標準方法(向連結串列末尾新增節點),我還是建議使用顯式 的 AddFirst 和 AddLast 方法,這樣可以使意圖更清晰。它還包含匹配的 RemoveFirst 和 RemoveLast 方法,以及 First 和 Last 屬性。所有這些操作返回的都是連結串列中的節點而不是節點 的值;如果連結串列是空(empty)的,這些屬性將返回空(null)。
B.2.4 Collection<T> 、 BindingList<T> 、 ObservableCollection<T> 和 KeyedCollection<TKey, TItem>
Collection<T> 與我們將要介紹的剩餘列表一樣,位於 System.Collections.Object- Model 名稱空間。與 List<T> 類似,它也實現了泛型和非泛型的集合介面。
儘管你可以對其自身使用 Collection<T> ,但它更常見的用法是作為基類使用。它常扮演 其他列表的包裝器的角色:要麼在建構函式中指定一個列表,要麼在後臺新建一個 List<T> 。所 有對於集合的變動行為,都通過受保護的虛方法( InsertItem 、 SetItem 、 RemoveItem 和 ClearItems )實現。派生類可以攔截這些方法,引發事件或提供其他自定義行為。派生類可通 過 Items 屬性訪問被包裝的列表。如果該列表為只讀,公共的變動方法將丟擲異常,而不再呼叫 虛方法,你不必在覆蓋的時候再次檢查。 BindingList<T> 和 ObservableCollection<T> 派生自 Collection<T> ,可以提供繫結 功能。
BindingList<T> 在.NET 2.0中就存在了,而 ObservableCollection<T> 是WPF (Windows Presentation Foundation)引入的。當然,在使用者介面繫結資料時沒有必要一定使用它 們——你也許有自己的理由,對列表的變化更有興趣。這時,你應該觀察哪個集合以更有用的方 式提供了通知,然後再選擇使用哪個。注意,只會通知你通過包裝器所發生的變化;如果基礎列 表被其他可能會修改它的程式碼共享,包裝器將不會引發任何事件。
KeyedCollection<TKey, TItem> 是列表和字典的混合產物,可以通過鍵或索引來獲取項。 與普通字典不同的是,鍵不能獨立存在,應該有效地內嵌在項中。在許多情況下,這很自然,例 如一個擁有 CustomerID 屬性的 Customer 型別。 KeyedCollection<,> 為抽象類;派生類將實 現 GetKeyForItem 方法,可以從列表中的任意項中提取鍵。在我們這個客戶的示例中, GetKeyForItem 方法返回給定客戶的ID。與字典類似,鍵在集合中必須是唯一的——試圖新增 具有相同鍵的另一個項將失敗並丟擲異常。儘管不允許空鍵,但 GetKeyForItem 可以返回空(如 果鍵型別為引用型別),這時將忽略鍵(並且無法通過鍵獲取項)。
B.2.5 ReadOnlyCollection<T> 和 ReadOnlyObservableCollection<T>
最後兩個列表更像是包裝器,即使基礎列表為易變的也只提供只讀訪問。它們仍然實現了泛型和非泛型的集合介面。並且混合使用了顯式和隱式的介面實現,這樣使用具體型別的編譯時表示式的呼叫者將無法使用變動操作。
ReadOnlyObservableCollection<T> 派生自 ReadOnlyCollection<T> ,並和 Observerble- Collection<T> 一樣實現了相同的 INotifyCollectionChanged 和 INotifyPro pertyChanged 介面。 ReadOnlyObservableCollection<T> 的例項只能通過一個 ObservableCollection<T> 後臺列表進行構建。儘管集合對呼叫者來說依然是隻讀的,但它們可以觀察對後臺列表其他地方的 改變。
儘管通常情況下我建議使用介面作為API中方法的返回值,但特意公開 ReadOnly- Collection<T> 也是很有用的,它可以為呼叫者清楚地指明不能修改返回的集合。但仍需寫明 基礎集合是否可以在其他地方修改,或是否為有效的常量。