一、基本概念
协变和逆变是在计算机科学中,描述具有父/子型别关系的多个型别,通过型别构造器、构造出的多个复杂型别之间是否有父/子型别有序或逆序的关系;
官方描述:协变和逆变都是术语,前者指能够使用比原始指定的派生类型的派生程度更大(更具体的)的类型,后者指能够使用比原始指定的派生类型的派生程度更小(不太具体的)的类型。以上概念理解起来绕口或看得不明所以,简单点说就是以下意思:
协变:就是对具体成员的输出参数进行一次类型转换,且类型转换的准则是 “里氏替换原则”。例如,如果Cat是Animal的子类型,那么Cat类型的表达式可用于任何出现Animal类型表达式的地方。所谓的变型(variance)是指如何根据组成类型之间的子类型关系,来确定更复杂的类型之间(例如Cat列表之于Animal列表,回传Cat的函数之于回传Animal的函数...等等)的子类型关系。当我们用类型构造出更复杂的类型,原本类型的子类型性质可能被保持、反转、或忽略───取决于类型构造器的变型性质。
逆变:是对具体成员的输入参数进行一次类型转换,且类型转换的准则是"里氏替换原则"。在使用委托时,因为委托方法签名参数比方法参数更具体,因此可以在传递给处理程序方法时对它们进行隐式转换。这样,当创建可由大量类使用的更加通用的委托方法时,使用逆变就更为简单了。
二、遇到的情况
在通常情况下,简单的父子类关系根据“里氏替换原则”,父类型(也叫基类型)的变量可以指向其子类对象,进而可以正常调用子类对象的具体实现,实现了面向对象的多态特性,如下代码:
定义的的测试类:
/// <summary>
/// 水果类
/// </summary>
public class Fruit
{
public string Name { get; set; }
}
/// <summary>
/// 苹果类
/// </summary>
public class Apple : Fruit
{
}
通常情况下的父子类的关系及使用:
Fruit fruit = new Fruit(); //正常
Apple apple = new Apple(); //正常
Fruit fruit1 = new Apple(); //正常,里氏替换原则
//Apple apple1 = new Fruit(); //语法错误,父类型对象不转换为子类
但在某些场景中,我们既需要父子类关系的原本特性被保持,同时又需要更为复杂数据结构特性以满足复杂的业务需求,简单的父子类关系这种形式貌似难以满足或实现,如下:
List<Fruit> fruits = new List<Fruit>(); //正常
List<Apple> apples = new List<Apple>(); //正常
//List<Fruit> fruits1 = new List<Apple>(); //错误
问题来了:一个Apple是Fruit,难道一堆Apple就不是Fruit了?
此时,协变和逆变就是为了解决此类问题出现的;
-
IEnumerable<Cat>是IEnumerable<Animal>的子类型,因为类型构造器IEnumerable<T>是协变的(covariant)。注意到复杂类型IEnumerable的子类型关系和其接口中的参数类型是一致的,亦即,参数类型之间的子类型关系被保持住了。
-
Action<Cat>是Action<Animal>的超类型,因为类型构造器Action<T>是逆变的(contravariant)。(在此,Action<T>被用来表示一个参数类型为T或sub-T的一级函数)。注意到T的子类型关系在复杂类型Action的封装下是反转的,但是当它被视为函数的参数时其子类型关系是被保持的。
-
IList<Cat>或IList<Animal>彼此之间没有子类型关系。因为IList<T>类型构造器是不变的(invariant),所以参数类型之间的子类型关系被忽略了。
三、协变(Covariance)
1、out关键字(C#中)
对于泛型类型参数,out 关键字可指定类型参数是协变的。可以在泛型接口和委托中使用 out 关键字,如下代码:
/// <summary>
/// 协变
/// </summary>
/// <typeparam name="T"></typeparam>
public interface MyList<out T>
{
T GetName();
}
/// <summary>
/// 实现接口
/// </summary>
public class MyList: MyList<Apple>
{
public Apple GetName()
{
return new Apple();
}
}
调用:
//协变
MyList<Apple> apple = new MyList();
// fruit和apple指向同一对象:new MyList()
MyList<Fruit> fruit= apple;
//表面调用的是fruit里面的方法,实际上是new MyList().GetName(),返回的是Apple,
//赋值时,此处作了隐式转换(Fruit)new MyList().GetName()
Fruit result= fruit.GetName();
Console.WriteLine(result.GetType().Name); //输出 Apple
备注:泛型委托的协变原理也是一样的。
四、逆变(Contravariance)
1、in关键字(C#中)
对于泛型类型参数,in 关键字可指定类型参数是逆变的。可以在泛型接口和委托中使用 in 关键字。
/// <summary>
/// 逆变
/// </summary>
/// <typeparam name="T"></typeparam>
public interface Inverter<in T>
{
void GetName(T args);
}
/// <summary>
/// 逆变接口实现
/// </summary>
public class Inverter : Inverter<Fruit>
{
public void GetName(Fruit fruit)
{
Console.WriteLine(fruit.GetType().Name);
}
}
调用:
//逆变
Inverter<Fruit> fruit = new Inverter();
//inApple 和fruit 指向同一对象:new Inverter()
Inverter<Apple> inApple = fruit;
//实际上调用的是new Inverter().GetName(),传参时,作了隐式转换,fruit.GetName方法中的参数是Fruit
inApple.GetName(new Apple()); //输Apple
Console.Read();
五、形式定义
在一门程序设计语言的类型系统中,一个类型规则或者类型构造器是:
-
协变(covariant),如果它保持了子类型序关系≦。该序关系是:子类型≦基类型。
-
逆变(contravariant),如果它逆转了子类型序关系。
-
不变(invariant),如果上述两种均不适用。
首先考虑数组类型构造器: 从Animal类型,可以得到Animal[](“animal数组”)。 是否可以把它当作
-
协变:一个Cat[]也是一个Animal[]
-
逆变:一个Animal[]也是一个Cat[]
-
以上二者均不是(不变)?
如果要避免类型错误,且数组支持对其元素的读、写操作,那么只有第3个选择是安全的。Animal[]并不是总能当作Cat[],因为当一个客户读取数组并期望得到一个Cat,但Animal[]中包含的可能是个Dog。所以逆变规则是不安全的。
反之,一个Cat[]也不能被当作一个Animal[]。因为总是可以把一个Dog放到Animal[]中。在协变数组,这就不能保证是安全的,因为背后的存储可以实际是Cat[]。因此协变规则也不是安全的—数组构造器应该是不变。注意,这仅是可写(mutable)数组的问题;对于不可写(只读)数组,协变规则是安全的。
这示例了一般现像。只读数据类型(源)是协变的;只写数据类型(汇/sink)是逆变的。可读可写类型应是“不变”的。
Java与C#中的协变数组
早期版本的Java与C#不包含泛型(generics,即参数化多态)。在这样的设置下,使数组为“不变”将导致许多有用的多态程序被排除。然而,如果数组类型被处理为“不变”,那么它仅能用于确切为Object[]类型的数组。对于字符串数组等就不能做重排操作了。所以,Java与C#把数组类型处理为协变。在C#中,string[]是object[]的子类型,在Java中,String[]是Object[]的子类型。这个方法的缺点是留下了运行时错误的可能,而一个更严格的类型系统本可以在编译时识别出该错误。这个方法还有损性能,因为在运行时要运行额外的类型检查。Java与C#有了泛型后,有了类型安全的编写这种多态函数。数组比较与重排可以给定参数类型,也可以强制C#方法只读方式访问一个集合,可以用界面IEnumerable<object>代替作为数组object[]。
六、一些疑问
1、协变、逆变 为什么只能针对泛型接口或者委托?而不能针对泛型类?
因为它们都只能定义方法成员(接口不能定义字段),而方法成员在创建对象的时候是不涉及到对象内存分配的,所以它们是类型(内存)安全的。
为什么不针对泛型?因为泛型类是模板类,而类成员是包含字段的,不同类型的字段是影响对象内存分配的,没有派生关系的类型它们是不兼容的,也是内存不安全的。
2、协变、逆变 为什么是类型安全的?
本质上是里氏替换原则,由里氏替换原则可知:派生程度小的是派生程度大的子集,所以子类替换父类的位置整个程序功能都不会发生改变。
3、为什么 in 、out 只能是单向的(输入或输出)?
因为若类型参数同时为输入参数和输出参数,则必然会有一种转换不符合里氏替换原则,即将父类型的变量赋值给子类型的变量,这是不安全的所以需要明确指定 in 或 out。
七、总结
1、协变和逆变一般应用于泛型接口和泛型委托中;
2、协变和逆变主要是为了保证某些类型在特殊数据结构中变型时的类型安全;
3、简单类型父类的变量能指向子类B的对象,此时,若IEnumerable<A>类型的变量能指向IEnumerable<B>的对象,为协变;若若IEnumerable<B>类型的变量能指向IEnumerable<A>的对象,为逆变;
4、协变”->”和谐的变”->”很自然的变化”->string->object :协变,类型参数只能作为输类型;“逆变”->”逆常的变”->”不正常的变化”->object->string 逆变,类型参数只能作为输入参数类型。