.NET 中只读集合接口的故事

阅读数:1156 2011 年 11 月 18 日

话题:.NET语言 & 开发

.NET 4.5 中添加了两个新的集合接口,IReadOnlyList 和 IReadOnlyDictionary。尽管这些接口表面上看起来是如此稀松平常,但是它们却暴露出关于向后兼容性、互操作性、以及协变的作用等相当复杂的故事。

IReadOnlyList 和 IReadOnlyDictionary 是.NET 开发者一直都想得到的两个接口。一个只读接口除了提供某种(相对于可写入接口的)对称感之外,还应消除实现那些只抛出 NotSupportedException 异常而什么都不做的方法。由于时间原因,这一切未能完成。

接下来一次机会是在.NET 2.0 中引入泛型。这使得微软可以淘汰弱类型集合,并使用强类型的对等集合替代之。但是,基类库[1]团队又一次错过了这次提供只读列表(read-only list)的机会,正如Kit George 所写到的

由于我们打算为你与 Joe 所谈论的问题提供一种缺省实现,而不是给出一个接口,因此我们提供了 ReadOnlyCollectionBase 基类。然而,我能理解人们之所以不愿使用它,是因为它不是强类型的。但随着泛型的引入,我们现在还拥有了 ReadOnlyCollection<T>,这样你不仅获得了同等功能,而且还是强类型的:太棒了!

由于 ReadOnlyCollection<T> 不是密封类,因此必要时你可以开足马力随意编写你自己的集合。因为我们为此创作的这些集合可适应一般需求,所以我们尚未计划为与此相同的概念引入接口。

Krzysztof Cwalina 也对此主题发表了看法,

无论这听起来是否令人惊讶,但是 IList 和 IList<T> 是我们打算用于只读集合的两个接口。它们都拥有 IsReadOnly 布尔型属性,当某个只读集合实现此属性后应返回 true。我们不想添加纯只读接口的原因是,我们觉得它会给基类库添加太多不必要的复杂性。请注意,就复杂性而言,我们既指此新接口又指其消费者。

我们认为,如果 API 设计者不关心在运行时检查 IsReadOnly 属性、及其可能抛出的异常,那么这种情况下使用 IList 接口并无大碍;如果他们愿意一次性地提供一个真正干净的自定义 API,那么这种情况下他们应显示实现 IList 接口、并公布量身定制的只读 API。对于从对象模型中公开的集合而言,后者是种典型方式。

虽然开发者曾抱怨此种情况,但是泛型所提供的新机会远远大于这个症结,并且该问题在.NET 4 以前很大程度上被忽视了。然而,此决定也引发了一些反应,我们会在稍后讨论。

随着在.NET 4 中一个令人兴奋的新功能被添加到运行时。在较早版本的.NET 中,当接口成为类型时,那些接口是受到过分限制的。例如,即使 Customer 继承自 Person,也无法将类型为 IEnumerable<Customer> 的对象作为参数传给某个参数类型为 IEnumerable<Person> 的函数。随着协变支持的添加,该限制才得以部分解除。

我们之所以说“部分”,是因为在某些情况下,人们应一次性地使用某个具有丰富 API 的接口,而不是使用 IEnumerable 接口。即使 IList 接口不是协变的,一个只读列表接口也理应如此。不幸的是,.NET 基类库团队再次决定不解决此疏忽。

然后,WinRT 的引入和 COM 的卷土重来改变了一切。COM 互操作性曾是开发者在别无其他选择的情况下才会使用的一种技术,但现已成为.NET 编程的基石。而且由于 WinRT 公开了IVectorView<T>IMapView<K, V>接口,因此.NET 也必须作出相应调整。

WinRT 计划中一个颇为有趣的功能是,为每个开发平台公布不同但功能类似的 API。正如你可能已经知道的,通过 JavaScript 开发者眼睛看到的是,所有方法名都表示为驼峰式大小写(camelCased[2]),而 C++ 和.NET 开发者看到的方法名则表示为帕斯卡大小写(PascalCased[3])。另一处更加剧烈的变化是,在 C++ 与.NET 的接口之间实现自动映射。因此.NET 开发者无需处理 Windows.Foundation.Collections 命名空间,只要继续使用 System.Collections.Generic 命名空间就行了。IVectorView<T> 和 IMapView<K, V> 这两个接口会被运行库分别转化为IReadOnlyList<T>接口和IReadOnlyDictionary<TKey, TValue>接口。

值得注意的是,在 C++/WinRT 中的这些接口名在某定程度上是更准确的。这些接口是用来表示针对某集合的一些视图,但是接口并不确保该集合本身是不变的。即使在那些经验丰富的.NET 开发者中也很常见的一种错误是,假设 ReadOnlyCollection 类型是某个不变集合副本,其实,它只是对某个活动集合的包装(wrapper)(关于只读、冻结、且不变集合的详细信息,请参阅 Andrew Arnott 的同名帖子)。

尽管 IList<T> 接口与 IReadOnlyList<T> 接口具有全部相同的成员、并且所有 IList<T> 类型的列表都可表示为只读列表,但是 IList<T> 不是继承自 IReadOnlyList<T>,当有人得知这些以后可能会觉得很有趣。Immo Landwerth 解释说

之所以能工作是因为那些只读接口是可读写接口的纯子集,这看起来是个合理的假设。不幸的是,此假设与实际不符,因为在元数据级别上位于每个接口上的每个方法都有各自的槽(slot)(这使得显式接口实现得以工作)。

或者换言之,引入只读接口作为某些可变种类基类的唯一机会是退回到.NET 2.0,即它们最初被构思出来的时候。一旦全面推出,对其能做的唯一改变就是添加协变和 / 或逆变标记(在 VB 和 C# 中表示为“in”和“out”)。

当被问及为什么没有 IReadOnlyCollection<T> 接口时,Immo 回答说,

我们曾考虑过这个设计,但是我们觉得加入一个提供仅有 Count 属性的类型并不会为基类库增加太多价值。在基类库团队中,我们认为,如果某个 API 是从负 1000 点开始的,那么即使能提供一些价值也不足以证明可被添加。添加新 API 的理由还包括代价,例如,开发者会拥有更多可供选择的概念。起初我们认为,添加这个类型将使得代码在某些场景(你只想获得计数,然后对它做一些有趣的东西)下获得更好的性能。例如,批量添加到某个现有集合。然而,在这些场景下,我们已鼓励人们仅采用 IEnumerable<T> 接口,而且对于拥有实现了 ICollection<T> 接口的实例的特殊情况也是如此。自从我们所有的内建集合类型实现了此接口以后,在那些最常见场景下并未获得任何性能收益。顺便说一下,针对 IEnumerable<T> 的扩展方法 Count() 同样可以完成此功能。

这些新接口可用于.NET 4.5 和.NET for Windows 8。

译注

[1] 基类库,Base Class Library,缩写为 BCL。有关基类库的更多信息,请参与MSDN

[2] camelCased,驼峰式命名法,又称小驼峰式命名法(lower camel case)。格式为,第一个单字以小写字母开始;第二个单字的首字母大写,例如:firstName、lastName。

[3] PascalCased,帕斯卡命名法,又称大驼峰式命名法(upper camel case)。格式为,每一个单字的首字母都采用大写字母,例如:FirstName、LastName、CamelCase。

查看英文原文:The Story of Read-Only Collection Interfaces in .NET