定义
引子:可替换原则
假设有两个类型T和U,那么它们的关系只可能是以下四种:
- T比U大(T是U的祖先)
- T比U小
- 两者一样大
- 两者无关 那么当T比U大或者两者一样大时,我们可以保证这么写是合法的:
U u = ...;
T t = u;
这是面向对象的基本特性。 可替换原则又可表述为:赋值兼容特性(assignment compatibility),也就是说可替换性表现为赋值操作是合法的。赋值操作有显式的等号,也有隐式的参数传递:
- 显式赋值
A = B - 接收方法的返回值
A = func(),其中func()的返回值声明是B类型 - 方法调用时的参数传递
func(B),其中func()的入参声明是A类型
接下来我们看看协变和逆变的定义。
协变和逆变的定义
假设前面的T和U是有继承关系的,现在有一种操作(术语就叫映射吧)将T和U转化成了更复杂的复合类型T'和U',那么:
- 如果新类型的大小关系和T与U的大小关系一样,则称该操作是协变的
- 如果新类型的大小关系和原关系相反,则称操作是逆变的
- 如果新类型无关了,则称该操作是不变(或抗变)的
这个定义比较抽象,下面给个例子帮助理解:
设T是Animal,U是Cat,显然后者比前者小。
令这里的操作是“数组化”,那么T'和U'就分别是Animal[]和Cat[]。在Java中Cat[]是可以被赋值给Animal[]的,所以后者比前者小。因此我们说,Java的数组是协变的。如果Animal[]可以被赋值给Cat[],那就说数组是逆变的;如果两个方向都不能赋值,就说数组是不变的。
既然可替换方向有三种,那我们怎么选呢?下一节我们将看到,要考虑的主要是类型安全问题。
小结
本节我们了解了可变性(就是协变、逆变、不变的一种)的定义。关键点就是:1. 可替换;2. 复合类型。
接下来我们分情况讨论常见的复合类型。
需要考虑协变和逆变的几种复合类型:
我们将按照由易到难的顺序逐一讨论。
数组
先说结论:
Read-only data types (sources) can be covariant; write-only data types (sinks) can be contravariant. Mutable data types which act as both sources and sinks should be invariant.
- 只读的数据可以是协变的
- 只写的数据可以是逆变的
- 可读写的数据应该是不变的
为啥呢?举个数组的例子:
- covariant: a
Cat[]is anAnimal[]; - contravariant: an
Animal[]is aCat[]; - invariant: an
Animal[]is not aCat[]and aCat[]is not anAnimal[].
如果我们要读一个Animal[]数组,那么采用协变是安全的,因为即使数组里面有Cat,把它当成Animal也是OK的,代码太常见,省略。但是采用逆变就不对了:如果我们要读一个Cat[],但是有可能拿到的是一个Animal类,显然是有问题的:
Cat[] cats = new Animal[10]{...}; // 假如某语言的数组支持逆变,可以这么写
cats[0].miao(); // 挂了
如果我们要写一个Animal[]数组,采用协变则是有问题的。看看这段程序:
Animal[] animals = new Cat[10]; // 协变,没毛病
animals[0] = new Dog(); // 凉了
往Cat数组里面加入一个Dog时,显然类型对不上了。主流语言的数组往往都是协变的,因此编译器允许这么写,但是运行时Java会抛出ArrayStoreException,C#会抛出ArrayTypeMismatchException.
而采用逆变是没有问题的。
Cat[] cats = new Animal[10]{...}; // 假如某语言的数组支持逆变,可以这么写
cats[0] = new Dog(); // 没有类型错误,就是这么任性
综上:
要想保持绝对的类型安全,可读写的数据类型应该是不变的。而主流语言的数组实现时照顾了读,在写的时候可能会出现运行时异常。那么为啥不把数组设计成不可变的?
Java和C#一开始是没有泛型的。那么要提供一些通用的操作数组的方法,比如:
boolean equalArrays(Object[] a1, Object[] a2);
void shuffleArray(Object[] a);
如果数组是不变的,那就没法操作String[]等其它数组。所以为了方便使用,大家不约而同地选择让数组是协变的。这显然带来了写的问题。它们的处理方式是,在创建数组时记下最初的真正数据类型,后面写入新元素时,运行时一定会检查新元素的类型是否匹配,如果不匹配就抛异常。
这么做显然是有点问题的,但编程语言需要保持对老代码的兼容性,所以数组的行为就这样保留下来。至于有什么问题,主要是以下两点:
- 把本来在编译时能发现的类型错误推迟到了运行时才能发现
- 每次写数组都要悄悄地检查类型,影响性能
后面有了泛型之后,上面两个方法又有了泛型的实现,从而解决了数组的问题:
<T> boolean equalArrays(T[] a1, T[] a2);
<T> void shuffleArray(T[] a);
方法
先看例子1:
假设我们有个方法(不考虑具体编程语言):
static Cat makeCat() { return new Cat(); }
那么把它赋值给如下方法是类型安全的:
Supplier<Animal> s = makeCat;
因为我们期待s执行的结果是一个Animal,而makeCat返回的是Cat,不影响使用。
所以方法的返回值可以是协变的。
再看例子2: 假设我们有两个方法:
static void useCat(Cat cat) {...}
static void useAnimal(Animal animal) {...}
那么,下面的赋值是类型安全的(Mammal意为哺乳动物):
Consumer<Mammal> c = useAnimal;
因为c在执行时会传入Mammal或其子类,而useAnimal只要求Animal就行了。
相反,下面的赋值则是有问题的:
Consumer<Mammal> c = useCat;
因为c在执行时会传入Mammal或其子类,而useCat是要求Cat的,这可就保证不了了。
所以,方法的输入参数往往是逆变的。(为什么是往往,后面会解释)
综上:
方法的输入参数是逆变的,返回值是协变的。
如下面的Java示例,主流语言对于方法确实是这么处理的。
/**
* 在本例中,传到此方法的输入一定是个Integer,因此我们用个Number接受显然也没问题。
* 而调用者期待此方法返回的是Number或其子类,因此我们返回个Long也没问题。
*/
Long numberToLong(Number number) {
return number.longValue();
}
@Test
public void test() {
// 虽然函数签名类型不一样,因为参数支持逆变,返回值支持协变,仍然合法
Function<Integer, Number> function = this::numberToLong;
// 调用function.apply时,输入只能是个Integer或其子类,而result只能是Number或其父类
// 这个不叫协变或逆变,仅仅是面向对象的特性(赋值兼容性)
Object result = function.apply(1);
System.out.println(result);
}
严格地说,方法在Java中不是一等公民。Function在Java中只是个接口。这里举例仅用来说明方法确实是有协变和逆变的行为。函数式语言则是有专门的方法类型的。
进一步:子类复写父类方法时的协变和逆变
子类复写父类方法时,方法签名也不用完全一样。如图所示:
按照上面的分析,只要方法参数是逆变的,返回值是协变的,就是安全的。这其实也暗合了Liskov替换原则的几条规则。
然而这么玩实在太灵活了,主流语言中只有Scala严格实现了这个理论,其它语言则做了一些限制或变形。具体列举如下:
| 编程语言 | 参数类型 | 返回值类型 |
|---|---|---|
| C++, Java, C# | 不变 | 协变 |
| Scala | 逆变 | 协变 |
| Eiffel | 协变 | 协变 |
埃菲尔头铁语言居然打破了Liskov替换原则,法国人就是任性。
最后,方法里面还有一种高阶方法(high order functions)也要考虑可变性,过于烧脑,暂且按下不表。
泛型类或接口
最后我们来看泛型问题。
在C#设计者看来,泛型接口可以被视作方法的变形。 How?我们看前面的各种Supplier,Consumer,Function实例,都可以理解为一个对象,里面只有一个apply方法。所以接口就可以理解为方法的变形啦!这么理解有什么好处呢?就可以把方法的可变性规则推广到接口了,妙啊!
假设我们有个泛型接口,就一个方法(再次,不要关注具体语言):
public interface Iterator<E> {
E next();
}
由于这个泛型参数E是出现在方法返回值位置的,那么整个Iterator<E>接口就可以是协变的。也就是说以下这种赋值理论上是合法的:
Iterator<String> strItor = ...
Iterator<Object> itor = strItor;
我们再看另一个接口:
public interface Comparable<T> {
public int compareTo(T o);
}
泛型参数T出现在方法参数位置,所以Comparable<T>接口可以是逆变的。也就是说这种赋值理论上是合法的:
Comparable<Object> comp = ...
Comparable<String> c = comp;
好了,对理论的讨论到此结束,接下来要看具体语言如何支持泛型类或接口的可变性。主要有两种流派:
声明时指定
如前所述,C#是要在声明时指定接口泛型的可变性的。如果不声明,就是不可变的。绝大多数语言:Scala,Kotlin,都是这种行为。
Eric Lippert还在此文中讨论了使用哪种记号表示协变和逆变,最终还是觉得in和out最直观。接口的签名长这样:
interface IFoo<out T, in U> { …
接口的泛型参数是协变还是逆变是不可以随便声明的。编译器会检查泛型参数的位置以及各种复杂的传递规则,确保声明是一致的。
Kotlin和C#的记号一样,而Scala选择了 +-符号。
使用时指定
没错,就是Java了。Java的泛型根本就是不可变的。只不过可以通过通配符支持协变和逆变:
List<Number> numbers = new LinkedList<>();
List<? super Integer> a = numbers; // 逆变
a.add(1); //合法
Object a1 = a.get(1); // get只能得到Object
List<Integer> integers = new LinkedList<>();
List<? extends Integer> b = integers; // 协变
Integer object = b.get(1); //合法
b.add(1); // 写不了
在声明方法签名时,Joshua Bloch在《Effective Java》中总结了PECS原则(Producer Extends, Consumer Super),此处不再赘述。
不过大家有没有想过,为啥声明时指定时的原则是:参数逆变,返回值协变;而到了PECS,参数既可能逆变,又可能协变?
因为从使用者角度来说,参数可能是用来读的(生产者),也可能是用来写的(消费者);而在声明一个泛型类时,这个类如何使用泛型自己是知道的。其实,只要我保证我的方法参数只读不写,我也可以强行规定本泛型类是协变的!见Kotlin的@UnsafeVariance. 所以“参数逆变,返回值协变”并非一直正确。
另外,为啥只有Java这么玩呢? 还是因为这么搞虽然灵活,但太难理解了,会造成很多困惑。具体参见:比较