13.3.5 【接口和委托的泛型可变性】限制和说明

1. 不支持类的类型参数的可变性

只有接口和委托可以拥有可变的类型参数。即使类中包含只用于输入(或只用于输出)的类
型参数,仍然不能为它们指定 in 或 out 修饰符。例如, IComparer<T> 的公共实现 Comparer<T>
是不变的——不能将 Comparer<IShape> 转换为 Comparer<Circle> 。
除了实现方面的困难,从理论上看来也应该是这样的。接口是一种从特定视角观察对象的方
式,而类则更多地植根于对象的实际类型。不可否认,继承可以将一个对象视为它继承层次结构
中任何类的实例,由此在一定程度上削弱了这种理由的说服力。但不管怎样,CLR不允许这么做。

2. 可变性只支持引用转换

你不能对任意两个类型参数使用可变性,因为在它们之间会产生转换。这种转换必须为引用
转换。基本上,这使转换只能操作引用类型,并且不能影响引用的二进制表示。因此,编译器知
道操作是类型安全的,并且不会在任何地方插入实际的转换代码。我们在13.3.2节提到过,可变
转换本身是引用转换,所以不会有任何额外的代码。
特别地,这种限制禁止任何值类型转换和用户定义的转换。比如下面的转换是无效的。
 将 IEnumerable<int> 转换为 IEnumerable<object> ——装箱转换;
 将 IEnumerable<short> 转换为 IEnumerable<int> ——值类型转换;
 将 IEnumerable<string> 转换为 IEnumerable<XName> ——用户定义的转换。
用户定义的转换比较少见,因此不成什么问题,但对值类型的限制可能会令你痛苦万分。

3.  out 参数不是输出参数

这曾让我大为诧异,尽管事后看来是有道理的。考虑使用以方法定义的委托:

        delegate bool TryParser<T>(string input, out T value);

你可能会认为 T 可以是协变的——毕竟它只用在输出位置,是这样吗?
CLR并不真正了解 out 参数。在它看来, out 参数只是应用了 [Out] 特性的 ref 参数。C#以明
确赋值的方式为该特性附加了特殊的含义,但CLR没有。并且 ref 参数意味着数据是双向的,因
此如果类型 T 为 ref 参数,也就意味着 T 是不变的。
事实上,即使CLR支持 out 参数,也仍然不安全,因为它可用于方法本身的输入位置;写入
变量之后,同样也可以从中读取它。如果将 out 参数看成是“运行时复制值”似乎好一些,但它
本质上是实参和参数的别名,如果不是完全相同的类型,将会产生问题。由于稍微有些繁琐,此
处不再演示,但本书的网站上可以看到有关示例。
委托和接口使用 out 参数的情况很少,因此这可能不会对你产生影响,但为了以防万一,还
是有必要了解的。

4. 可变性必须显式指定

在介绍表示可变性的语法时(即对类型参数使用 in 或 out 修饰符),你可能会问为什么要这
么麻烦。编译器可以检查正在使用的可变性是否有效,因此为什么不能自动应用呢?
这样可以——至少在很多情况下是可以的——但我宁愿它不可以。通常我们向接口添加方法
时,只会影响实现,而不会影响调用者。但如果声明了一个可变的类型参数,然后又添加了一个
破坏这种可变性的方法,所有的调用者都会受影响。这会造成混乱不堪的局面。可变性要求你对
未来发生的事情考虑周全,并且强迫开发者显式指定修饰符,鼓励他们在执行可变性之前做到心
中有数。
对于委托来说,这种显式的特性就没有那么多争论了:任何对签名所做的影响可变性的修改,
都会破坏已有的使用。但如果在接口的定义中指定了可变性的修饰符,而在委托声明中不指定,
则会显得很奇怪,因此要保持它们的一致性。

5. 注意破坏性修改

每当新的转换可用时,当前代码都有被破坏的风险。例如,如果你依赖于不允许可变性的
is 或 as 操作符的结果,运行在.NET 4时,代码的行为将有所不同。同样,在某些情况下,因为
有了更多可用的选项,重载决策也会选择不同的方法。因此这也成了另一个显式指定可变性的理
由:降低代码被破坏的风险。
这些情况应该是很少见的,而且可变性的优点也比潜在的缺点更加重要。你已经有了单元测
试,可以捕获那些微小的变化,对不对?严肃地说,C#团队对于代码破损的态度非常认真,但有
时引入新特性难免会破坏代码。

6. 多播委托与可变性不能混用

通常情况下,对于泛型来说,除非涉及强制转换,否则不用担心执行时遇到类型安全问题。
不幸的是,当多个可变委托类型组合到一起时,情况就比较讨厌了。用代码可以更好地描述:

            Func<string> stringFunc = () => "";
            Func<object> objectFunc = () => new object();
            Func<object> combind = objectFunc + stringFunc;

这段代码可以通过编译,因为将 Func<string> 类型的表达式转换为 Func<object> 是协变
的引用转换。但对象本身仍然为 Func<string >,并且实际进行处理的 Delegate.Combine 方法
要求参数必须为相同的类型——否则它将无法确定要创建什么类型的委托。因此以上代码在执行
时会抛出 ArgumentException 。
这个问题在.NET 4快发布的时候才被发现,但微软察觉到了,并且很可能会在未来的版本中
予以解决(.NET 4.5中还未得到解决)。在此之前的应对之策是:基于可变委托新建一个类型正
确的委托对象,然后再与同一类型的另一个委托进行组合。例如,略微修改之前的代码即可使其工作:

            Func<string> stringFunc = () => "";
            Func<object> objectFunc = () => new object();

            Func<object> defensiveCopy = new Func<object>(stringFunc);
            Func<object> combind = objectFunc + defensiveCopy;

庆幸的是,以我的经验来说,这种情况很少见。

7. 不存在调用者指定的可变性,也不存在部分可变性

与其他问题相比,这个问题的确更能引起你的兴趣,但值得注意的是,C#的可变性与Java
系统相去甚远。Java的泛型可变性相当灵活,它从另一侧面来解决问题:不在类型本身声明可变
性,而是在使用类型的代码处表示所需的可变性。

例如,Java的 List<T> 接口大体上相当于C#的 IList<T> 。它包含添加和提取项的方法,这
在C#中显然是不变的,而在Java中,你可以在调用代码时声明类型来说明所需的可变性。然后编
译器会阻止你使用具有相反可变性的成员。例如,以下代码是完全合法的

            List<Shape> shapes1 = new ArrayList<Shape>();
            List <? super Square > squares = shapes1;       //声明为逆变的
            squares.add(new Square(10, 10, 20, 20));

            List<Circle> circles = new ArrayList<Circle>();
            circles.Add(new Circle(10, 10, 20));
            List <? extends Shape > Shapes2 = circles;      //声明为协变的
            Shape shape = Shapes2.get(0);

我在很大程度上更倾向于C#泛型,而不是Java泛型。特别是类型擦除(type erasure)在很
多时候会让你痛苦万分。但我发现这种处理可变性的方式真的很有趣。我认为C#未来版本中不会
出现类似的东西,所以你应该仔细考虑如何在不增加复杂性的前提下,将接口分割以增加灵活性。
在结束本章之前,还要介绍两处几乎是微不足道的改变——编译器如何处理 lock 语句和字段风格的事件。

猜你喜欢

转载自www.cnblogs.com/kikyoqiang/p/10111638.html