.NET中的泛型集合總結
最近對集合相關的命名空間比較感興趣,以前也就用下List<T>, Dictionary<Tkey, TValue>之類,總之,比較小白。點開N多博客,MSDN,StackOverflow,沒找到令我完全滿意的答案,本打算自己總結下寫出來,工作量好大的感覺……直到昨晚隨意翻到看了一些又放下的《深入理解C#》-附錄B部分,高興地簡直要叫出來——“這總結真是太絕了,好書不愧是好書”。真是“踏破鐵鞋無覓處,得來全不費工夫”,最好的資源就在眼下,而自己居然渾然不知。或許只有深入技術細節的時候,才能認識到經典為什麽是經典的原因吧!言歸正傳,本博客主要是對《深入理解C#》-附錄B的摘錄,並加了些標註。
附錄B .NET中的泛型集合
.NET中包含很多泛型集合,並且隨著時間的推移列表還在增長。本附錄涵蓋了最重要的泛型集合接口和類,但不會涉及System.Collections
、System.Collections.Specialized
和System.ComponentModel
中的非泛型集合。同樣,也不會涉及ILookup<TKey,TValue>
這樣的LINQ接口。本附錄是參考而非指南——在寫代碼時,可以用它來替代MSDN。在大多數情況下,MSDN顯然會提供更詳細的內容,但這裏的目的是在選擇代碼中要用的特定集合時,可以快速瀏覽不同的接口和可用的實現。
我沒有指出各集合是否為線程安全,MSDN中有更詳細的信息。普通的集合都不支持多重並發寫操作;有些支持單線程寫和並發讀操作。B.6節列出了.NET 4中添加的並發集合。此外,B.7節介紹了.NET4.5中引入的只讀集合接口。
B.1 接口
幾乎所有要學習的接口都位於System.Collections.Generic
命名空間。圖B-1展示了.NET4.5以前主要接口間的關系,此外還將非泛型的IEnumerable
作為根接口包括了進來。為避免圖表過於復雜,此處沒有包含.NET 4.5的只讀接口。
圖B-1 System.Collections.Generic中的接口(不包括.NET 4.5)
正如我們已經多次看到的,最基礎的泛型集合接口為IEnumerable<T>
,表示可叠代的項的序列。IEnumerable<T>
可以請求一個IEnumerator<T>
類型的叠代器。由於分離了可叠代序列和叠代器,這樣多個叠代器可以同時獨立地操作同一個序列。如果從數據庫角度來考慮,表就是IEnumerable<T>
IEnumerator<T>
。本附錄僅有的兩個可變(variant)集合接口為.NET 4中的IEnumerable<out T>
和IEnumerator<out T>
;其他所有接口的元素類型值均可雙向進出,因此必須保持不變。
接下來是ICollection<T>
,它擴展了IEnumerable<T>
,添加了兩個屬性(Count
和IsReadOnly
)、變動方法(Add
、Remove
和Clear
)、CopyTo
(將內容復制到數組中)和Contains
(判斷集合是否包含特殊的元素)。所有標準的泛型集合實現都實現了該接口。
IList<T>
全都是關於定位的:它提供了一個索引器、InsertAt
和RemoveAt
(分別與Add
和Remove
相同,但可以指定位置),以及IndexOf
(判斷集合中某元素的位置)。對IList<T>
進行叠代時,返回項的索引通常為0、1,以此類推。文檔裏沒有完整的記錄,但這是個合理的假設。同樣,通常認為可以快速通過索引對IList<T>
進行隨機訪問。
IDictionary<TKey, TValue>
表示一個獨一無二的鍵到它所對應的值的映射。值不必是唯一的,而且也可以為空;而鍵不能為空。可以將字典看成是鍵/值對的集合,因此IDictionary<TKey, TValue>
擴展了ICollection<KeyValuePair<TKey, TValue>>
。獲取值可以通過索引器或TryGetValue
方法;與非泛型IDictionary
類型不同,如果試圖用不存在的鍵獲取值,IDictionary<TKey, TValue>
的索引器將拋出一個KeyNotFoundException
。TryGetValue
的目的就是保證在用不存在的鍵進行探測時還能正常運行。
ISet<T>
是.NET 4新引入的接口,表示唯一值集。它反過來應用到了.NET 3.5中的HashSet<T>
上,以及.NET 4引入的一個新的實現——SortedSet<T>
。
在實現功能時,使用哪個接口(甚至實現)是十分明顯的。難的是如何將集合作為API的一部分公開;返回的類型越具體,調用者就越依賴於你指定類型的附加功能。這可以使調用者更輕松,但代價是降低了實現的靈活性。我通常傾向於將接口作為方法和屬性的返回類型,而不是保證一個特定的實現類。在API中公開易變集合之前,你也應該深思熟慮,特別是當集合代表的是對象或類型的狀態時。通常來說,返回集合的副本或只讀的包裝器是比較適宜的,除非方法的全部目的就是通過返回集合做出變動。
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(n – k),其中k為移除元素的索引。從列表尾部移除要比從頭部移除廉價得多。另一方面,如果要通過值移除元素而不是索引(通過Remove
而不是RemoveAt
),那麽不管元素位置如何復雜度都為O(n):每個元素都將得到平等的檢查或打亂。
List<T>
中的各種方法在一定程度上扮演著LINQ前身的角色。ConvertAll
可進行列表投影;FindAll
對原始列表進行過濾,生成只包含匹配指定謂詞的值的新列表。Sort
使用類型默認的或作為參數指定的相等比較器進行排序。但Sort
與LINQ中的OrderBy
有個顯著的不同:Sort
修改原始列表的內容,而不是生成一個排好序的副本。並且,Sort
是不穩定的,而OrderBy
是穩定的;使用Sort
時,原始列表中相等元素的順序可能會不同。LINQ不支持對List<T>
進行二進制搜索:如果列表已經按值正確排序了,BinarySearch
方法將比線性的IndexOf
搜索效率更高( 二進制搜索的復雜度為O(log n),線性搜索為O(n))。
List<T>
中略有爭議的部分是ForEach
方法。顧名思義,它遍歷一個列表,並對每個值都執行某個委托(指定為方法的參數)。很多開發者要求將其作為IEnumerable<T>
的擴展方法,但卻一直沒能如願;Eric Lippert在其博客中講述了這樣做會導致哲學麻煩的原因(參見http://mng.bz/Rur2)。在我看來使用Lambda表達式調用ForEach
有些矯枉過正。另一方面,如果你已經擁有一個要為列表中每個元素都執行一遍的委托,那還不如使用ForEach
,因為它已經存在了。
B.2.2 數組
在某種程度上,數組是.NET中最低級的集合。所有數組都直接派生自System.Array
,也是唯一的CLR直接支持的集合。一維數組實現了IList<T>
(及其擴展的接口)和非泛型的IList
、ICollection
接口;矩形數組只支持非泛型接口。數組從元素角度來說是易變的,從大小角度來說是固定的。它們顯示實現了集合接口中所有的可變方法(如Add
和Remove
),並拋出NotSupportedException
。
引用類型的數組通常是協變的;如Stream[]
引用可以隱式轉換為Object[]
,並且存在顯式的反向轉換(容易混淆的是,也可以將Stream[]
隱式轉換為IList<Object>
,盡管IList<T>
本身是不變的)。這意味著將在執行時驗證數組的改變——數組本身知道是什麽類型,因此如果先將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)。我不想誇大這一點,但在選擇數組作為集合類型時,這是一個值得註意的缺點。
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.ObjectModel
命名空間。與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>
,並和ObserverbleCollection<T>
一樣實現了相同的INotifyCollectionChanged
和INotifyPropertyChanged
接口。ReadOnlyObservableCollection<T>
的實例只能通過一個ObservableCollection<T>
後臺列表進行構建。盡管集合對調用者來說依然是只讀的,但它們可以觀察對後臺列表其他地方的改變。
盡管通常情況下我建議使用接口作為API中方法的返回值,但特意公開ReadOnlyCollection<T>
也是很有用的,它可以為調用者清楚地指明不能修改返回的集合。但仍需寫明基礎集合是否可以在其他地方修改,或是否為有效的常量。
B.3 字典
在框架中,字典的選擇要比列表少得多。只有三個主流的非並發IDictionary<TKey, TValue>
實現,此外還有ExpandoObject
(第14章已介紹過)、ConcurrentDictionary
(將在介紹其他並發集合時介紹)和RouteValueDictionary
(用於路由Web請求,特別是在ASP.NET MVC中)也實現了該接口。
註意,字典的主要目的在於為值提供有效的鍵查找。
B.3.1 Dictionary<TKey, TValue>
如果沒有特殊需求,Dictionary<TKey, TValue>
將是字典的默認選擇,就像List<T>
是列表的默認實現一樣。它使用了散列表,可以實現有效的查找(參見http://mng.bz/qTdH),雖然這意味著字典的效率取決於散列函數的優劣。可使用默認的散列和相等函數(調用鍵對象本身的Equals
和GetHashCode
),也可以在構造函數中指定IEqualityComparer<TKey>
作為參數。
最簡單的示例是用不區分大小寫的字符串鍵實現字典,如代碼清單B-1所示。
代碼清單B-1 在字典中使用自定義鍵比較器
var comparer = StringComparer.OrdinalIgnoreCase;
var dict = new Dictionary<String, int>(comparer);
dict["TEST"] = 10;
Console.WriteLine(dict["test"]); //輸出10
盡管字典中的鍵必須唯一,但散列碼並不需要如此。兩個不等的鍵完全有可能擁有相同的散列碼;這就是散列沖突(hash collision)(http://en.wikipedia.org/wiki/Collision_(computer_science)——譯者註),盡管這多少會降低字典的效率,但卻可以正常工作。如果鍵是易變的,並且散列碼在插入後發生了改變,字典將會失敗。易變的字典鍵總是一個壞主意,但如果確實不得不使用,則應確保在插入後不會改變。
散列表的實現細節是沒有規定的,可能會隨時改變,但一個重要的方面可能會引起混淆:盡管Dictionary<TKey, TValue>
有時可能會按順序排列,但無法保證總是這樣。如果向字典添加了若幹項然後叠代,你會發現項的順序與插入時相同,但請不要信以為真。有點不幸的是,刻意添加條目以維持排序的實現可能會很怪異,而碰巧自然擾亂了排序的實現則可能帶來更少的混淆。
與List<T>
一樣,Dictionary<TKey, TValue>
將條目保存在數組中,並在必要的時候進行擴充,且擴充的平攤復雜度為O(1)。如果散列合理,通過鍵訪問的復雜度也為O(1);而如果所有鍵的散列碼都相等,由於要依次檢查各個鍵是否相等,因此最終的復雜度為O(n)。在大多數實際場合中,這都不是問題。
B.3.2 SortedList<TKey, TValue>
和SortedDictionary<TKey, TValue>
乍一看可能會以為名為SortedList<,>
的類為列表,但實則不然。這兩個類型都是字典,並且誰也沒有實現IList<T>
。如果取名為ListBackedSortedDictionary
和TreeBackedSortedDictionary
可能更加貼切,但現在改已經來不及了。
這兩個類有很多共同點:比較鍵時都使用IComparer<TKey>
而不是IEqualityComparer<TKey>
,並且鍵是根據比較器排好序的。在查找值時,它們的性能均為O(log n),並且都能執行二進制搜索。但它們的內部數據結構卻迥然不同:SortedList<,>
維護一個排序的條目數組,而SortedDictionary<,>
則使用的是紅黑樹結構(參見維基百科條目http://mng.bz/K1S4)。這導致了插入和移除時間以及內存效率上的顯著差異。如果要創建一個排序的字典,SortedList<,>
將被有效地填充,想象一下保持List<T>
排序的步驟,你會發現向列表末尾添加單項是廉價的(若忽略數組擴充的話將為O(1)),而隨機添加項則是昂貴的,因為涉及復制已有項(最糟糕的情況是O(n))。向SortedDictionary<,>
中的平衡樹添加項總是相當廉價(復雜度為O(log n)),但在堆上會為每個條目分配一個樹節點,這將使開銷和內存碎片比使用SortedList<,>
鍵值條目的數組要更多。
這兩種集合都使用單獨的集合公開鍵和值,並且這兩種情況下返回的集合都是活動的,因為它們將隨著基礎字典的改變而改變。但SortedList<,>
公開的集合實現了IList<T>
,因此可以使用排序的鍵索引有效地訪問條目。
我不想因為談論了這麽多關於復雜度的內容而給你造成太大困擾。如果不是海量數據,則可不必擔心所使用的實現。如果字典的條目數可能會很大,你應該仔細分析這兩種集合的性能特點,然後決定使用哪一個。
B.3.3 ReadOnlyDictionary<TKey, TValue>
熟悉了B.2.5節中介紹的ReadOnlyCollection<T>
後,ReadOnlyDictionary<TKey, TValue>
應該也不會讓你感到特別意外。ReadOnlyDictionary<TKey, TValue>
也只是一個圍繞已有集合(本例中指IDictionary<TKey, TValue>
)的包裝器而已,可隱藏顯式接口實現後所有發生變化的操作,並且在調用時拋出NotSupportedException
。
與只讀列表相同,ReadOnlyDictionary<TKey, TValue>
的確只是一個包裝器;如果基礎集合(傳入構造函數的集合)發生變化,則這些修改內容可通過包裝器顯現出來。
B.4 集
在.NET 3.5之前,框架中根本沒有公開集(set)集合。如果要在.NET 2.0中表示集,通常會使用Dictionary<,>
,用集的項作為鍵,用假數據作為值。.NET3.5的HashSet<T>
在一定程度上改變了這一局面,現在.NET 4還添加了SortedSet<T>
和通用的ISet<T>
接口。盡管在邏輯上,集接口應該只包含Add
/Remove
/Contains
操作,但ISet<T>
還指定了很多其他操作來控制集(ExceptWith
、IntersectWith
、SymmetricExceptWith
和UnionWith
)並在各種復雜條件下驗證集(SetEquals
、Overlaps
、IsSubsetOf
、IsSupersetOf
、IsProperSubsetOf
和IsProperSupersetOf
)。所有這些方法的參數均為IEnumerable<T>
而不是ISet<T>
,這乍看上去會很奇怪,但卻意味著集可以很自然地與LINQ進行交互。
B.4.1 HashSet<T>
HashSet<T>
是不含值的Dictionary<,>
。它們具有相同的性能特征,並且你也可以指定一個IEqualityComparer<T>
來自定義項的比較。同樣,HashSet<T>
所維護的順序也不一定就是值添加的順序。
HashSet<T>
添加了一個RemoveWhere
方法,可以移除所有匹配給定謂詞的條目。這可以在叠代時對集進行刪減,而不必擔心在叠代時不能修改集合的禁令。
B.4.2 SortedSet<T>
(.NET 4)
就像HashSet<T>
之於Dictionary<,>
一樣,SortedSet<T>
是沒有值的SortedDictionary<,>
。它維護一個值的紅黑樹,添加、移除和包含檢查(containment check)的復雜度為O(log n)。在對集進行叠代時,產生的是排序的值。
和HashSet<T>
一樣它也提供了RemoveWhere
方法(盡管接口中沒有),並且還提供了額外的屬性(Min
和Max
)用來返回最小和最大值。一個比較有趣的方法是GetViewBetween
,它返回介於原始集上下限之內(含上下限)的另一個SortedSet<T>
。這是一個易變的活動視圖——對於它的改變將反映到原始集上,反之亦然,如代碼清單B-2所示。
代碼清單B-2 通過視圖觀察排序集中的改變
var baseSet = new SortedSet<int> { 1, 5, 12, 20, 25 };
var view = baseSet.GetViewBetween(10, 20);
view.Add(14);
Console.WriteLine(baseSet.Count); //輸出6
foreach (int value in view)
{
Console.WriteLine(value); //輸出12、14、20
}
盡管GetViewBetween
很方便,卻不是免費的午餐:為保持內部的一致性,對視圖的操作可能比預期的更昂貴。尤其在訪問視圖的Count
屬性時,如果在上次遍歷之後基礎集發生了改變,操作的復雜度將為O(n)。所有強大的工具,都應該謹慎用之。
SortedSet<T>
的最後一個特性是它公開了一個Reverse()
方法,可以進行反序叠代。Enumerable.Reverse()
沒有使用該方法,而是緩沖了它調用的序列的內容。如果你知道要反序訪問排序集,使用SortedSet<T>
類型的表達式代替更通用的接口類型可能會更有用,因為可訪問這個更高效的實現。
B.5 Queue<T>和Stack<T>
隊列和棧是所有計算機科學課程的重要組成部分。它們有時分別指FIFO(先進先出)和LIFO(後進先出)結構。這兩種數據結構的基本理念是相同的:向集合添加項,並在其他時候移除。所不同的是移除的順序:隊列就像排隊進商店,排在第一位的將是第一個被接待的;棧就像一摞盤子,最後一個放在頂上的將是最先被取走的。隊列和棧的一個常見用途是維護一個待處理的工作項清單。
正如LinkedList<T>
一樣,盡管可使用普通的集合接口方法來訪問隊列和棧,但我還是建議使用指定的類,這樣代碼會更加清晰。
B.5.1 Queue<T>
Queue<T>
實現為一個環形緩沖區:本質上它維護一個數組,包含兩個索引,分別用於記住下一個添加項和取出項的位置(slot)。如果添加索引追上了移除索引,所有內容將被復制到一個更大的數組中。
Queue<T>
提供了Enqueue
和Dequeue
方法,用於添加和移除項。Peek
方法用來查看下一個出隊的項,而不會實際移除。Dequeue
和Peek
在操作空(empty)隊列時都將拋出InvalidOperationException
。對隊列進行叠代時,產生的值的順序與出隊時一致。
B.5.2 Stack<T>
Stack<T>
的實現比Queue<T>
還簡單——你可以把它想成是一個List<T>
,只不過它還包含Push
方法用於向列表末尾添加新項,Pop
方法用於移除最後的項,以及Peek
方法用於查看而不移除最後的項。同樣,Pop
和Peek
在操作空(empty)棧時將拋出InvalidOperationException
。對棧進行叠代時,產生的值的順序與出棧時一致——即最近添加的值將率先返回。
B.6 並行集合(.NET 4)
作為.NET 4並行擴展的一部分,新的System.Collections.Concurrent
命名空間中包含一些新的集合。它們被設計為在含有較少鎖的多線程並發操作時是安全的。該命名空間下還包含三個用於對並發操作的集合進行分區的類,但在此我們不討論它們。
B.6.1 IProducerConsumerCollection<T>
和BlockingCollection<T>
IProducerConsumerCollection<T>
被設計用於BlockingCollection<T>
,有三個新的集合實現了該接口。在描述隊列和棧時,我說過它們通常用於為稍後的處理存儲工作項;生產者/消費者模式是一種並行執行這些工作項的方式。有時只有一個生產者線程創建工作,多個消費者線程執行工作項。在其他情況下,消費者也可以是生產者,例如,網絡爬蟲(crawler)處理一個Web頁面時會發現更多的鏈接,供後續爬取。
IProducerConsumerCollection<T>
是生產者/消費者模式中數據存儲的抽象,BlockingCollection<T>
以易用的方式包裝該抽象,並提供了限制一次緩沖多少項的功能。BlockingCollection<T>
假設沒有東西會直接添加到包裝的集合中,所有相關方都應該使用包裝器來對工作項進行添加和移除。構造函數包含一個重載,不傳入IProducerConsumerCollection<T>
參數,而使用ConcurrentQueue<T>
作為後臺存儲。
IProducerConsumerCollection<T>
只提供了三個特別有趣的方法:ToArray
、TryAdd
和TryTake
。ToArray
將當前集合內容復制到新的數組中,這個數組是集合在調用該方法時的快照。TryAdd
和TryTake
都遵循了標準的TryXXX
模式,試圖向集合添加或移除項,返回指明成功或失敗的布爾值。它允許有效的失敗模式,降低了對鎖的需求。例如在Queue<T>
中,要把“驗證隊列中是否有項”和“如果有項就進行出隊操作”這兩個操作合並為一個,就需要一個鎖——否則Dequeue
就可能拋出異常(例如,當隊列有且僅有一個項時,兩個線程同時判斷它是否有項,並且都返回true,這時其中一個線程先執行了出隊操作,而另一個線程再執行出隊操作時,由於隊列已經空了,因此將拋出異常。——譯者註)。
BlockingCollection<T>
包含一系列重載,允許指定超時和取消標記,可以在這些非阻塞方法之上提供阻塞行為。通常不需要直接使用BlockingCollection<T>
或IProducerConsumerCollection<T>
,你可以調用並行擴展中使用了這兩個類的其他部分。但了解它們還是很有必要的,特別是在需要自定義行為的時候。
B.6.2 ConcurrentBag<T>
、ConcurrentQueue<T>
和ConcurrentStack<T>
框架自帶了三個IProducerConsumerCollection<T>
的實現。本質上,它們在獲取項的順序上有所不同;隊列和棧與它們非並發等價類的行為一致,而ConcurrentBag<T>
沒有順序保證。
它們都以線程安全的方式實現了IEnumerable<T>
。GetEnumerator()
返回的叠代器將對集合的快照進行叠代;叠代時可以修改集合,並且改變不會出現在叠代器中。這三個類都提供了與TryTake
類似的TryPeek
方法,不過不會從集合中移除值。與TryTake
不同的是,IProducerConsumerCollection<T>
中沒有指定TryPeek
方法。
B.6.3 ConcurrentDictionary<TKey, TValue>
ConcurrentDictionary<TKey, TValue>
實現了標準的IDictionary<TKey, TValue>
接口(但是所有的並發集合沒有一個實現了IList<T>
),本質上是一個線程安全的基於散列的字典。它支持並發的多線程讀寫和線程安全的叠代,不過與上節的三個集合不同,在叠代時對字典的修改,可能會也可能不會反映到叠代器上。
它不僅僅意味著線程安全的訪問。普通的字典實現基本上可以通過索引器提供添加或更新,通過Add
方法添加或拋出異常,但ConcurrentDictionary<TKey, TValue>
提供了名副其實的大雜燴。你可以根據前一個值來更新與鍵關聯的值;通過鍵獲取值,如果該鍵事先不存在就添加;只有在值是你所期望的時候才有條件地更新;以及許多其他的可能性,所有這些行為都是原子的。在開始時都顯得很難,但並行團隊的Stephen Toub撰寫了一篇博客,詳細介紹了什麽時候應該使用哪一個方法(參見http://mng.bz/WMdW)。
B.7 只讀接口(.NET 4.5)
NET 4.5引入了三個新的集合接口,即IReadOnlyCollection<T>
、IReadOnlyList<T>
和IReadOnlyDictionary<TKey, TValue>
。截至本書撰寫之時,這些接口還沒有得到廣泛應用。盡管如此,還是有必要了解一下的,以便知道它們不是什麽。圖B-2展示了三個接口間以及和IEnumerable
接口的關系。
圖B-2 .NET 4.5的只讀接口
如果覺得ReadOnlyCollection<T>
的名字有點言過其實,那麽這些接口則更加詭異。它們不僅允許其他代碼對其進行修改,而且如果集合是可變的,甚至可以通過結合對象本身進行修改。例如,List<T>
實現了IReadOnlyList<T>
,但顯然它並不是一個只讀集合。
當然這並不是說這些接口沒有用處。IReadOnlyCollection<T>
和IReadOnlyList<T>
對於T
都是協變的,這與IEnumerable<T>
類似,但還暴露了更多的操作。可惜IReadOnlyDictionary<TKey, TValue>
對於兩個類型參數都是不變的,因為它實現了IEnumerable<KeyValuePair<TKey, TValue>>
,而KeyValuePair<TKey, TValue>
是一個結構,本身就是不變的。此外,IReadOnlyList<T>
的協變性意味著它不能暴露任何以T
為參數的方法,如Contains
和IndexOf
。其最大的好處在於它暴露了一個索引器,通過索引來獲取項。
目前我並沒怎麽使用過這些接口,但我相信它們在未來肯定會發揮重要作用。2012年底,微軟在NuGet上發布了不可變集合的預覽版,即Microsoft.Bcl.Immutable
。BCL團隊的博客文章(http://mng.bz/Xlqd)道出了更多細節,不過它基本上無需解釋:不可變的集合和可凍結的集合(可變集合,在凍結後變為不可變集合)。當然,如果元素類型是可變的(如StringBuilder
),那它也只能幫你到這了。但我依然為此興奮不已,因為不可變性實在是太有用了。
B.8 小結
.NET Framework包含一系列豐富的集合(盡管對於集來說沒那麽豐富)(作者前面使用了a rich set of collecions,後面用了a rich collection of sets,分別表示豐富的集合和集。此處的中文無法體現原文這種對仗。——譯者註)。它們隨著框架的其他部分一起逐漸成長起來,盡管接下來的一段時間內,最常用的集合還應該是List<T>
和Dictionary<TKey, TValue>
。
當然未來還會有其他數據結構添加進來,但要在其好處與添加到核心框架中的代價之間做出權衡。也許未來我們會看到明確的基於樹的API,而不是像現在這樣使用樹作為已有集合的實現細節。也許可以看到斐波納契堆(Fibonacci heaps)、弱引用緩存等——但正如我們所看到的那樣,對於開發者來說已經夠多了,並且有信息過載的風險。
如果你的項目需要特殊的數據結構,可以上網找找開源實現;Wintellect的Power Collections作為內置集合的替代品,已經有很長的歷史了(參見http://powercollections.codeplex.com)。但在大多數情況下,框架完全可以滿足你的需求,希望本附錄可以在創造性使用泛型集合方面擴展你的視野。
下班回來之前對自己說:今天一定要把這篇博客寫了!然而回來以後,看看這瞅瞅那,點開各種超鏈接,拖到很晚才開始,哎,這習慣真的不好。。。。
.NET中的泛型集合總結