协变(Covariant)、逆变(Contravariant)与不变(Invariant)
1. 定义
协变与逆变 (Covariance and contravariance) 是在计算机科学中,描述具有父/子型别关系的多个型别通过型别构造器、构造出的多个复杂型别之间是否有父/子型别关系的用语。
协变、逆变和不变,属于变型 (variance) 的三种结果,变型指类型构造器如何根据内部类型关系组成自己的类型关系。
协变与逆变这个概念并不局限于某种语言,支持继承与多态的语言都会遇到这个概念。
- 协变(covariant),如果它保持了子类型序关系≦。该序关系是:子类型≦基类型。
- 逆变(contravariant),如果它逆转了子类型序关系。
- 不变(invariant),如果上述两种均不适用。
2. 例子
对于任意类型关系而言,子类型可以胜任父类型的任何场景。(里氏替换原则)
对于简单类型关系 Animal 和 Cat 而言,Cat 是 Animal 的子类型。那么对于复杂类型构造器:
- IEnumerable<> 是协变的,因为 IEnumerable<Cat> 总是 IEnumerable<Animal> 的子类:
在一个需要 IEnumerable<Animal> 的地方,主调方对迭代器的操作总是希望返回一个 Animal,而将 IEnumerable<Cat> 当成 IEnumerable<Animal> 用,则会返回一个 Cat,Cat 可以胜任 Animal 的任何场景。所以说:类型构造器对其内部类型只有抛出操作时,类型构造器通常是协变的。
- Action<> 是逆变的,因为 Action<Animal> 总是 Action<Cat> 的子类:
在一个需要 Action<Cat> 的地方,主调方对一个 Action<Cat> 的调用,总是希望传进一个 Cat 时,操作顺利进行,而将 Action<Animal> 当成 Action<Cat> 用,则只要传进一个 Animal 即可顺利进行,实参传 Cat 在任何场景下都合理。所以说:类型构造器对其内部类型只有接收操作时,类型构造器通常是逆变的。(关于这个例子的疑问见第 5 节)
- IList<> 是不变的,因为 IList<Cat> 既不能当 IList<Animal> 的子类,也不能当它的父类:
如果 IList<Cat> 是 IList<Animal> 的子类,那么当主调方拥有一个 IList<Animal>(但它实际是 IList<Cat>)且想把一个 Dog 塞进去时,明明是合法操作,但操作却不安全。
如果 IList<Animal> 是 IList<Cat> 的子类,那么当主调方拥有一个 IList<Cat>(但实际是 IList<Animal>)且想从中得到一个 Cat 时,有可能得到了一个 Dog,操作不安全。
但如果列表是只读 (read-only) 的,那么 IList<> 就可以是协变的。(附注:只读数据类型称为源 (Source) ,只写数据类型称为汇 (Sink))。
所以说:类型构造器对其内部类型既有抛出操作,又有接收操作时,类型构造器应该是不变的。
3. 有什么用?
为什么我们需要讨论协变、逆变和不变?在设计编程语言,或是进阶使用编程语言的数组、继承和泛型时,必须将变型列入考量,否则可能会违反类型安全,影响程序运行时的健壮性。
正如例子中所表述的,迭代器是协变的,那么内部类型越具体,迭代器需要做的就越多(迭代器这个例子可能不太明显,子类实现只需要比父类实现多一个类型转换);函数是逆变的,即要求的参数越泛化,函数需要做的就越多;列表是不变的,那么任意两种元素类型的列表,都应该当成不同的类型对待。
考虑这样一个例子:一个对数组排序的 sort 函数,如果需要适用于各种类型的数组,那么 sort 函数要求的参数(形参)就需要足够泛化,这使得 sort 需要做非常多繁琐的事情,甚至繁琐到无法实现。比如说 sort 接收一个 Object 数组,这足够泛化,但函数内部并不知道数组每个元素实际是什么类型,那么比较方法就无法确定,毕竟 int 也不能跟 string 去比较大小。由于考虑入参的函数是逆变的,这使得函数的泛化性扩展受到了约束。
关于协变的例子就比较直观,协变是很容易理解的,协变的类型构造器的特化性扩展受到自然约束。要支持越具体的类,就需要做各样的判断和特例操作。譬如,用一个 Food<> 类来生产粮食,以喂养动物,Food<Animal> 太过泛化,需要生产所有动物都能吃的粮食,所有动物都能吃的或许只有水了,我们 Food<Animal> 只能生产水,猫狗喝了都没事;而对于 Food<Cat> 猫食构造机,那么除了水,还能生产鱼干,对于 Food<Dog> 狗食构造机,除了水还能生产骨头,且不应生产巧克力等等。由于特化本身就是同细胞分裂一样无限延伸的,所以特化性扩展自然受到约束。
这两个例子说明了什么呢?1. [逆变]依赖于某个组件的,组件越特化,行为越好写;2. [协变]服务于某个组件的,组件越泛化,行为越好写。 这种指导思想在我们设计框架或是组件时,可以帮助我们拿捏泛化和特化的程度(当然这里说的特化是指更具体化的意思,并不是 C++ 模板编程中的特化 (template specialization),虽然核心思想也差不多)。
而不变 (invariant) 呢,在设计类型构造器时,不变的类型构造器是很影响复用性的,等于说强行要分类讨论,每种类型具体分析,要去复用或许还需要分析行为本身有没有共同点,然后去抽取公共函数。不变性或许很影响复用性,但对类型系统而言是最简单的情况,无论它实际是逆变的还是协变的,当成不变永远不会出错。
4. 应对
我们更希望从中总结出能够应对逆变或是协变特性的编程思想或范式,不过这件事情或许应该交给更专业的作者来做。出于记录和分享的目的,我仍然在这一小节给出了自己的观点。
逆变阻止了类型构造器无限泛化,对于越泛化的依赖项,类型构造器能获得的特性支持越少。这种情况下,有两种方式可以解决:1. 构建约束,使依赖项有基本的特性保证; 2. 依赖插槽(依赖注入),插入实现构造器所需求的特性实现。很多时候可能两种方式需要一起使用,特别是第一种,约束在很多情况下是必须的。
还是 sort 函数的例子,我们需要约束传入数组的元素类型是统一的,否则问题的规模过于庞大(无限种类型和无限种类型之间的大小比较)。然后,约束数组元素实现比较运算符(或实现某种可比较接口),或是让 sort 函数接收一个 compare 比较函数插槽,让插槽去处理泛化所抹去的必要特性。
在各大语言 (C#, Java, etc.) 中,使用泛型 (Generics) 去做这种类型约束,而使用插槽拓展 sort 函数的灵活性。注意使用泛型之后,不同类型数组被当成不变的,由具体类型去生成具体的 sort 行为,接收不同数组的 sort 函数之间也不存在类型关系。
协变的类型构造器无法无限具体化,服务对象越具体化,类型构造器能从父类中得到的帮助越少。这种情况下,应该1. 制定好类型构造器的行为边界,以便父类实现能更大程度地为子类服务(复用);2. 可以将行为中的差异化操作委托给服务对象实现,以减少类型构造器的设计冗余。
第 1 点不举例子,关于第 2 点,在 Food<> 例子中,我们可以为 Animal 类设计一个 bool isEdible(food) 函数接口,让动物自己告诉食物机,某个食物能不能吃。这样一来,Food<> 只需要关注如何生产粮食,并调用 isEdible 筛选菜单即可,借由泛型/模板,可以使 Food<> 不断特化下去。
5. 其他问题
- 逆变对于编程来说是不直观的,理解逆变可能需要一定的时间。譬如在第 2 节中的 Action 例子中,你可能会想:凭什么说 Action<Animal> 是 Action<Cat> 的子类呢,Action<Object> 表示对一个 Object 进行处理,那么对一个更具体的 Cat 类的处理明显要精细于对 Animal 类的处理,岂不表明 Action<Animal> 无法胜任 Action<Cat> 的精细场景么?
- 然而如果你足够了解 OOP 的继承,你应该要记住,在继承中:子类对父类已有行为的继承改造,应该是相同功能的不同实现,而不是修改覆盖父类功能,更不能是其父类功能的局部实现。 Action<> 作为一种委托(C# 委托),其 invoke 方法囊括其全部特性,那么倘若有一个 Action<Animal> 能成为一个 Action<Cat> 的子类,那么这个 Action<Animal> 的处理行为,应该是其父类 Action<Cat> 的相同功能的不同实现,否则不应该成为其子类。Action<> 作为一种库模板,没有指定行为的细节,但编程中为一个 Action<> 指定细节时,它的类型信息就不再是泛化的 Action<>,不能随便指定两个 Action<> 实例,就去讨论其类型关系。理解这些,才能更好地理解继承和变型。
- 变型关系在编程中并不是绝对的,脱离应用场景去讨论变型关系,会破坏编程的灵活性。比如在第 2 节的例子中,列表是不变的,而在第 4 节的 sort 函数中,数组实际上被当成协变的(因为我们说了“Object数组足够泛化”这种话,说明 Object 数组可以当其他类型数组的父类),以及在各大支持泛型的语言的 sort 函数中,又可以将数组当成不变的,这是由排序行为的特殊性决定的,具体为什么,可以自己思考一下。