类型协变和逆变是什么鬼?

类型协变和逆变,什么鬼?干什么的?怎么用?

不少童鞋面试的时候会被问到关于类型协变和逆变的问题。这不是最近才发生的事情,自从Java5.0开始引入类型协变和逆变的概念到现在,无数的无辜童鞋面试折在这个上面。虽然不是什么新概念、新技术,但是真TMD绕啊!

下面我们就来看看类型协变和逆变到底是什么鬼。

首先,既然是“变”,那么大家就不要觉得所谓“协变”和“逆变”是类型系统自然的结论,一定有什么不那么自然的事情才够刺激!

那么先看看最自然的是什么情况(没有"变"哦):

class Fruit {}
class Apple extends Fruit { ... }
class Orange extends Fruit { ... }

大家一定认可这样的代码:

Fruit fruit = null;
Apple apple = new Apple();
fruit = apple;

 那下面的代码呢?

Fruit[] fruitAry = null;
Apple[] appleAry = new Apple[10];
fruitAry = appleAry;

这个看起来很自然啊!我说我要一个能装水果的篮子,然后你给我一个装苹果的篮子,我说这就是我要的。多么自然啊!

但是不好意思,编译器不这么看。在编译器看来,它只知道苹果是一种水果,现在你拿着一个装苹果的篮子,硬说它是装水果的篮子,编译器当然不同意了。结果就是“编译出错!”

这种情况是出在5.0以前的编译器上的。那么现在的编译器又是怎么说的呢?

现在的编译器把一个具有泛型参数T的泛型类型看做是关于类型T的一种变换,类型T的数组也可以看做是接受泛型参数为T的一种泛型类型。注意这里仅仅是看做,是理论上的,就是说从理论上String[]和Array<String>是等价的。

所以后面的讨论,都是基于泛型来进行的,不特别针对数组这种特殊情况了。

下面来看两个泛型类型的声明:

class Picker<T> {
    public T pick() {
        // ...
        return (T)null;
    }
}

class Checker<T> {
    public Boolean check(T tobeChecked) {
        return false;
    }
}

对于这两个泛型类型和上面的水果和苹果、橘子类型,下面的声明和赋值操作又会是什么情况呢?

Picker<Fruit> fruitPicker = new Picker<Apple>(); // 编译报错
Fruit fruit = fruitPicker.pick();
Checker<Fruit> fruitChecker = new Checker<Apple>(); // 编译报错
Boolean result = fruitChecker.check(fruit);

不是说5.0以后能解决这类变换了吗?怎么还报错捏?

原因是:“事情没那么简单,骚年。”

现在仔细研究一下 Picker 类型和 Checker 类型,看一下在声明、实例化、赋值 以及 使用的时候,程序猿一般会有什么样的假设吧。

对于 Picker<T>:

Picker<Fruit> fruitPicker = null; // 我假设有一个能摘水果的采摘机,名叫 fruitPicker
Picker<Apple> applePicker = new Picker<Apple>(); // 我买了一个叫做 applePicker 的苹果采摘机
fruitPicker = applePicker; // 我说这个苹果采摘机就是我需要的那个叫做 fruitPicker 的水果采摘机
Fruit fruit = fruitPicker.pick(); // 我用这个水果采摘机摘了一个水果,至于是苹果还是橘子,无所谓

// 上面这段代码关键点是调用的泛型类型的方法是返回泛型参数类型 T 的方法 public T pick();

// 如果是要接受类型 T 的参数的方法捏?
Checker<Fruit> fruitChecker = null; // 我假设有一个能检验水果品质的机器,叫做 fruitChecker
Checker<Apple> appleChecker = new Checker<Apple>(); // 我买了一个验苹果机,叫 appleChecker
fruitChecker = appleChecker; // 我说这个验苹果机就是那个 fruitChecker
Boolean result = fruitChecker.check(fruit); // 我用它来检验水果 fruit

对于前面采摘机的情况,这些假设很自然,也很合理。这种情况就叫做是类型协变的(协调嘛)。

但是对于检验水果的机器就不是那么回事了,看这句:

Boolean result = fruitChecker.check(fruit); // 我用它来检验水果 fruit

没有前面所有代码,单单看这句的话,我们只知道有这么个水果检验机,我用它来检验一个水果。这时候,我并不知道到底是检验的苹果还是橘子。尽管前面实际上买的是个苹果检验机,而更前面 fruit 实际上也指向了一个 苹果。但这一切在运行的时候就不会变吗?如果这些代码不是挨在一起写的,而是分别放在不同的方法中呢?没人保证中间不会有别的什么代码把 fruit 的指向换成了一个橘子。到那个时候用一个检验苹果的机器来检验橘子,很明显不会有好下场。

编译器要是不管这事,那还要强类型语言干什么呢?强类型语言的一大卖点不就是通过强类型控制,在编译的时候挡住大部分类型方面的问题从而减少运行时错误吗?

那么看来这种对泛型参数类型的变换造成了运行时的类型问题啊。这个错误本质上是上面的假设和使用方法不合逻辑。

不同于采摘机(拿到一个采摘机,它会吐出摘下来的果子,那么能处理任何果子的加工过程才会用一个水果采摘机的抽象来使用其采摘方法,以期获得一个果子);检验机需要一个传入的参数,那么苹果检验机就需要传入一个苹果,橘子检验机就需要一个橘子。什么样的语言构造所表示的语义能自然地保证苹果检验机在检验方法被调用的时候就一定能得到一个苹果呢?

这显然不那么容易,但是不管怎么说,赋值逻辑以及参数传递逻辑都符合同样的一个规则:

不管我要什么,你只能给我更加具体的东西,而不能给我更抽象的东西!

我要一个水果,你可以给我一个苹果,但如果我要一个苹果,你给我一个水果,我就懵圈了。

看 Picker 的定义和使用:

    public T pick() { ... }
}

Picker<Fruit> fruitPicker = null; // 这个声明能保证 pick 方法返回的一定是 Fruit 或 其子类型的对象

Fruit fruit = fruitPicker.pick(); // 需求的范围和声明的范围是一致的,需要返回一个Fruit 或其子类型的对象

翻回头再看赋值语句:

fruitPicker = applePicker; // 赋值没有缩小需求范围,而是缩小了提供范围
// 把一个返回更具体类型的采摘机赋值给一个返回更宽泛类型的采摘机
// 在使用这个采摘机的时候,需求的肯定是宽泛的类型

再看Checker的定义和使用:

Checker<Fruit> fruitChecker = null; // 可以肯定其 check 方法需要一个 Fruit 或 其子类型的对象

fruitChecker.check(fruit); // 使用的时候,说明需求是一个 Fruit 或其子类型的对象,没有缩小需求范围

那看看赋值语句呢:

fruitChecker = appleChecker; // 把需要更具体类型参数的对象赋值给一个需求更宽泛类型的引用
// 实际上这个赋值收缩了需求范围
// 原本定义 fruitChecker 的时候,需求范围是“需要一个水果”
// 而这个赋值则缩小了需求范围,变成了“需要一个苹果”

问题就在于赋值语句实际上缩小了需求范围,而编译器是不可能跟踪这种需求范围的缩小的。

总结一下:

如果方法是提供型的,就是说对外提供某个类型的对象。这种情况下,实例化和赋值会缩小提供的范围,会提供更加具体的对象,对于使用者而言,是绝对没有问题的。

反之,如果方法是需求型的,就是说需要某个类型的对象。这种情况,实例化和赋值会缩小需求范围,就是说方法调用的时候对于传入参数的限制比起引用声明的时候还要严格和具体。按照引用的声明,使用这个引用的代码会假设需求是一个比较宽泛的范围,而实际上代码运行的时候这个引用所指向的对象是一个需求更窄的对象。这就会出问题。

写代码的时候,我们只应该根据所使用的对象引用的声明类型来做假设。而如果赋值使得假设不能成立的时候,问题就一定会出现!

像上面 Checker 的例子,声明和引用的时候都是假定其方法能接受一个 Fruit 类型的对象。

而实例化和赋值却给了这个引用一个能力更弱的对象,这个对象的方法只能接受更具体一些的参数,这怎么行?

看来是到了从语法上增加点什么,来规范这种行为的时候了。还是分成两种情况,返回 T 类型 和 需求 T 类型。

如果类型被使用到的所有方法只是返回 T 类型的对象,那么可以使用类型协变:

// 声明了一个协变引用 fruitPicker,
// 使用这个引用的地方会假定:那里所使用的方法一定是返回 Fruit 或其子类型的对象
Picker<? extends Fruit> fruitPicker = new Picker<Apple>();

// 使用的时候,按照期望返回比较抽象的情况来做出假设,编写处理比较抽象的对象的代码,不能要求更具体的东西
Fruit fruit = fruitPicker.pick();

如果类型被使用到的所有方法都是接收 T 类型参数的,那么就需要使用逆变:

// 声明的引用类型表示,使用这个引用的地方,一定是期待有一个能接受任何 Apple 或其祖先类型参数的逻辑
// 实例化并赋值的实际对象是一个拥有能够接受 Fruit 类型参数的方法的对象
Checker<? super Apple> appleChecker = new Checker<Fruit>();
//...
Apple apple = new Apple();
//...
appleChecker.check(apple); // 调用的时候,把一个苹果传给一个接收水果类型参数的方法是没有问题的

 

最后总结一下

协变适合以下场景:

1. 只调用返回泛型参数类型 T 这种类型的方法;

2. 实例化和引用赋值可以缩小提供类型的范围,使其比声明引用和方法调用的代码更具体;

3. 语法上,在声明引用的时候使用类似 Picker<? extends Fruit> 这样的形式。

逆变适合以下场景:

1. 只调用接收泛型参数类型 T 这种类型作为参数的方法;

2. 实例化和引用赋值可以扩大接收参数类型的范围,使得实际得到的对象比调用其方法的代码对参数的要求更宽容,更抽象;

3. 语法上,在声明引用的时候使用类似 Checker<? super Apple> 这样的形式。

对于逆变,不好理解,可能下面的例子能容易理解一点:

Checker 类型的 check 方法的语义其实是只需要检测东西还能不能吃;

Fruit 继承自 Food;

有一种食物腐败检测仪,其 check 方法实际上是对食物是否变酸、发出腐败气味、或者有没有酒味等这些方面进行检测。那么这种检测仪其实可以检测任何食物。

现在超市的水果组需要添置一台水果检测仪,由于经费有限,最后只给了他们一台食物腐败检测仪,还要求他们和水产组共用。在水果组看来,这台仪器就是水果检测仪,也能凑合用;对于水产组,这个就是水产检测仪,没毛病。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值