协变与逆变

ChlorineC Lv4

协变(covariant)和逆变(contravariant)是对于类型的概念,是类型安全和类型转换中的重要概念,但可能对大多数初学者来说是一个陌生的概念。

其概念借鉴自范畴论,在C#/Java/Typescript这样强类型安全语言中有较广泛的应用,维基百科上的解释相对复杂,数学意味比较重,令许多人望而生畏。

希望本文能够消除其神秘感,用尽量通俗的语言使大家理解协变和逆变及其在泛型中的应用。

背景知识

首先我们来看一个例子,这是我最近在完成的一个机器学习模型运行平台的UML类图,我们借用其结构来观察类之间的继承关系。

继承树示意图

我们看左边那一列,继承链是这样的,从左到右为子类的父类的协变顺序:MLPModel->NNModel->MLModel->ModelBase(这里不需要思考为什么,只需要知道这个序列就行了)

协变与逆变的定义

我们都知道,在几乎所有面向对象的语言中,子类型都可以隐式的转为父类型(如上文中PRModel可以隐式转为ModelBase类型而不发生错误)。

我们说A如果可以隐式转化成B,A就是B的子类型(A≤B),如int可以隐式转化为float,则int就是float的子类型。

需要明晰的一点是:在子类型序列关系的角度上,A≤B(A是B的子类型);但从实现的角度上,A是B的子类,那么A实现的成员一定涵盖的B的所有成员(可以说是大于等于)。

以上文给出的子类型序列关系为例:MLPModel≤NNModel≤MLModel≤ModelBase

根据上面的子类型序列关系(即从小到大排列的继承链),我们就可以定义协变和逆变

  • 协变(covariant):变化方向与子类型序列相同(如把MLPModel对象变成ModelBase对象)
  • 逆变(contravariant):与协变相反,变化方向与子类型序列相反

在本例的UML图中,其具体放下如下图所示:

image-20230124162448398

在绝大多数编程语言中,协变是允许的,而逆变是禁止的,原因显而易见:协变是类型安全的,而逆变是类型不安全的。因此逆变的实际应用并不在将基类对象直接转换成子类类型(父类实例可能没有子类的成员,在引用调用时会出现NullReference的类型安全问题),而是在与生产者与消费者函数相关的定义上。

PECS规则

PECS(Producer Extends Consumer Super)规则是在Java中广泛应用的一种类设计规则,目的是让生产者和消费者都能在类型安全的前提下处理最大范围的数据类型,具体规则就是生产者(产生数据)考虑数据类型时应该尽量沿继承树向下,越具体越好;消费者(接收数据)考虑数据类型时应该尽量沿着继承树向上,越抽象越好

用协变和逆变的语言来说就是:生产者逆变,消费者协变

为什么这样可以最大范围地接收数据且保证类型安全呢?我们不妨分开思考:

我们假设有一个Animal类,其子类有CatBear等,再往下又按不同颜色有WhiteCat, BlackBear等各自的子类。

  • 生产者(producer, out-only):现有一个生产动物的类型,返回一个Animal类型的对象
    • 先不考虑逆变的协变的问题,仅从类型安全的角度考虑,我们发现该类型其实可以返回Cat, Bear等类型的对象,因为它们都是Animal,是安全的隐式转换。
    • 再回归到协变和逆变的角度,我们发现返回值从Animal类型到Cat类型是逆变的,这满足PECS原则;但在其内部的赋值又是协变的(Cat对象赋值给Animal类型是类型安全的)。
  • 消费者(consumer, in-only):现有一个收购动物的类型,它接收一个Animal类型的对象
    • 这个比较直观,因为Bear、Cat等都是动物,可以直接被接收

里氏替换原则

子类对象的行为应该可以完全替代父类对象的行为,满足里氏替换的类型转换就是类型安全的。

这部分其实相对不那么重要,一时间没想清楚可以跳过。

那么和我们的协变逆变有什么关系呢?我们只需要把继承链倒过来,把原本的子类作为父类,看作一种新的继承,这样就可以把逆变应用到里氏替换中来判断是否类型安全了。

泛型中的协变与逆变限制

理解了协变与逆变的基本概念后,我们来着手看一个例子来理解泛型中为什么要对协变性和逆变性做出限制。

在C#/Java中,有许多基于其他类型的类型,如数组类型,它是允许协变的(允许把string[]隐式转化成object[]),这样做有许多好处,例如可以写出下列代码通用地比较所有对象数组:

1
boolean ArrayEquals(object[] a, object[] b);

但是我们需要注意,此处类型安全地原因是该函数仅在这次转换中担任消费者的地位,因此允许协变,是类型安全的。

我们再看另一个例子:当允许协变的数组类型作为生产者(需要返回一个新的实例)时,类型安全就会崩溃。

现在我们看下面的代码:

1
2
3
4
5
6
7
8
string[] a = new string[];

// 数组是允许协变的,因此下列操作是合法的
object[] b = a;

// 向object数组插入一个float类型的值对象
// 对于object类型的数组来说是合法的,但实际上是一个string对象,因此是类型不安全的
b[0] = 1.0;

在上述例子中,我们看到了允许协变也可能带来类型不安全的问题。

解决方案也很简单,就和大家想的一样,根据PECS原则,对基于其他类型的类型做出如下限制:

  • 对于允许协变的类型,我们限制其是只读的,不能改变其值。
  • 对于允许逆变的类型,秉承与协变相反的对称性原则,我们限制其是只写的,不能读取其值(不能确定读出来值类型)

C#中in和out关键字

C#中使用in和out关键字来允许基于类型的类型(如泛型)的协变和逆变。

使用out来允许协变

在声明类型参数时加入out关键字来使该类型允许协变,如<out T>。如此,该类型在处理T的继承关系时就允许协变,但代价是与T相关类型的赋值操作是不变的(不允许协变或逆变的赋值操作)。

这样的限制可以规避我们之前提到的类型不安全的状况。

1
2
3
4
// 下列语句在没有out时编译器不通过
// 有了out语句,cats列表可以转化为animals列表,因为所有Cat都是Animal(协变)
IEnumerable<out Cat> cats = new ...;
IEnumerable<out Animal> animals = cats;

使用in来允许逆变

逆变的情况总是比协变稍微难解释一些。

在声明类型参数时加入in关键词来允许该类型逆变,如<in T>

1
2
3
4
// 有了in语句,animals列表可以接收Cat实例,是逆变
List<in Animal> animals = new List<Animal>();
// 下列语句在没有in时编译器不通过
animals.Add(new Cat());
  • 标题: 协变与逆变
  • 作者: ChlorineC
  • 创建于 : 2023-01-24 08:00:00
  • 更新于 : 2024-06-04 03:50:36
  • 链接: https://chlorinec.top/2023/01/24/Development/covariant-and-contravarirant/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论