Java中的协变与逆变

时间:2023-03-09 19:13:55
Java中的协变与逆变

  Java作为面向对象的典型语言,相比于C++而言,对类的继承和派生有着更简洁的设计(比如单根继承)。

  在继承派生的过程中,是符合Liskov替换原则(LSP)的。LSP总结起来,就一句话:

    所有引用基类(父类)的地方必须能够透明地使用其子类的对象。

  LSP包含四层含义:

    ① 子类完全拥有父类的方法,且具体子类必须实现父类的抽象方法;

    ② 子类中可以增加自己的方法;

    ③ 当子类覆盖或实现父类的方法时,方法的形参要比父类方法的更加宽松;

    ④ 当子类覆盖或实现父类的方法时,方法的返回值要比父类方法的更加严格。

  针对LSP四层含义的③④条,就引出了协变(Covariance)和逆变(Contravariance)的概念:

    协变,简言之,就是父类型到子类型,变得越来越具体,在Java中体现在返回值类型不变或更加具体(异常类型也是如此)等。

    逆变,简言之,就是父类型到子类型,变得越来越具体,但是方法的形参却变得更加抽象或不变(注意:这里在Java中本质为方法重载,而不是覆盖,当添加@Override标签将会报错!)

  于是,针对上述的逆变,概念可以理解,但是Java中所谓的“方法形参变得更加宽泛”,实质是方法重载,似乎也就不能严格地称为继承关系下的“逆变”,思考之,似乎Java的继承派生过程中,几乎所有的操作,都是协变的,那么是不是就说明Java中并不存在逆变呢???

  错!Java中是存在逆变的!

  这里就引出了Java中的泛型,这里举个例子:

  在Java中,Number类是Integer类的父类(super),如果某个方法的签名是void method(List<Number> listNumber),那么按照协变的思想,是不是意味着为这个方法传入List<Integer>类型参数也是可以的呢?

  当然不... 很多博客在此都说“Java对于这样的泛型是不支持协变的”,但我认为,事实是List<Integer>的实参,依旧是一个List类型的持有对象,因此对于List<Number>这个持有对象来说,二者持有的对象存在继承派生的关系,但二者本身并不存在继承派生的关系,因而也就无从谈及协变(实质,这里二者的关系是“不变”,“不变”是针对于协变与逆变概念而言的)。

  举个例子来说明这个简单的问题,一个父亲和他的儿子都分别有一辆车,他们的车款型相同(当然,也可能不同,但总归是车,即持有对象,这里为了针对上述二者持有对象均为List故意言之),尽管车上的父亲和儿子存在着“继承派生”的关系,但是这两辆车并不存在继承关系,所以二者之间并没有“协变”的概念。

  那么,总不能对于这样的method,要为每种持有对象持有的对象类型分别重载实现method吧...于是,Java就提供了泛型的通配符(注意,这里才谈到泛型),为了解决上述的method问题,可以这样声明method: void method(List<? extends Number> listNumber)。这样,这里的形参就必须是一个持有对象,它持有的对象类型,必须是Number类或者是继承自Number类的更具体的子类(如Integer类,Double类),此时,可以说这个方法依旧实现了“协变”,那么Java中的逆变是体现在哪儿的呢?

  这里就引出了通配符后另一个关键字,super。

  这样声明的方法:void method(List<? super Integer> listInteger),说明该持有对象持有的对象类型,必须是Integer或Integer的父类(超类super),于是,此时向方法中传递持有Number类的持有对象也是可以的,甚至,可以传递一个持有Object类型的持有对象。此处便是使得参数类型变得更加宽泛,因此此处体现的是“逆变”。

  这也很好记:

    ? extends 对应 协变

    ? super    对应 逆变

    (? 即为Java泛型的通配符)

  综上,Java是符合LSP的一门语言,对“协变”“逆变”的支持也是有具体实现以及道理的。理解好这些概念,可以让编程中遇到的知识概念更加系统化,理解记忆也更高效。

  至于前几天,有同学在群里讨论,Java中如果子类覆盖了父类的方法,是否就不符合LSP了,如果说Java是严格按照LSP来设计的,那么这种情况是否就不能称为覆盖,而是重载...

  当时被这个问题雷到了... 我理解的LSP应该是一种思想,是设计过程以及实现过程中开发者应该牢记并遵守的。如果按照LSP的总的规则,那么每个父类对象出现的地方,都可以用其具体子类对象来替换而不会发生错误。这个错误当然是保证语法编译不会发生错误,而不是针对覆盖方法导致的功能不同。所以...这个问题,实质上应该归结于理解发生了偏颇...

  本博客参考博客:

  Java协变和逆变:https://blog.csdn.net/qiuchengjia/article/details/52910901

  Java的逆变与协变:https://www.cnblogs.com/en-heng/p/5041124.html

  from Steven Shen

    编辑于2018.6.22

    修改于2019.9.4