C#泛型的协变与逆变

1.协变性

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

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

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

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

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

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

使用out修饰符允许协变性

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

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

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

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

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

2.逆变性

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

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

public class Fruit { }
public class Apple:Fruit { }
public class Orange:Fruit { }
public interface IExample<T>
{
	public T Item { get;set; }
}
public class ExampleClass<T> : IExample<T>
{
	public T Item { get; set; }
}
public static void GenericPracticeMain()  
{  
    IExample<Fruit> fruit = new ExampleClass<Fruit>(){Item = new Orange()};  
    // 编译不通过
    IExample<Apple> apple = fruit;  
}

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

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

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

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

使用in修饰符允许逆变性

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

public interface IExample<in T>
{
	public T Item { set; }
}
IExample<Fruit> fruit = new ExampleClass(){Item = new Orange()};
// 编译通过
IExample<Apple> apple = fruit;

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

3.总结

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

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

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

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


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

你可能感兴趣的:(C#,c#)