【从LSP到协逆变到List】

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

提示:这里可以添加本文要记录的大概内容:

谈到LSP原则,我们首先想到的是方法重写时的那些规则,我们总是要求他们满足里氏替换原则。但是其实LSP并不只是针对继承重写而言的。在认识LSP时,我们应该脱离“方法重写”这个概念去谈,而在深化对LSP认识时,我们又应该结合方法重写去思考


提示:以下是本篇文章正文内容,下面案例可供参考

基本思想

其实,LSP原则可以用一句话概括:子类可以去替换基类(父类)。子类继承了父类的属性和方法,属于父类的扩展,也就是说,父类能做到的,子类也能做到。对于外界来说,将子类看成是基类应该是被允许的。基于这个思想,我们往往把子类包装成基类,其中最经典的莫过于List接口以及其实现类ArrayList的使用场景:

List<Integer> list=new ArrayList<>();

在这个场景中,我们其实创建了一个ArrayList的对象,但是静态检查时编译器将其解释为List,实现了将子类“包装”成了基类。
这个情境再扩展一下,假设我有生命Life(基类)、动物Animal(Life的子类)、哺乳动物Mammal(Animal子类)、猫Cat(Mammal的子类),然后可以有下面一个层层包装:

Mammal mamal=new Cat();
Animal animal=mammal;
Life life=animal;

通过这种层层包装的方式,实现了将一个本该是“猫”Cat的对象解释成了“生命”Life(当然了也可以一次性直接将Cat包装成Life)。这个过程就很像俄罗斯套娃。
在这里插入图片描述
所以说,包装就是小盒子放进大盒子里边。我们用大盒子把小盒子包装起来,在外界看来他们是一样的。

定义大小关系

既然类之间有子孙和先辈之分,我们不妨将这种由于直接或者间接的继承形成的关系用一种偏序关系去表示。如果A类是B类的祖先,则说A>B,否则说A<B;而不具有继承关系的两个类之间则不能用偏序关系去比较,例如猫Cat和狗Dog。

协变和逆变

协变:从父类型到子类型
逆变:从子类型到父类型
用偏序关系来说明,协变就是由大变小(A*<A),逆变就是由小变大(A*>A)。其中A指原来的类型,A*指变化后的类型
我们针对大容器装小容器这个动作,在每一次装的过程中,可以将大容器看成盒子(可容纳他物的容器),而小容器看成(被容纳的对象),以方便理解。实际上,我们用一个盒子去装一个球的时候,可以让盒子变大,即盒子发生“逆变”,或者让球变小,即球发生“协变”。这样的变化能使得盒子依然能够装得下球(合法性)。

方法的覆盖

LSP对于方法重写有几条重要规定,其中最典型的莫过于关于返回值类型和参数类型的规则:
1、子类返回值类型对于父类而言要么不变要么协变
2、子类参数类型对于父类而言要么不变要么逆变
其实不难理解。我们可以借用刚才的盒子装球的例子。假设我有下面方法:

public typeA function(typeB b){....}

先看返回值类型。在实际应用中,方法的返回值一般别有他用,调用者常使用一个变量来记录一个方法的返回值,这个变量就是一个容器(假设类型是typeC),根据我们的包装原则(大装小),他要保证能够装得下typeA,因此有typeC >= typeA(C就是A或者是A的祖先)。
而当我们重写方法时,我们想要改变返回值的类型,那么怎么改才是合适的呢?由于外界不能察觉我们偷偷换了typeA,因此返回值类型改变之后,外界依然能用原来的盒子(typeC)去装下新的返回值类型,在这个过程中,记录返回值的外界变量的角色是“盒子”,而返回值充当了“球”的角色。我们要做的就是在盒子不变的情况下,怎样合理地变化球的大小。讲到这,答案其实就已经出来了。我们保证盒子能装下被换下之前的球,那么我们的新球想要保证必定能够被该盒子装下,那么新球就要小于或者等于原来球的大小——即typeA* <= typeA,这就是我们说的返回值类型协变或者不变。
再来看参数类型,对于参数,是外界给我们一个“球”,而我们用一个“盒子”去容纳。也就是说外界的对象是“球”(typeD),参数类型是“盒子”(typeB),有typeB>=typeD。如果我们想要改变盒子的尺寸,我们需要保证新盒子依然能够装的下外界的球,我们能做的就是不改变盒子的大小或者换成一个更大的盒子,即typeB*>=typeB,这就是我们说的参数类型协变或者不变。
值得注意的是,java中并不承认改写方法参数类型的行为看作重写override,而是把这种行为看作重载overload。即java中方法的重写,必须要保持参数列表的一致性

数组协变性

数组是具有协变性质的数组结构。如果Animal是Cat的父类型,那么数组Animal[ ]也看作是数组Cat[ ]的父类型。利用这种属性,我们可以对数组进行“大小盒子”的包装。

Mammal mammals=new Mammal[10];
Animal[] animals=mammals;
Life[] lives=animals;

上述代码在java中是能通过编译且运行的,也可以添加元素:

mammals[0]=new Mammal();
mammals[1]=new Cat();

第一行添加了元素Mammal对象,由于这个数组低层其实是Mammal类型的,由“大装小”原则可以知道,我们也能够向数组中添加Mammal的子类型Cat作为数组元素(协变性体现)
但下面代码是不正确的:

animals[2]=new Animal();//编译yes,运行no

因为animals经过静态检查被编译器解释为Animal类型的数组,所以往数组里添加Animal对象的时候编译器是不会报错的,而当运行时,由于这个数组本质其实是Mammal类型的,而小盒子不能装下大盒子,子类型不能替换成父类型,所以运行时会报错。

List类型

List类型本身是不具有协变性的。假如A是B的子类型,然而List是不能看作List的子类型的,这一点和数组有很大区别。所以会有下面这种状况:

List<Cat> cats=new ArrayList<>();
List<Animal> animals=cats;//编译报错

那么有什么办法能够使得列表也具有协变的性质呢?这就要说到通配符?了。
java提供了无界通配符,使得List之间也可以具有“父子”关系,List<?>可以看作任何List的父类,即:

List<String> lst1=new ArrayList<>();
List<?> lst2=lst1;
List<Integer> lst3=new ArrayList<>();
List<?> lst4=lst3;

上面代码都是能运行的,也就是说List<?>可以替换成任意一个List。那当我们只想用它替换成某一个列表元素类的子类(实现协变)或者是某一个列表元素类的父类(实现逆变)该怎么办呢?
这就要提到上界通配符和下界通配符。

上界通配符<? extends A>

List<? extends A>表示List<B> 其中B<=A,即列表中的元素可以是A的不变或者协变,A在这里的作用相当于上界,所以称为上界通配符。

List<Double> lst0=new ArrayList<>();
List<? extends Number> lst1=lst0;//一切正常

下界通配符<? super A>

List<? extends A>表示List<B> 其中B>=A,即列表中的元素可以是A的不变或者逆变,A在这里的作用相当于下界。

List<Object> lst0=new ArrayList<>();
List<? super Number> lst1=lst0;//一切正常

add和get操作

上界通配符:

List<Double> lst0=new ArrayList<>();
List<? extends Number> lst1=lst0;//一切正常
Double d1=2.0;
Number n1=...;
Object o1=...;
lst1.add(d1);//编译报错
lst1.add(n1);//编译报错
lst1.add(o1);//编译报错
lst1.add(null);//yes
Double d2=lst1.get();//编译报错
Number n2=lst1.get();//yes
Object o2=lst1.get();//yes

为什么会出现这一情况呢?对于add,实际上通过List<? extends Number>,我们将其看作是List<B>其中B<=Number,而我们add实际上要保证我们的对象能够被B类型的盒子装得下,也就是说,我们的add元素类型C<=B<=A(A代指Number),由于B是不确定的(只说明了B<=A),所以我们无法判断C是否<=B,因此编译的时候会报错。除非C=null,则盒子B必定能够装的下(装空气)。
对于get,我们实际上是用一个盒子去装get方法的返回值(球),盒子里边装的是B类型的元素,虽然不确定,但是有B<=A,所以只要我们的盒子D比A大,则必有D>=A>=B,也就是说,当我们的容器是A或者是A的父类时,get是可行的。
下界通配符:

List<Objeect> lst0=new ArrayList<>();
List<? super Number> lst1=lst0;//一切正常
Double d1=2.0;
Number n1=...;
Object o1=...;
lst1.add(d1);//yes
lst1.add(n1);//yes
lst1.add(o1);//编译报错
lst1.add(null);//yes
Double d2=lst1.get();//编译报错
Number n2=lst1.get();//编译报错
Object o2=lst1.get();//编译报错

类似上面,对于add,由于B>=A,只要我们保证“球”C <=A,即使B不确定,也一样有B>=C,即盒子B能装下球C,这要求add的元素类型是A或者A的子类型。对于get,由于B>=A,但是B是不确定的,所以除了站在最顶层的Object类,我们难以找到一个其他我们能声称其必定>=B的类,所以除了Object以外任何get方法都是不可行的。
总结:
对于<? extends A>,add方法除了null以外不能使用,get方法要保证容器类型D>=A;对于<? super A>,get方法除了容器是object以外不能使用,add方法要保证元素类型C<=A
这种大小关系在数轴上表示如下:
在这里插入图片描述

  • 48
    点赞
  • 49
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值