前言
在上一篇文章中,我们学会了继承,简单来说就是对共性的抽取以实现代码的复用,提高代码的效率。而今天我们就要学习一个与继承息息相关的知识点—>多态,从字面意思来理解,多态是拥有多种形态。具体来说是当我们完成一个行为时,不同的对象去完成这个行为会发生不同的状态
多态是什么?
例如:我们在生活中经常用到的打印机,打印机的主要功能其实是为了打印纸张,我们不去纠结纸张的大小,打印出来的文件有黑白,有彩色,这就是多态,同样是打印,但是打印的颜色不一样。再比如动物中的猫咪和狗狗都是需要吃饭的,但是猫咪是吃猫粮,狗狗是吃狗粮。总的来说就是:同一件事,发生在不同的对象身上,产生的效果就是不一样的。
如何实现多态
我们大概已经知道了什么是多态,但是应该如何实现多态呢?🤔换句话说,实现多态应该具备哪些条件呢?我们一个一个来解决。
实现多态必须满足以下几个条件:
1.🚝继承
2.🚄重写(包含动态绑定)
3.🚅子类必须要对父类中方法进行重写
4.🚈通过父类的引用调用重写的方法
继承很好理解,子类通过关键字extends继承父类,形成并在继承体系下完成,那这个向上转型该怎么理解呢?
向上转型
向上转型:实际就是创建一个子类对象,将其当成父类对象来使用。
语法格式:父类类型 对象名 = new 子类类型()
我们通过代码来看一看
/**
* @author Jason
* @date 2023/1/2 16:01
*/
class Animal{
public String name;
public int age;
public void eat(){
System.out.println(name+"吃饭");
}
}
class Dog extends Animal{
public void bark1(){
System.out.println(name+"汪汪叫");
}
}
class Cat extends Animal{
public void bark2(){
System.out.println(name+"喵喵叫");
}
}
public class Test {
public static void main(String[] args) {
Animal animal1=new Dog();//向上转型
animal1.name="坦克";
animal1.eat();
System.out.println("华丽的分割线==================");
Animal animal2=new Cat();//向上转型
animal2.name="咪咪";
animal2.eat();
}
}
向上转型一共有三种方法,第一种就是我们刚刚提到:📓直接赋值,还有方法传参以及方法返回
📔方法传参向上转型
public static void func(Animal animal){
System.out.println("方法传参向上传参");
}
public static void main(String[] args) {
Dog dog=new Dog();
func(dog);
}
📗返回值向上传参
public static Animal func2(){
System.out.println("返回值传参");
return new Dog();
}
以上就是向上转型的三种方法
那是否可以通过父类去调用子类的成员呢?我们来看看。⬇️
🎥提示:当我通过父类的引用去调用子类特有的成员方法时出现了报错,简单来说,我的父类没有bark1和bark2方法,是不能调用的(这也是向上转型的一大缺点),但好处就是能够使代码更加灵活起来。只能调用父类自己的方法,不能去调用子类特有的方法。
向上转型的优点😃:能让代码变得更加灵活
向上转型的缺点😭:不能调用子类特有的方法
代码中我们创建了一个父类为Animal,两个子类Dog和Cat继承于父类Animal。注意看注释后的两行代码,左侧为父类类型,右侧为子类对象,所以理论上来说,等号两边的数据必须一致,否则就会报错。animal虽然是父类类型但是可以引用一个子类对象,因为范围是从小范围向大范围的转换。
猫咪和狗狗都是动物,所以将子类对象转化为父类引用是完全可以的,大的范围是囊括小范围的,所以我们向上转型是安全的。
向下转型(不推荐)
我们通过向上转型将子类对象当成了父类方法使用,这个时候父类是无法再去调用子类方法的,由于某些业务需要,有的时候需要调用子类特有的方法,这个时候就可以用向下转型。
为什么不推荐使用向下转型呢?⬇️
public static void main(String[] args) {
Cat cat=new Cat();
Dog dog =new Dog();
Animal animal1=cat;//向上转型,安全
Animal animal2=dog;//向上转型,安全
cat=(Cat)animal1;//向下转型,安全,因为猫向上转型为动物,向下转型强制转换为猫
cat=(Cat)animal2;//向下转型,错误,因为我的animal2已经是狗类,猫是无法转换为狗
}
编译时期虽然没有报错,但是运行期间就会产生错误,无法将一个狗类转换为猫类,cat=(Cat)animal1使用了强制类型转换,等号右边的权限范围是大于等号左边的权限范围,所以使用强制类型转换并没有发生报错。
换句话来说:🐱猫和🐶狗都是动物,但是动物不一定就会是猫和狗,可以是牛羊马,可以是任何纲目🐛🐆🐟。
针对向下转型的不安全性,Java引入了instanceof,如果该表达式结果为true,则表示可以安全转换。
可以看到结果编译只打印了猫咪的eat()方法,在判断if条件语句是否为true时并没有进入。
使用instanceof可以有效避免向下转型带来的不安全性。
重写
什么是重写?重写(override)又称之为覆盖。重写是子类对父类非静态、非priavte、非final修饰,非构造方法等的实现过程进行编写。重写的优势在于子类可以根据自己的需求来定,也就是说可以根据需要实现父类的方法。
重写的设计原则:对于已经投入的类,尽量不要去修改。最好的方法是重新定义一个类,来重复利用其中相同之处,并且加入或改动新的内容
方法的重写是有规则的:
1.与父类方法名称相同
2.与父类参数列表相同
3.与父类返回值相同
先给大家显示一下重写的快捷键⬇️
大家看第三张图,因为父类Animal只有eat()方法,只需要点击eat()方法即可,如果你的父类在定义时是有不同的方法,都需要重写的话,按住ctrl键选择即可。
@Override为重写的注解,也就是代表当前这个方法是被重写的,重写的快捷键按住之后会出现super.eat(),删除后改成右边你所需要的方法类型即可。
class Animal{
public String name;
public int age;
public void eat() {
System.out.println(name + "吃饭");
}
}
class Dog extends Animal{
public void bark1(){
System.out.println(name+"汪汪叫");
}
@Override
public void eat() {
System.out.println(name+"吃骨头");
}
}
class Cat extends Animal {
public void bark2() {
System.out.println(name + "喵喵叫");
}
@Override
public void eat() {
System.out.println(name+"吃小鱼干");
}
}
public class Test {
public static void main(String[] args) {
Animal animal1=new Dog();
animal1.name="坦克";
animal1.eat();
System.out.println("华丽的分割线==================");
Animal animal2=new Cat();
animal2.name="咪咪";
animal2.eat();
}
}
动态绑定
按照正常来说,我已经实现了向上转型,理应调用的是父类的eat()方法。⬇️
但是实际情况确实这样子的⬇️
变成了调用子类的eat()方法了,为什么会这样呢,这是因为在这个过程中发生了🔗动态绑定。
💡所谓的动态绑定就是在运行的时候,在java看来在编译的时候调用的是Animal的eat()方法,但是实际在运行期间绑定到了子类的eat()方法上。
通过下面查看编译完成的字节码文件,可以得知。编译的时候,确实是调用了父类的eat(),但是在运行的时候,帮我们调用了子类重写的方法。
🪫怎么才能实现动态绑定?
1.向上转型
2.重写
3.通过父类引用,调用这个父类和子类重写的方法
以上三点在代码中都已经体现到了,动态绑定是我们发生多态的基础。
静态绑定
既然有动态绑定是不是也有静态绑定?是的,重载就是静态绑定,在编译的时候已经确定调用哪个方法。
//静态绑定,在编译的时候根据参数就去调用已知方法
public static int add(int a,int b){
return a+b;
}
public static int add(int a,int b,int c){
return a+b+c;
}
public static void main(String[] args) {
System.out.println(add(1,2));
System.out.println(add(1,2,3));
}
说了这么多那动态到底是什么?🤔我们用方法传参的向上转型来解释⬇️
public static void function(Animal animal){
animal.eat();
}
public void func(){
System.out.println("上半部分是类实现者");
System.out.println("下半部分是类的调用者");
}
public static void main(String[] args) {
Animal animal1=new Dog();
animal1.name="坦克";
function(animal1);
System.out.println("华丽的分割线==================");
Animal animal2=new Cat();
animal2.name="咪咪";
function(animal2);
}
🔋对于此时的Animal来说,能知道他将来引用哪个对象吗?答案是:不知道。
但是我们能确定的是:Animal引用的对象不一样,调用eat()方法表现的行为就不一样, Animal引用Dog就是吃骨头,引用Cat就是吃小鱼干。这就是多态,当父类引用的对象不一样的时候,表现出的行为是不一样的。
🗣向上转型+动态绑定+重写+通过父类调用父类和子类重写的方法就形成了多态,而多态就是一种思想。
重写(补充)
💡补充关于重写的一些规则
1.子类重写父类的方法,一定要和父类的方法原型一致,方法名,参数列表,返回值都必须一致。
2. 被重写的方法返回值类型可以不同,但是必须有继承关系。
3. static修饰的方法不能被重写,因为此时是类方法,static修饰的是不依赖于对象的,我们在类和对象中已经提到过。
4. 被private修饰的方法也是不能被重写的,因为它的范围只能在本类中使用,被死死限制住了。
5. 子类的访问修饰限定权限要大于等于父类,比如父类的方法被public修饰,此时我的子类方法就不能被proteced修饰,因为protected的权限是低于父类的. private<默认<protected<public。
6.被final修饰的方法也是不能被重写的,因为此时这个方法被称为密封方法。
重点要抓住重写和重载的区别,大家不要去背,要学会理解(虽然我自己也记不住)
方法重载只是对一个类多态性的表现,而方法重写则是子类和父类的一种多态性表现
📟📲
例如我们的手机,一开始的手机只有打电话发短信的功能,拨通时可以显示来电号码。但是我们现在的手机功能越来越丰富,再拨通号码之后,可以显示来电号码所在的区域,来电者的姓名,甚至可以提示你来电是否是骚扰电话。我们在设计新功能时,不可能直接将旧的功能除去,因为这些功能老用户还需要继续使用,否则就不能称之为电话。是在现有的基础上不断优化,让电话变得更加多样化,有效避免了一些差错,例如诈骗电话, 在来电显示这一点上也充分体现了功能的多态化。
多态优缺点
多态的优点
我们通过if条件语句来判断打印所有的科目,这样不仅效率低下而且多费手续,设计起来也很不方便。
import org.w3c.dom.ls.LSOutput;
/**
* @author Jason
* @date 2023/1/3 22:24
*/
class Course{
public void course() {
System.out.println("科目");
}
}
class Literature extends Course{
@Override
public void course() {
System.out.println("文学");
}
}
class Math extends Course{
@Override
public void course() {
System.out.println("数学");
}
}
class Geography extends Course{
@Override
public void course() {
System.out.println("地理");
}
}
public class Test4 {
public static void drawShapes(){
Literature literature=new Literature();
Math math=new Math();
Geography geography=new Geography();
String[] shapes = {"literature","math","geography",};
for (String shape : shapes) {
if (shape.equals("literature")) {
literature.course();
} else if (shape.equals("math")) {
math.course();
} else if (shape.equals("geography")) {
geography.course();
}
}
}
public static void main(String[] args) {
drawShapes();
}
}
如果我们直接使用多态的原理去实现,就方便很多,这样就避免了使用if-else语句,使得我们的代码变得越来简单高效。
public static void drawShapes(){
//创建数组的方式,将所创建的对象都放在一个数组里面
Course[] shapes = {new Literature(),new Math(),new Geography()};
for(Course shape:shapes){
shape.course();
}
}
public static void main(String[] args) {
drawShapes();
}
}
使用多态的好处:
1.能够降低代码的"圈复杂度",避免使用大量的if-else
2.可扩展能力较强,对于刚才的实现方法来说,我们只需要关注drawShapes方法,只需要创建一个新的实例即可,不需要去改动if-else,这样一来我们的时间和效率成本就降低了很多。
圈复杂度:什么叫 “圈复杂度” ?
圈复杂度是一种描述一段代码复杂程度的方式. 一段代码如果平铺直叙, 那么就比较简单容易理解. 而如果有很多的条件分支或者循环语句, 就认为理解起来更复杂.因此我们可以简单粗暴的计算一段代码中条件语句和循环语句出现的个数, 这个个数就称为 “圈复杂度”。如果一个方法的圈复杂度太高, 就需要考虑重构, 不同公司对于代码的圈复杂度的规范不一样. 一般不会超过 10 。
多态的缺点
1.属性没有多态性,当父类和子类都有同名属性的时候,通过父类的引用只能调用父类自己的成员属性,而不能调用子类特有的属性
2.构造方法没有多态性⬇️
class B{
public B(){
func();
}
public void func(){
System.out.println("A.func()");
}
}
class D extends B{
private int num=1;
public D()
super();//默认会有一个不带参数的构造方法,会去调用父类的构造方法
}
public void func() {
System.out.println("B.func()");
}
}
public class Main {
public static void main(String[] args) {
D d=new D();
}
}
按照正常的流程来说,当我实例完D对象之后,会调用D的不带参数的构造方法,会默认提供一个不带参数的构造方法,super()会去调用父类不带参数的构造方法。那么问题来了,此时调用的func()是父类的方法还是子类的方法?我们来看下结果。⬇️
输出的是子类的func()方法,那我们num的值应该是多少?等于1吗?
答:不是,等于0
class B{
public B(){
func();
}
public void func(){
System.out.println("A.func()");
}
}
class D extends B{
private int num=1;
public D(){//默认会有一个不带参数的构造方法
super();//会去调用父类的构造方法
}
public void func() {
System.out.println("B.func()");
System.out.println(num+"因为此时父类到现在还没有走完");
}
}
public class Main {
public static void main(String[] args) {
D d=new D();//这里应该调用子类不带参数的构造方法
}
}
📡我们在上一节继承中说过,方法的执行顺序应该是先执行父类的实例方法和构造方法再执行子类的实例方法和构造方法,在刚才func()方法运行期间,输出的是子类的func()方法,没有经过父类,子类并没有经过初始化,所以输出的是系统默认的0。
🎛
通过调试发现,并没有经过父类的构造方法,直接打印了子类的func(),num的值也是0。
- 💛构造 D 对象的同时, 会调用 B 的构造方法.
- 💚B 的构造方法中调用了 func 方法, 此时会触发动态绑定, 会调用到 D 中的 func
- 💜此时 D 对象自身还没有构造, 此时 num 处在未初始化的状态, 值为 0
- 🖤如果具备多态性,num的值应该是1. 所以在构造函数内,尽量避免使用实例方法,除了final和private方法。
结论:“用尽量简单的方式使对象进入可工作状态”,尽量不要在构造器中调用方法(如果这个方法被子类重写,就会触发动态绑定,但是此时子类对象还没有完全构造完成),会出现一些隐藏的但是又极难发现的问题