# 前言
关于协变和逆变,早在前些日子就在想整理一下了,不过由于前几天研究了下 Unity 的阴影与光照烘焙 ,所以一直放在心上却没有实施,都忘了之前是准备想怎么写开头了... 也忘了作笔记,只能现在重新想一想。
逆变 和 协变其实是对面向对象的一个补充,在 C# 中通过 in 标识逆变,out 标识 协变,如果没有标识,那么默认就是 『不变』。
之前想思考了好些天了,感觉有些地方还是有点没想透彻,这次整理的同时进行更多的试验,确定自己的想法对不对。
# 描述
变体仅针对引用类型,且只有 数组 (隐式协变)、泛型委托、泛型接口 可以使用。
注:对于同一泛型参数,逆变和协变不能同时存在。
正常来说,根据里氏替换原则:子类型(subtype)必须能够替换掉他们的基类型(base type)
我们通常在写代码的时候,也会不自觉地用到这一点:例如派生出多个子类的父类,可以作为一个『统一的方法参数』接受子类传递然后处理:
public void Log(Parent pt) | |
{ | |
Debug.Log(pt); | |
} | |
Child child = new Child(); | |
ChildBoy childBoy = new ChildBoy(); | |
Log(child); | |
Log(childBoy); |
我们可以说一个类是另一个类的基类,但是一个接口、一个委托正常是没有这种关系的,所以当包裹一层 (委托、接口) 后就不行了 —— 理论上它们并没有子类指向父类的关系。
泛型是对类型系统的进一步抽象,上面的变化映射至 (数组、泛型委托、泛型接口) 就是逆变与协变的概念。
从简单表现上来看:
- 协变用于隐式将返回参数 (容器) 的子类转为父类
- 逆变用于隐式将传入参数 (容器) 的父类转为子类
想想看:
- 协 —— 子类指向父类的关系符合原始的关系转换方向
- 逆 —— 父类转子类
# 测试
# 测试协变
# (1) 数组
数组的协变相信大多数人都不自觉使用过,例如:
string[] strings = new string[1]; | |
object[] objects = strings; |
上述代码就是数组隐式支持的协变:我们可以把子类数组直接赋值给定义的基类数组。
上面已经解释过,协变用于 『返回值隐式转换为父类』
因为不管如何从数组中取值:strings [index] 都可以转为 object
符合 『子类指向父类的关系』,协变成立。
# (2) 接口
接口的协变以 C# 内置 IReadOnlyList 接口为例,该接口标记了 out,List 就实现了该接口。
首先定义两个有父子关系的引用对象:
public class Parent | |
{ | |
public override string ToString() | |
{ | |
return "Parent:父类"; | |
} | |
} | |
public class Child : Parent | |
{ | |
public override string ToString() | |
{ | |
return "Child:子类"; | |
} | |
} |
然后进行调用测试:
List<Child> childList = new List<Child>() { new Child() }; | |
//==== 报错 ==== 列表是普通类,没有也不支持协变功能 | |
//List<Parent> parentList = childList; | |
// 正常赋值,协变使得声明子类的接口可隐式转为父类 | |
IReadOnlyList<Parent> baseList = childList; | |
Debug.Log(baseList[0]); |
打印结果:
Test (0.008s)
---
Child:子类
可能在这里还会有点不明白发生了什么,再以 List 实现的另一个接口 IList<T>
为例,IList 接口并未做标识,因此它是『不变』的,如果我们想这样赋值:
// 报错,提示无法隐式转换 List<Child>->IList<Panret> | |
IList<Parent> list = childList; |
作为『不变』的泛型接口,想要将泛型子类赋值泛型父类就会得到报错。
现在应该大概有点感觉了 —— 特别是当拥有多个子类,我们想统一接收的时候,拥有协变就可以直接这样写:
List<Child> childList = new List<Child>() { new Child() }; | |
List<ChildBoy> childList2 = new List<ChildBoy>() { new ChildBoy() }; | |
List<ChildGirl> childList3 = new List<ChildGirl>() { new ChildGirl() }; | |
Log(childList); | |
Log(childList2); | |
Log(childList3); | |
public void Log(IReadOnlyList<Parent> baseList) | |
{ | |
Debug.Log(baseList[0]); | |
} |
打印结果:
Test (0.009s)
---
Child:子类
ChildBoy:子类
ChildGirl:子类
当我们调用 childList [0] 可以得到一个 Child
当我们调用 childList2 [0] 可以得到一个 ChildBoy
当我们调用 childList3 [0] 可以得到一个 ChildGirl
最后从接口得到的这个对象,都可以安全转为 Parent
符合 『子类指向父类的关系』,协变成立。
# (3) 委托
除此之外,还有接触最多的 TResult Func<out TResult>
委托,它的返回值也是支持协变的:
Func<Parent> parentFunc = () => new Parent(); | |
Func<Child> childFunc = () => new Child(); | |
parentFunc = childFunc; |
上面已经解释过,协变用于 『返回值隐式转换为父类』:因为 Child 可以转为 Parent,所以 Func<Child>
也可以安全转为 Func<Parent>
.
当我们调用 childFunc () 可以得到一个 子类
当我们调用 parentFunc () 可以得到一个 父类
当 parentFunc=childFunc 调用可以得到一个 父类
符合 『子类指向父类的关系』,协变成立。
# 测试逆变
协变标识返回值,逆变标识参数。
虽然听起来差不多,不过协变看着其实更符合思考,因为基于接口、委托的执行返回值我们可以更加直观地得出结论,逆变可能就没那么容易理解了。
还是先以 C# 内置 IComparable 接口为例,该接口标记了逆变,我们定义两个类:Parent 和 Child,并使 Parent 实现该接口。
IComparable<Parent> ip = new Parent(); | |
IComparable<Child> iChild = new Child(); | |
//==== 报错 ==== | |
ip = iChild; | |
// 正常赋值,逆变使得声明父类接口隐式转为声明子类接口 | |
iChild = ip; |
在上述代码中,反而是 Child 接口能够接受 Parent 接口对象,第一个地方将 iChild (子类接口) 赋值 ip (父类接口) 为什么会报错呢?
在上面我们已经测试过协变了,协变得出的结论是最终返回结果的类型一定是符合转换规则的,那么这里应该也可以先从执行结果上考虑:
若执行
IComparable<Parent>
接口,需要接受 Parent 或 Child 参数
若执行IComparable<Child>
接口,只能接受 Child 参数
- 如果我们把 ip 赋值给 iChild,那么在参数中就变成只能接受 Child 类型了。
- 但是如果把 iChild 赋值给 ip,那么参数中种就变成也可以接受 Parent 类型了。
所以,区别是什么?
区别在于:(1) ip 本来是接受父类型,变成只能接受子类型,是合理的。(2) iChild 本来只能接受子类型,如果变成接受父类型参数则不合理。
想一想,一个方法的参数接受的是子类,但是把父类传进去,是不是只有强制类型转换?但是强制类型转换是不是又涉及到这个『父类实际上装的并不是这个子类』问题呢?
说实话,逆变确实感觉更绕,但从结果上来看,又能感觉确实应当如此。
泛型委托也是一样的道理:
Action<Parent> parentAction = (p) => Debug.Log(p); | |
Action<Child> childAction = (c) => Debug.Log(c); | |
// 报错,因为如果赋值成功,调用 parentAction (childAction) 就可以传入父类型了 | |
parentAction = childAction; | |
// 正常赋值,childAction 只能接受 Child 类型,parentAction 接受 Child 类型符合父类指向子类的关系 | |
childAction = parentAction; |
# 总结
协变 (out) 逆变 (in):数组是协变的 - 子类数组可以隐式转为父类数组使用,只能用于『数组、泛型委托、泛型接口』,协变用于返回值隐式转为父类 (容器),逆变用于传入值可以被隐式转为子类 (容器)(当然在调用时类型就变成确定了),不变:不可互转,变体仅适用于引用类型。
- 逆变常见是 Action <子类>=Action < 父类 >,参数为父类的委托赋值给参数子类的委托,调用时必须传入子类对象,根据面向对象规则子类一定包含父类字段或方法,可以正常调用
- 协变常见的比如 List 实现的 IEnumerable,正常情况下 List <子类型> 无法赋值给 List < 父类型 >,这个接口就标记了协变,使得可以声明 IEnumerabl < 父类型 > = List < 子类型 >
- 也可以简单记为:协变可以使得声明为父类的泛型可以装载子类,逆变使得声明子类的泛型可以可装载父类,其最终执行的返回值、参数符合里氏替换原则。
对于这两者,从表现上来看:
- 协变:声明的泛型容器子类可以赋值父类
- 逆变:声明的泛型容器父类可以赋值子类
从调用结果上来看:
- 协变:返回结果符合里氏替换原则,返回值为父类型的方法也可以返回子类型
- 逆变:接收参数符合里氏替换原则,参数为父类型的方法可以接受子类型的参数
所以回到前面的一个问题:对于同一个模板参数 T ,协变和逆变不能同时存在,为什么呢?
仔细考虑一下,如果一个接口参数同时支持协变和逆变,那么上述测试的赋值方式就必须同时双向支持,这样就会导致出现『参数为子类型的接口,可以传入父类型』,这是不合法的。
反之亦然,返回参数为子类型的接口,返回了实际为父类型的对象,也是不合法的。
C# 中常见的自带协变逆变的接口或委托:
IEnumerable<out T>
IEnumerator<out T>
System.Linq.IQueryable<out T>
IComparer<in T>
IComparable<in T>
IEqualityComparer<in T>
IReadOnlyList<out T>
TResult Func<in T, out TResult>(T arg) 等
void Action<in T>(T obj) 等
- 数组默认协变
—————————————————————————————————————————
参考文档:
- covariance-and-contravariance
- C# - 协变、逆变 看完这篇就懂了
- 逆变与协变详解
- .NET 4.0 中的泛型协变和反变