自从我们1月份报道了不可变集合后,该API进一步发展,并公布了更多关于内部机制的内容。首先是关于最新版本中做出了哪些改变的概要:
构造函数
尽管不可变集合仍然不提供构造函数,但不必再使用Empty对象了。以前你会看到这样的代码:
var list = ImmutableList<int>.Empty.Add(1, 2, 3);
新版本中有一个Create静态工厂方法,可以使用泛型类型推断。表达式将简化为:
var list = ImmutableList.Create(1, 2, 3);
兼容性
是否实现IList<T>接口是热议的话题。该接口的支持者认为,与引入IReadOnlyList<T>之前的库进行交互是十分必要的。而反对者则抱怨对于同样的旧库,没有必要在修改集合的值之前判断IList.IsReadOnly是否为false。
最终,BCL小组为遗留问题做出妥协,实现了IList<T>。尽管所有人都同意如果没有IList.IsReadOnly()会更好,但现在这背后已经有了太多复杂的因素。
对于公开的不可变类和接口的完整列表,请参阅兼容性表。
相等性语义
与其他集合类型一样,不可变集合将只支持引用相等性。BCL小组写到:
计算集合的值相等性是十分昂贵的,并且对嵌套集合(如ImmutableDictionary<string, ImmutableList<string>>)相等性的比较也很难定义。最终,提供这种功能在设计不同比较器时会导致更多的问题,就像客户指出的那样。
之前这些集合覆盖了Object.Equals而不是op_equals。
还有人询问是否支持IStructuralEquatable。由于其“很难泛化”,BCL小组已经放弃了支持该接口。例如,在有些场景下可能需要跳过集合中的某些项(如解析器中的空格节点),如果没有特殊的实现,这几乎是不可能的。
而且遗憾的是,为了防止使用继承来添加IStructualEquatable,不可变类被设计为密封的。
平台支持
不可变集合库专为.NET 4.5及以后的版本而设计。它利用了新的只读接口,并且开发者不想为旧库维护一个单独的版本。它还可用于Windows 8和“protable-net45+win8”配置。
序列化
不可变集合不支持使用Serializable特性的旧序列化设计。目前还没有确定是否支持其他序列化设计,如DataContractSerializer。
本质
不可变集合基于AVL树(除栈和队列外)。你可以在不重新复制整个树的情况下在列表的开头、中间或结尾执行插入操作。在维基百科关于持久数据结构这篇文章的树这一节中,有关于这种插入的示例。
不可变散列表也使用了AVL树。它没有使用在散列值上执行模操作这种普通散列表的桶设计,而是根据原始散列值对树进行排序。这意味着检索操作需要执行一个平均检索时间为O(log n)的二进制搜索。
请记住在使用多线程操作时,大O标记法会带来误导。不可变集合的一个替代方案是使用并发集合,它需要昂贵的内部锁来确保线程安全。
不可变集合有一个有意思的特性,它的内部节点并不是不可变的。为了降低构建集合时创建的垃圾,每个节点都起始于一个可编辑的状态。这允许构造函数改变已有的AVL树,因为它添加了节点,而不是废弃并重新创建。当构造结束、不可变包装器返回的时候,节点将被冻结,以防止进一步修改。
另一个令人感到意外的设计决策是枚举器使用了对象池。在.NET中,很多枚举器被设计为不会分配任何内存。如果从IList<T>上获取枚举器,需要两次内存分配。但对于List<T>,枚举器是一个结构,不需要任何内存分配。
同样,不可变集合也使用了结构作为枚举器。但由于其内部结构是一个树,因此枚举器需要用一个栈来保存之前访问过的节点,以进行跟踪。为了减少内存分配,将很多这样的栈存储在对象池中(实际也是一个栈),并由一个锁来进行保护。实际上,这是整个不可变集合库中唯一的锁。对枚举器调用Dispose方法是至关重要的,否则栈将不能返回到对象池中。
更多信息请观看Chinnel 9的视频不可变集合的内部工作原理。
使用建议
在创建不可变集合时,最好是使用Create函数一次性创建整个集合。这将允许集合对树进行预分配并直接填充节点。第二好的方法是使用builder,不过要调用ToImmutable才能冻结节点。
在枚举不可变集合中的项时,要使用foreach循环。由于其内部是树形结构,因此foreach要比for快很多。(注:从.NET 2.0开始,即使是普通的列表,用foreach读取也比用for快很多。)
如果集合在创建之后不会改变,那么不可变集合的性能将比用只读包装器保护的普通集合差很多。不可变集合更适用于高效创建与其他集合有少许不同的集合。
查看英文原文:More on Immutable Collections in .NET