协变 (Covariant)、逆变 (Contravariant) 与抗变 (Invariant)
协变 (Covariant)、逆变 (Contravariant) 与抗变 (Invariant)
定义
我们会记起里氏替换原则,对于任意类型关系而言,子类型可以胜任父类型的任何场景。
数组情况
以数组为例,我们有Animal
以及继承自Animal
的 Cat
和Dag
。
- 协变:一个
List<Cat>
也是一个List<Animal>
- 逆变:一个
List<Animal>
也是一个List<Cat>
- 以上二者均不是则为不变
如果要避免类型错误,且数组支持对其元素的读、写操作,那么只有第3个选择是安全的。List<Animal>
并不是总能当作List<Cat>
,因为当一个客户读取数组并期望得到一个Cat
,但List<Animal>
中包含的可能是个Dog
。所以逆变规则是不安全的。
反之,一个List<Cat>
也不能被当作一个List<Animal>
。因为总是可以把一个Dog
放到List<Animal>
中,也就是说,我们可能尝试将Dog
写入这个被当做List<Animal>
但实际上是List<Cat>
。在协变阵列,这就不能保证是安全的,因此协变规则也不是安全的。注意,这仅是可写(mutable)阵列的问题;对于不可写(只读)阵列,协变规则是安全的。
这示例了一般现象。只读数据类型是协变的;只写数据类型是逆变的。可读可写型别应是“不变”的。
函数情况
对于函数而言,我们将一个函数期望输入一只 Cat
并返回一只 Animal
写为 Cat -> Animal
。
可以说,函数f
可以安全替换函数g
,如果与函数g
相比,函数f接受更一般的参数类型,返回更特化的结果类型。例如,函数型别Cat->Cat
可安全用于期望Cat->Animal
的地方;类似地,函数型别Animal->Animal
可用于期望Cat->Animal
的地方
换句话说,类型构造符对输入类型是逆变的而对输出类型是协变的。这一规则首先被Luca Cardelli正式提出。[1]
对于以下示例,假设Cat
是 Animal
的子类(使用Java语法)
1 | class AnimalShelter { |
问题是:如果我们子类化 AnimalShelter
, 我们可以让getAnimalForAdoption
和 putAnimal
具有什么类型?
返回值的协变
在允许协变返回值的语言中, 子类可以重写 getAnimalForAdoption
方法来返回一个更窄的类型:
1 | class CatShelter extends AnimalShelter { |
主流的面向对象语言中, Java和C++允许返回值协变,C#不支持。
方法参数的逆变
类似地,子类重写的方法接受更宽的类型也是类型安全(type safe)的:
1 | class CatShelter extends AnimalShelter { |
允许参数逆变的面向对象语言并不多——C++和Java会把它当成一个函数重载。
在有泛型的语言中,前面的例子可用更类型安全的方式重写:不定义 AnimalShelter
,改为定义一个参数化的类 Shelter<T>
。(这种方法的缺点之一是基类实现者需要预料到哪些类型要在子类中特化)
1 | class Shelter<T extends Animal> { |
更多案例
https://stackoverflow.com/questions/2662369/covariance-and-contravariance-real-world-example
为什么要考虑变型
考虑这样一个例子:一个对数组排序的 sort 函数,如果需要适用于各种类型的数组,那么 sort 函数要求的参数(形参)就需要足够泛化,这使得 sort 需要做非常多繁琐的事情,甚至繁琐到无法实现。比如说 sort 接收一个 Object 数组,这足够泛化,但函数内部并不知道数组每个元素实际是什么类型,那么比较方法就无法确定,毕竟 int 也不能跟 string 去比较大小。由于考虑入参的函数是逆变的,这使得函数的泛化性扩展受到了约束。
关于协变的例子就比较直观,协变是很容易理解的,协变的类型构造器的特化性扩展受到自然约束,也就是说,要支持更具体的类,就需要做各样的判断和特例操作。譬如,用一个 Food<>
类来生产粮食,以喂养动物,Food<Animal>
非常泛化,只能生产所有动物都能吃的粮食,那么我们的 Food<Animal>
可能只生产水,猫狗喝了都没事;而对于 Food<Cat>
猫食构造机,那么除了水,还能生产鱼干,对于 Food<Dog>
狗食构造机,除了水还能生产骨头,且不应生产巧克力等等。由于特化本身就是同细胞分裂一样无限延伸的,所以特化性扩展自然受到约束。
这两个例子说明了什么呢?1. [逆变]依赖于某个组件的,组件越特化,行为越好写;2. [协变]服务于某个组件的,组件越泛化,行为越好写。 这种指导思想在我们设计框架或是组件时,可以帮助我们拿捏泛化和特化的程度(当然这里说的特化是指更具体化的意思,并不是 C++ 模板编程中的特化 (template specialization),虽然核心思想也差不多)。
而不变 (invariant) 呢,在设计类型构造器时,不变的类型构造器是很影响复用性的,等于说强行要分类讨论,每种类型具体分析,要去复用或许还需要分析行为本身有没有共同点,然后去抽取公共函数。不变性或许很影响复用性,但对类型系统而言是最简单的情况,无论它实际是逆变的还是协变的,当成不变永远不会出错。
逆变阻止了类型构造器无限泛化,对于越泛化的依赖项,类型构造器能获得的特性支持越少。这种情况下,有两种方式可以解决:1. 构建约束,使依赖项有基本的特性保证; 2. 依赖插槽(依赖注入),插入实现构造器所需求的特性实现。很多时候可能两种方式需要一起使用,特别是第一种,约束在很多情况下是必须的。
还是 sort 函数的例子,我们需要约束传入数组的元素类型是统一的,否则问题的规模过于庞大(无限种类型和无限种类型之间的大小比较)。然后,约束数组元素实现比较运算符(或实现某种可比较接口),或是让 sort 函数接收一个 compare 比较函数插槽,让插槽去处理泛化所抹去的必要特性。
在各大语言 (C#, Java, etc.) 中,使用泛型 (Generics) 去做这种类型约束,而使用插槽拓展 sort 函数的灵活性。注意使用泛型之后,不同类型数组被当成不变的,由具体类型去生成具体的 sort 行为,接收不同数组的 sort 函数之间也不存在类型关系。
协变的类型构造器无法无限具体化,服务对象越具体化,类型构造器能从父类中得到的帮助越少。这种情况下,应该1. 制定好类型构造器的行为边界,以便父类实现能更大程度地为子类服务(复用);2. 可以将行为中的差异化操作委托给服务对象实现,以减少类型构造器的设计冗余。
第 1 点不举例子,关于第 2 点,在 Food<> 例子中,我们可以为 Animal 类设计一个 bool isEdible(food)
函数接口,让动物自己告诉食物机,某个食物能不能吃。这样一来,Food<> 只需要关注如何生产粮食,并调用 isEdible 筛选菜单即可,借由泛型/模板,可以使 Food<> 不断特化下去。
特定语言中的样例
Java
抗变
Java中泛型是抗变的,那就意味着List<String>
不是List<Object>
的子类型。因为如果不这样的话就会产生类型不安全问题。
例如下面代码可以通过编译的话,就会在运行时抛出异常
1 | List<String> strs = new ArrayList<String>(); |
所以上面的代码在编译时就会报错,这就保证了类型安全。
但值得注意的是Java中的数组是协变的,所以数组真的会遇到上面的问题,编译可以正常通过,但会发生运行时异常,所以在Java中要优先使用泛型集合。
1 | String[] strs= new String[]{"ss007"}; |
协变
抗变性会严重制约程序的灵活性,例如有如下方法copyAll
,将一个String
集合的内容copy到一个Object
集合中,这是顺理成章的事。
1 | // Java |
但是如果Collection<E>
中的addAll
方法签名如下的话,copyAll
方法就通不过编译,因为通过上面的讲解,我们知道由于抗变性,Collection<String>
不是Collection<Object>
的子类,所以编译通不过。
1 | boolean addAll(Collection<E> c); |
那怎么办呢?
Java通过通配符参数(wildcard type argument)来解决, 把addAll
的签名改成如下即可:
1 | boolean addAll(Collection<? extends E> c); |
? extends E
表示此方法可以接收E
或者E
的子类的集合。此通配符使得泛型类型协变了。
逆变
同理有时我们需要将Collection<Object>
传递给Collection<String>
就使用? super E
,其 表示可以接收E
或者E
的父类,子类的位置却可以接收父类的实例,这就使得泛型类型发生了逆变
1 | void m (List<? super String){} |
协变与逆变的特性
当使用? extends E
时,只能调用传入参数的读取方法而无法调用其修改方法。 当使用? super E
时,可以调用输入参数的修改方法,但调用读取方法的话返回值类型永远是Object,几乎没有用处。
这与我们在上文数组情况下最后的结论是一致的,只读数据类型是协变的;只写数据类型是逆变的。可读可写型别应是“不变”的。
Kotlin
Kotlin中没有通配符,取而代之的是 Declaration-site variance和Use-site variance 。其通过两个关键字out
和in
来实现Java中的? extends
与? super
的功能.
假设我们有如下两个类和一个接口
1 | open class Animal |
协变(out)
我们要定义一个方法,参数类型为Box<Animal>
,但是我们希望可以传入Box<Dog>
即希望可以发生协变。
Java实现
1 | private Animal getOutAnimalFromBox(Box<? extends Animal> box) { |
Kotlin对应的实现为:
1 | fun getAnimalFromBox(b: Box<out Animal>) : Animal { |
此方法可以接受Box<Dog>
类型的参数了。
可见此处使用out
代替了? extends
。从结果来看确实更合适一点,因为传入的参数只能提供值,而不能消费值。由于out
是在方法调用的参数中标记的,处于使用端,所以叫Use-site variance与Use-site variance对应的就是Declaration-site variance了。
我们发现接口Box<T>
中既有消费值的方法fun putAnimal(a: T)
,又有提供值的方法fun getAnimal(): T
,导致我们必须在使用侧告诉编译器我们要使用哪一类方法。那我们可以在声明接口的时候告诉编译器吗?答案是肯定的,但是就需要将接口拆分为只包含提供值的方法的接口producer与只包含消费值的方法的接口consumer。
1 | //producer |
拆分完接口并做了相应的声明后,就可以不在使用端使用out
或者in
了。
1 | fun getAnimalFromReadableBox(b: ReadableBox<Animal>){ |
上面的方法可以直接接受ReadableBox<Dog>
类型的参数,给人的感觉好像是Kotlin使得泛型协变了。
1 | getAnimalFromReadableBox(object :ReadableBox<Dog>{ |
此种情况下out
和in
是在声明时候使用的,所以叫Declaration-site variance了。
逆变(in)
我们要定义一个方法,参数类型为Box<Dog>
,但是我们希望可以传入Box<Animal>
,即希望可以发生逆变。
Java实现
1 | private void putAnimalInBox(BoxJ<? super Dog> box){ |
Kotlin对应实现
1 | fun putAnimalInBox(b: Box<in Dog>){ |
此方法可以接受Box<Animal>
类型的参数了
可见此处使用in
代替了? super
,从结果来看确实更合适一点,因为传入的参数只适合消费值,而不适合获取值,获取到的值失去了有用的类型信息。由于in
是在方法调用的参数中标记的,处于使用端,所以叫Use-site variance
让我们来看一下使用Declaration-site variance实现逆变
1 | fun putAnimalToWritableBox(b:WritableBox<Dog>){ |
上面的方法可以直接接受WritableBox<Animal>
类型的参数,给人的感觉好像是Kotlin使得泛型逆变了。
1 | putAnimalToWritableBox(object :WritableBox<Animal>{ |
Dart
Some (rarely used) coding patterns rely on tightening a type by overriding a parameter’s type with a subtype, which is invalid. In this case, you can use the
covariant
keyword to tell the analyzer that you are doing this intentionally. This removes the static error and instead checks for an invalid argument type at runtime.一些(少数情况下)编程模式要求使用子类类型覆写参数类型来收紧类型本身(也就是说参数协变,正常来讲参数一般是逆变的,尤其是函数参数)(指拥有这个参数的类型),这在语法上不合法。这种情况下,你可以使用
covariant
关键字告诉分析器你的意图。此时静态错误被抑制,而在运行时检查参数类型的合法性。
正常来说,一个类型构造器的子类,应该接收更泛化的参数(逆变),而返回更特化的结果(协变),才能使得子类适用于所有父类场景。而 Dart 中的 covariant 关键字打破了这个约定,covariant 允许子类收紧其参数的类型(使构造器对于参数是协变的),以使子类专门化。这实际上是违背里氏替换原则的。
考虑其给出的例子:
1 | class Animal { |
给每一种动物定义了追逐其他动物的方法,而在猫的实现中,收紧了追逐对象,表明猫只能追逐老鼠。这使得 Cat 不是在任何场景下都能将类型擦除成 Animal 使用的,违背了里氏替换原则。
在 Dart 中,如果重写父类方法,则重写方法的参数必须与原始方法具有相同的类型
Since Animal.chase
in your example accepts an argument of Animal
, you must do the same in your override:
1 | class Cat extends Animal { |
为什么?想象一下,如果没有这样的限制。Cat
可以定义void chase(Mouse x)
而Dog
可以定义void chase(Cat x)
。然后如果你有一个List<Animal> animals
,你调用所有动物中某个的chase(cat)
方法,如果该动物是狗,正常,但如果是猫,参数中的猫就不是老鼠!Cat 类无法处理被要求追逐另一只 Cat 的问题。
所以你必须使用void chase(Animal x)
. 我们可以通过添加运行时类型检查来模拟void chase(Mouse x)
类型签名:
1 | void chase(Animal x) { |
事实证明这是一个相当常见的操作,如果可能的话在编译时检查它会更好。所以 Dart 添加了一个covariant
运算符。将函数签名更改为chase(covariant Mouse x)
(其中 Mouse 是 Animal 的子类)会做三件事:
- 允许您省略
x is Mouse
检查,因为它已为您完成。 - 如果任何 Dart 代码调用
Cat.chase(x)
而 x 不是 Mouse 或其子类(如果在编译时已知),则会产生编译时错误。 - 在其他情况下创建运行时错误。
在 Dart 中,如果重写超类方法,则重写方法的参数必须与原始方法具有相同的类型。
由于Animal.chase
在您的示例中接受了一个参数Animal
,因此您必须在覆盖中执行相同的操作:
1 | class Cat extends Animal { |
为什么?想象一下,如果没有这样的限制。Cat
可以定义void chase(Mouse x)
而Dog
可以定义void chase(Cat x)
。然后想象你有一个List<Animal> animals
,你打电话chase(cat)
给其中一个。如果动物是狗,它会起作用,但如果动物是猫,猫就不是老鼠!Cat 类无法处理被要求追逐另一只 Cat 的问题。
所以你被迫使用void chase(Animal x)
. 我们可以通过添加运行时类型检查来模拟void chase(Mouse x)
类型签名:
1 | void chase(Animal x) { |
事实证明这是一个相当常见的操作,如果可能的话在编译时检查它会更好。所以 Dart 添加了一个covariant
运算符。将函数签名更改为chase(covariant Mouse x)
(其中 Mouse 是 Animal 的子类)会做三件事:
- 允许您省略
x is Mouse
检查,因为它已为您完成。 - 如果任何 Dart 代码调用
Cat.chase(x)
x 不是 Mouse 或其子类(如果在编译时已知),则会产生编译时错误。 - 在其他情况下创建运行时错误。
另一个例子是operator ==(Object x)
对象上的方法。假设你有一个类Point
:
你可以这样实现operator==
:
1 | class Point { |
但是即使你比较Point(1,2) == "string"
一个数字或其他一些对象,这段代码也会编译。将点与不是点的事物进行比较是没有意义的。
可以使用covariant
来告诉 Dartother
应该是一个点,否则就是一个错误。
1 | bool operator==(covariant Point other) => |
参考
https://zh.m.wikipedia.org/zh-hans/协变与逆变
https://codingnote.com/2020/03/08/java-covariant-contravariant-invariant/
https://blog.csdn.net/ShuSheng0007/article/details/108759370
https://typealias.com/guides/illustrated-guide-covariance-contravariance/
https://zhuanlan.zhihu.com/p/268523581
https://blog.csdn.net/B1151937289/article/details/119523464
https://stackoverflow.com/questions/71237639/functioning-of-covariant-in-flutter