.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)。但在大多数情况下,框架完全可以满足你的需求,希望本附录可以在创造性使用泛型集合方面扩展你的视野。
下班回来之前对自己说:今天一定要把这篇博客写了!然而回来以后,看看这瞅瞅那,点开各种超链接,拖到很晚才开始,哎,这习惯真的不好。。。。