C#泛型的协变与逆变

本是古典 何须时尚 2023-09-28 05:35 255阅读 0赞

1.协变性

当我们使用泛型编程时,可能会遇到如下问题,即将一个较具体的类型赋值给一个较泛化的类型是可行的,但在泛型中却无法编译通过。

  1. // 编译不通过:Type 'string' doesn't match expected type 'object'.
  2. List<object> list = new List<string>();
  3. // 编译通过
  4. object item = new string("abc");

用不同类型参数声明同一个泛型类的两个变量,这两个变量不是类型兼容的——即使是将一个较具体的类型赋值给一个较泛化的类型。也就是说,他们不是协变量。 那么什么是协变?举个例子来讲,假设有X和Y两个类型,且X和Y之间有特殊关系,即每个X类型的值都能转换成Y类型。如果I<X>I<Y>也总有这样的特殊关系,那么就可以说“I<T>T协变”。

那么为什么泛型不支持协变呢?我们可以假设C#允许泛型协变,看一下会发生什么:

  1. List<string> strList = new List<string>(){
  2. "aaa","bbb"};
  3. // 假设合法
  4. List<object> objList = strList;
  5. objList.Add(1);
  6. objList.Add(2);

可以看到,因为objList是List<object>类型的,因此向其中添加int类型的数据完全合法,但objList又是strList的别名,而strList只能是一个字符串列表,向其中添加整型数据就破坏了其类型安全。因此若允许不受限制的泛型协变性,类型安全将完全失去保障。

使用out修饰符允许协变性

根据前面的描述,泛型类型之所以限制协变性是因为List<T>允许向其内容写入,从而使类型安全失去保障。那么如果只允许读取,不允许写入呢?

C#从4开始加入了对安全协变性的支持。如果要指出泛型接口应该对它的某个类型参数协变,就用out修饰该类型参数。

  1. List<string> strList = new List<string>(){
  2. "aaa","bbb"};
  3. // 假设合法
  4. // List<object> objList = strList;
  5. IReadOnlyList<object> objList = strList;

上面的代码中,IReadOnlyList<T>就是使用了out修饰符的泛型接口,而List<T>实现了这一接口。因为IReadOnlyList<T>接口只提供了读取方法,并没有提供写入方法,因此该协变转换是合法的。

协变转换也存在一些限制:
(1)只有泛型接口和泛型委托才支持协变,泛型类和结构不支持。
(2)来源T和目标T必须都为引用类型,不能是值类型。

2.逆变性

逆变性就是协变性的反方向。仍然是之前的例子,假设有X和Y两个类型,且X和Y之间有特殊关系,即每个X类型的值都能转换成Y类型。如果I<X>I<Y>总具有相反的特殊关系,即I<Y>类型的每个值都能转换为I<X>类型,那么就可以说“I<T>T逆变”。

固定泛型同样不允许逆变:

  1. public class Fruit {
  2. }
  3. public class Apple:Fruit {
  4. }
  5. public class Orange:Fruit {
  6. }
  7. public interface IExample<T>
  8. {
  9. public T Item {
  10. get;set; }
  11. }
  12. public class ExampleClass<T> : IExample<T>
  13. {
  14. public T Item {
  15. get; set; }
  16. }
  17. public static void GenericPracticeMain()
  18. {
  19. IExample<Fruit> fruit = new ExampleClass<Fruit>(){
  20. Item = new Orange()};
  21. // 编译不通过
  22. IExample<Apple> apple = fruit;
  23. }

同样的,我们假设上面这种写法可以通过编译,看看会发生什么问题:

  1. IExample<Fruit> fruit = new ExampleClass(){
  2. Item = new Orange()};
  3. // 假设编译通过
  4. IExample<Apple> apple = fruit;
  5. Apple app = apple.Item;

我们会发现假如编译通过,我们就可以合法地将Item赋值给一个Apple变量,但问题在于Item原本是一个Orange对象,这样的转换显然是不正确的。

根据上面的示例,我们可以发现,固定泛型接口不允许逆变的原因在于其内部存在有返回值的方法。那么假如泛型接口内部不存在有返回值的方法,是不是就允许逆变了呢?

使用in修饰符允许逆变性

C#提供了in操作符来允许泛型逆变。它的使用方法与out修饰符类似。通过in修饰的泛型接口不允许T作为属性取值方法或方法返回类型使用。

  1. public interface IExample<in T>
  2. {
  3. public T Item {
  4. set; }
  5. }
  6. IExample<Fruit> fruit = new ExampleClass(){
  7. Item = new Orange()};
  8. // 编译通过
  9. IExample<Apple> apple = fruit;

这看起来还是有些反直觉,因为我们还是将“一筐苹果”的指针指向了“一筐橘子”,但实际上因为无法返回Apple类型的值,所以该过程只发生了从Orange向Fruit类型的转换,而没有发生从Fruit向Apple类型的转换,这是完全合法的。

3.总结

协变:从子类转换到父类;泛型参数定义的类型只能作为返回类型,不能作为参数类型;使用out修饰。
逆变:从父类转换到子类;泛型参数定义的类型只能作为参数类型,不能作为返回类型;使用in修饰。

协变之所以不允许泛型参数作为参数类型,是因为IExample<Apple>的接口方法要求传入一个Apple作为参数,但把IExample<Apple>赋值给IExample<Fruit>之后,你传入的就是Fruit对象。从Fruit->Apple是类型不安全的。

逆变之所以不允许泛型参数作为返回类型,是因为IExample<Fruit>的接口方法返回的是一个Fruit对象,但把IExample<Fruit>赋值给IExample<Apple>之后,返回的就是Apple对象。从Fruit->Apple是类型不安全的。

其实它们本质上还是符合里氏替换原则。通过限制输入或输出,就可以防止类型不安全的转换。


参考文献:
[1]马克·米凯利斯.C#8.0本质论[M].机械工业出版社.

发表评论

表情:
评论列表 (有 0 条评论,255人围观)

还没有评论,来说两句吧...

相关阅读

    相关 详解

    逆变(`contravariant`)与协变(`covariant`)是`C4`新增的概念,许多书籍和博客都有讲解,我觉得都没有把它们讲清楚,搞明白了它们,可以更准确地去定义泛