# 前言

关于协变和逆变,早在前些日子就在想整理一下了,不过由于前几天研究了下 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 中的泛型协变和反变