1. 程式人生 > >.NET中的泛型集合總結

.NET中的泛型集合總結

pro 顯示 接口 www 最重要的 div 類型 項目 .cn

最近對集合相關的命名空間比較感興趣,以前也就用下List<T>, Dictionary<Tkey, TValue>之類,總之,比較小白。點開N多博客,MSDN,StackOverflow,沒找到令我完全滿意的答案,本打算自己總結下寫出來,工作量好大的感覺……直到昨晚隨意翻到看了一些又放下的《深入理解C#》-附錄B部分,高興地簡直要叫出來——“這總結真是太絕了,好書不愧是好書”。真是“踏破鐵鞋無覓處,得來全不費工夫”,最好的資源就在眼下,而自己居然渾然不知。或許只有深入技術細節的時候,才能認識到經典為什麽是經典的原因吧!言歸正傳,本博客主要是對《深入理解C#》-附錄B的摘錄,並加了些標註。

附錄B .NET中的泛型集合

.NET中包含很多泛型集合,並且隨著時間的推移列表還在增長。本附錄涵蓋了最重要的泛型集合接口和類,但不會涉及System.CollectionsSystem.Collections.SpecializedSystem.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>,添加了兩個屬性(CountIsReadOnly)、變動方法(AddRemoveClear)、CopyTo(將內容復制到數組中)和Contains(判斷集合是否包含特殊的元素)。所有標準的泛型集合實現都實現了該接口

IList<T>全都是關於定位的:它提供了一個索引器InsertAtRemoveAt(分別與AddRemove相同,但可以指定位置),以及IndexOf(判斷集合中某元素的位置)。對IList<T>進行叠代時,返回項的索引通常為0、1,以此類推。文檔裏沒有完整的記錄,但這是個合理的假設。同樣,通常認為可以快速通過索引對IList<T>進行隨機訪問。

IDictionary<TKey, TValue>表示一個獨一無二的鍵到它所對應的值的映射。值不必是唯一的,而且也可以為空;而鍵不能為空。可以將字典看成是鍵/值對的集合,因此IDictionary<TKey, TValue>擴展了ICollection<KeyValuePair<TKey, TValue>>。獲取值可以通過索引器或TryGetValue方法;與非泛型IDictionary類型不同,如果試圖用不存在的鍵獲取值,IDictionary<TKey, TValue>的索引器將拋出一個KeyNotFoundExceptionTryGetValue的目的就是保證在用不存在的鍵進行探測時還能正常運行

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。此外,它還實現了非泛型的ICollectionIList接口,並在必要時進行裝箱和拆箱,以及進行執行時類型檢查,以保證新元素始終與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>(及其擴展的接口)和非泛型的IListICollection接口;矩形數組只支持非泛型接口。數組從元素角度來說是易變的,從大小角度來說是固定的。它們顯示實現了集合接口中所有的可變方法(如AddRemove),並拋出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>相同,數組支持ConvertAllFindAllBinarySearch方法,不過對數組來說,這些都是Array類的以數組為第一個參數的靜態方法。

回到本節最開始所說的,數組是相當低級的數據結構。它們是其他集合的重要根基,在適當的情況下有效,但在大量使用之前還是應該三思。Eric同樣為該話題撰寫了博客,指出它們有“些許害處”(參見http://mng.bz/3jd5)。我不想誇大這一點,但在選擇數組作為集合類型時,這是一個值得註意的缺點。

B.2.3 LinkedList<T>

什麽時候列表不是list呢?答案是當它為鏈表的時候。LinkedList<T>在很多方面都是一個列表,特別的,它是一個保持項添加順序的集合——但它卻沒有實現IList<T>。因為它無法遵從通過索引進行訪問的隱式契約。它是經典的計算機科學中的雙向鏈表:包含頭節點和尾節點,每個節點都包含對鏈表中前一個節點和後一個節點的引用。每個節點都公開為一個LinkedListNode<T>,這樣就可以很方便地在鏈表的中部插入或移除節點。鏈表顯式地維護其大小,因此可以訪問Count屬性。

在空間方面,鏈表比維護後臺數組的列表效率要低,同時它還不支持索引操作,但在鏈表中的任意位置插入或移除元素則非常快,前提是只要在相關位置存在對該節點的引用。這些操作的復雜度為O(1),因為所需要的只是對周圍的節點修改前/後的引用。插入或移除頭尾節點屬於特殊情況,通常可以快速訪問需要修改的節點。叠代(向前或向後)也是有效的,只需要按引用鏈的順序即可

盡管LinkedList<T>實現了Add等標準方法(向鏈表末尾添加節點),我還是建議使用顯式的AddFirstAddLast方法,這樣可以使意圖更清晰。它還包含匹配的RemoveFirstRemoveLast方法,以及FirstLast屬性。所有這些操作返回的都是鏈表中的節點而不是節點的值;如果鏈表是空(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>所有對於集合的變動行為,都通過受保護的虛方法(InsertItemSetItemRemoveItemClearItems)實現。派生類可以攔截這些方法,引發事件或提供其他自定義行為。派生類可通過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>一樣實現了相同的INotifyCollectionChangedINotifyPropertyChanged接口。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),雖然這意味著字典的效率取決於散列函數的優劣。可使用默認的散列和相等函數(調用鍵對象本身的EqualsGetHashCode),也可以在構造函數中指定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>。如果取名為ListBackedSortedDictionaryTreeBackedSortedDictionary可能更加貼切,但現在改已經來不及了。

這兩個類有很多共同點:比較鍵時都使用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>還指定了很多其他操作來控制集(ExceptWithIntersectWithSymmetricExceptWithUnionWith)並在各種復雜條件下驗證集(SetEqualsOverlapsIsSubsetOfIsSupersetOfIsProperSubsetOfIsProperSupersetOf)。所有這些方法的參數均為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方法(盡管接口中沒有),並且還提供了額外的屬性(MinMax)用來返回最小和最大值。一個比較有趣的方法是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>提供了EnqueueDequeue方法,用於添加和移除項。Peek方法用來查看下一個出隊的項,而不會實際移除。DequeuePeek在操作空(empty)隊列時都將拋出InvalidOperationException。對隊列進行叠代時,產生的值的順序與出隊時一致。

B.5.2 Stack<T>

Stack<T>的實現比Queue<T>還簡單——你可以把它想成是一個List<T>,只不過它還包含Push方法用於向列表末尾添加新項,Pop方法用於移除最後的項,以及Peek方法用於查看而不移除最後的項。同樣,PopPeek在操作空(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>只提供了三個特別有趣的方法:ToArrayTryAddTryTakeToArray將當前集合內容復制到新的數組中,這個數組是集合在調用該方法時的快照。TryAddTryTake都遵循了標準的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為參數的方法,如ContainsIndexOf。其最大的好處在於它暴露了一個索引器,通過索引來獲取項。

目前我並沒怎麽使用過這些接口,但我相信它們在未來肯定會發揮重要作用。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中的泛型集合總結