Java面向对象编程三大特征 - 多态

写在前面:博主是一只经过实战开发历练后投身培训事业的“小山猪”,昵称取自动画片《狮子王》中的“彭彭”,总是以乐观、积极的心态对待周边的事物。本人的技术路线从Java全栈工程师一路奔向大数据开发、数据挖掘领域,如今终有小成,愿将昔日所获与大家交流一二,希望对学习路上的你有所助益。同时,博主也想通过此次尝试打造一个完善的技术图书馆,任何与文章技术点有关的异常、错误、注意事项均会在末尾列出,欢迎大家通过各种方式提供素材。

  • 对于文章中出现的任何错误请大家批评指出,一定及时修改。
  • 有任何想要讨论和学习的问题可联系我:zhuyc@vip.163.com。
  • 发布文章的风格因专栏而异,均自成体系,不足之处请大家指正。

Java面向对象编程三大特征 - 多态

本文关键字:Java、面向对象、三大特征、多态


多态是面向对象编程的三大特征之一,是面向对象思想的终极体现之一。在理解多态之前需要先掌握继承、重写、父类引用指向子类对象的相关概念,对继承还没有完全明白的同学可进传送门: Java面向对象编程三大特征 - 继承

一、抽象类

在继承中,我们已经了解了子父类的关系以及如何对子父类进行设计,如果已经存在多个实体类,再去定义父类其实是不断的抽取公共重合部分的过程,如果有需要将会产生多重继承关系。在抽取整理的过程中,除了属性可以复用,有很多方法一样也可以复用,假如以图形举例:矩形、圆形,都可以具有周长和面积两个方法,但是计算的方式完全不同,矩形和圆形之间肯定不能构成子父类关系,那么只能是同时去继承一个父类,那么问题就来了,这两个类都有什么共同点?

除了都是图形好像并没有什么共同点,矩形有两组边长,圆形是通过半径来描述,如果非要往一起联系的话。。。Wait a moment(灵光一闪中,请勿打扰)!!!难道说是都可以计算出周长和面积?细细想来,也是能说出一番道理的,但是这好抽象啊!
如果真的是这样,也只能有一个模糊的思路,既然描述图形的属性不能够共用那就分别放在两个子类中吧,那么计算周长和面积的方法要怎么搞?如果在父类中定义相应的方法,那参数列表怎么写?方法体怎么填?这个坑好像有点大,接下来,我们就要华丽地将这个坑填平。

1. 抽象与抽象类

在上面的例子中,我们遇到了一个情况,有两个在逻辑上看似相关的类,我们想要把他们联系起来,因为这样做可以提高效率,但是在实施的过程中发现这个共同点有点太过模糊,难以用代码描述,甚至于还不如分开用来的方便,这时就要引出抽象的概念,对应的关键词为:abstract。

  • abstract可以修饰方法,修饰后被称为抽象方法
  • abstract可以修饰类,修饰后被称为抽象类
  • abstract不能与static修饰符同时使用
  • abstract不能与final修饰符同时使用

那么使用了abstract又能如何呢?这代表指定的方法和类很难表述,那么。。。就不用表述了!对于矩形类(Rectangle)与圆形类(Circle)的父类:图形类(Figure),我们只能总结出他具有计算周长和面积的方法,而具体的实现方法我们无法给出,只有明确了图形之后,才能给出具体的实现,于是我们使用抽象来描述这两个方法,被abstract修饰的方法不需要有方法体,且不能为private,由于抽象方法没有方法体,那么如果被代码调用到了怎么办呢?以下两个限制规则可以杜绝这个问题:

  • 抽象方法只能存在于抽象类中(接口在另外的文章中讨论)
  • 抽象类无法被直接实例化(匿名内部类的用法暂不做讨论)

既然抽象类不能被实例化,那么自然也就不会调用到没有方法体的那些方法了,那这些方法该怎么被调用呢?我们需要一步一步的来梳理,至少目前我们已经能够清晰的得到如下的关系图了:

2. 抽象类的特点

抽象类的本质依然是一个类(class),所以具备着一个普通类的所有功能,包括构造方法等的定义,总结一下,抽象类具有以下的几个特点:

  • 抽象类由abstract修饰
  • 抽象类中允许出现抽象方法
  • 抽象类不能通过构造器直接实例化
  • 可以在抽象类中定义普通方法供子类继承

现在,我们已经可以将抽象父类用代码描述出来:

// 定义抽象类:图形类
public abstract class Figure{
    // 定义计算周长的抽象方法:getC()
    public abstract double getC();
    // 定义计算面积的抽象方法:getS()
    public abstract double getS();
    // 定义描述图形的非抽象方法:print()
    public void print(){
        System.out.println("这是一个图形");
    }
}

3. 天生的父类:抽象类

现在我们已经有了一个抽象类,其中也定义了抽象方法,抽象类不能被直接实例化保证了抽象方法不会被直接调用到。回忆一下我们的出发点,费劲巴力的弄出个抽象类就是为了提取出两个类比较抽象的共同点,那么下一步自然是继承了。

  • 抽象类不能直接实例化,是天生的抽象类
  • 如果一个类继承了抽象类,那么必须重写父类中的抽象方法
  • 如果抽象类中定义了构造方法,可以被子类调用或在实例化子类对象时执行
  • 如果抽象类的子类依然是抽象类,可以不重写抽象方法,将重写操作留给下一级子类

二、重写

重写指的是子父类之间方法构成的关系,当子类继承父类时,父类中可能已经存在了某些方法,那么子类实例就可以直接进行调用。在有些时候由于子父类之间的差异,对于已经存在的方法想要做一些修改,这个时候我们可以利用重写,在子类中定义一个与父类中的方法完全相同的方法,包括返回值类型和方法签名(方法名 + 参数列表),此时就会构成重写。这样,子类实例在调用方法时就可以覆盖父类中的方法,具体的过程在后半部分阐述。

1. 重写与重载的区别

我们在刚开始接触方法的时候了解到了一个概念:重载,与重写有些类似,容易混淆,如果知识点已经模糊可以进传送门:Java程序的方法设计。总结一下,重写和重载有以下区别:

  • 重载是同一个类中方法与方法之间的关系
  • 重写是子父类间(接口与实现类间)方法与方法之间的关系
  • 构成重载:方法名相同,参数列表不同,返回值类型可以不同
  • 构成重写:方法名相同,参数列表相同,返回值类型相同或为对应类型的子类
  • 构成重载的方法之间权限修饰符可以不同
  • 重写方法的权限修饰符一定要大于被重写方法的权限修饰符

有关于权限修饰符的作用如果不明确可以进传送门:Java面向对象编程三大特征 - 封装。明确了重写的含义之后,我们终于可以再度提笔,完成我们之前的例子:

// 定义矩形类
public class Rectangle extends Figure{
    // 定义构造器
    public Rectangle(double height, double width) {
		this.height = height;
		this.width = width;
	}
    // 定义长和宽
    public double height;
    public double width;

    // 重写计算周长方法
    @Override
	public double getC() {
		return 2 * (this.height + this.width);
	}

    // 重写计算面积方法
	@Override
	public double getS() {
		return this.height + this.width;
	}

    // 可选覆盖
    @Override
	public void print(){
        System.out.println("矩形");
    }
}
// 定义圆形类
public class Circle extends Figure{
    // 定义构造器
    public Circle(double radius) {
		this.radius = radius;
	}

    // 定义半径
	public double radius;
	
    // 重写计算周长方法
	@Override
	public double getC() {
		return 2 * Math.PI * this.radius;
	}

    // 重写计算面积方法
	@Override
	public double getS() {
		return Math.PI * Math.pow(this.radius, 2);
	}

    // 可选覆盖
    @Override
	public void print(){
        System.out.println("圆形");
    }
}

2. 方法重写的规则

  • 重写的标识为@Override
  • 方法的重写发生在子类或者接口的实现类中
  • 被final声明的方法不能被重写
  • 被static声明的方法不能被重写,只能声明同结构的静态方法,但是此时不构成重写
  • 受限于权限修饰符,子类可能只能重写部分父类中的方法

3. 父类方法的显式调用

从上面的代码中可以看到,子类继承父类后,如果存在抽象方法则比如重写,由于父类中的方法是抽象的,所以无法调用。对于普通的方法,可以选择性的重写,一旦重写我们可以认为父类的方法被覆盖了,其实这样的形容是不准确的,在初学阶段可以认为是覆盖。
比较规范的说法是:通过子类实例无法直接调用到父类中的同名方法了,但是在内存中依然存在着父类方法的结构,只不过访问不到而已。另外,我们同样可以在子类中显式的调用出父类方法,这要用到super关键字。

  • super指代父类对象
  • super可以调用可访问的父类成员变量
  • super可以调用可访问的父类成员方法
  • super可以调用可访问的父类构造方法
  • 不能使用super调用父类中的抽象方法
  • 可以使用super调用父类中的静态方法

如果我们需要在子类中调用父类方法或构造器,可以将代码修改如下:

// 定义抽象类:图形类
public abstract class Figure{
    // 在抽象类中定义构造器,在子类实例创建时执行
    public Figure(){
        System.out.println("Figure init");
    }
    // 定义计算周长的抽象方法:getC()
    public abstract double getC();
    // 定义计算面积的抽象方法:getS()
    public abstract double getS();
    // 定义描述图形的非抽象方法:print()
    public void print(){
        System.out.println("这是一个图形");
    }
}
// 定义矩形类
public class Rectangle extends Figure{
    // 定义构造器
    public Rectangle(double height, double width) {
        super();// 会调用默认的无参构造,代码可省略
        this.height = height;
        this.width = width;
	}
    // 定义长和宽
    public double height;
    public double width;

    // 重写计算周长方法
    @Override
	public double getC() {
		return 2 * (this.height + this.width);
	}

    // 重写计算面积方法
	@Override
	public double getS() {
		return this.height + this.width;
	}

    // 可选覆盖
    @Override
	public void print(){
        super.print();// 调用父类方法
        System.out.println("矩形");
    }
}
// 定义圆形类
public class Circle extends Figure{
    // 定义构造器
    public Circle(double radius) {
        super();// 会调用默认的无参构造,代码可省略
        this.radius = radius;
	}

    // 定义半径
	public double radius;
	
    // 重写计算周长方法
	@Override
	public double getC() {
		return 2 * Math.PI * this.radius;
	}

    // 重写计算面积方法
	@Override
	public double getS() {
		return Math.PI * Math.pow(this.radius, 2);
	}

    // 可选覆盖
    @Override
	public void print(){
        super.print();// 调用父类方法
        System.out.println("圆形");
    }
}

三、父类引用指向子类对象

前面提到的概念消化完毕后,我们看一下子父类对象实例化的形式以及方法的执行效果。

1. 父类引用指向父类对象

如果父类是一个抽象类,则在等号右侧不能直接使用new加构造方法的方式实例化,如果一定要得到父类实例,就要使用匿名内部类的用法,这里不做讨论。
如果父类是一个普通类,那么我们在初始化时,等号左侧为父类型引用,等号右侧为父类型对象(实例),这个时候其实和我们去创建一个类的对象并没有什么分别,不需要想着他是某某类的父类,因为此时他不会和任何子类产生关系,只是一个默认继承了Object类的普通类,正常使用就好,能调用出的内容也都是父类中已定义的。

2. 子类引用指向子类对象

在进行子类实例化时,由于在子类的定义中继承了父类,所以在创建子类对象时,会先一步创建父类对象。在进行调用时,根据权限修饰符,可以调用出子类及父类中可访问的属性和方法。

public class Test{
    public static void main(String[] args){
        Rectangle rectangle = new Rectangle(5,10);
        // 调用Rectangle中定义的方法,以子类重写为准
        rectangle.print();
        System.out.println(rectangle.getC());// 得到矩形周长
        System.out.println(rectangle.getS());// 得到矩形面积
        Circle circle = new Circle(5);
        // 调用Circle中定义的方法,以子类重写为准
        circle.print();
        System.out.println(circle.getC());// 得到圆形周长
        System.out.println(circle.getS());// 得到圆形面积
    }
}

3. 引用与对象之间的关系

在刚开始学习编程时,我们接触了基本数据类型,可以直接用关键字声明,定义变量赋值后使用,并不需要使用new关键字。对于引用与对象的关系可以先参考之前的文章回顾一下:Java中的基本操作单元 - 类和对象。在这里我们重点要说明的是:等号左侧的引用部分,与等号右侧的部分在程序运行层面有怎样的关联。
与基本数据类型不同,在类中可以定义各种属性和方法,使用时也需要先创建对象。等号左侧的部分依然是一个类型的声明,未赋值时虽然默认情况下是null,但在程序编译运行时,也会在栈中进行存储,记录了相应的结构信息,他所指向的对象必须是一个和它兼容的类型。
类的声明引用存放在栈中,实例化得到的对象存放在堆中

  • 在代码编写阶段,能够调用出的内容以等号左侧类型为准
  • 在程序运行阶段,具体的的执行效果以等号右侧实例为准

下图为引用与实例在内存中的关系示意图,有关于Java对象在内存中的分布将在另外的文章中说明:

4. 父类引用指向子类对象

了解了引用与对象的关系之后,就有了一个疑问,如果等号左侧的声明类型与等号右侧的实例类型不一致会怎么样呢?如果我们要保证程序能够通过编译,并且顺利执行,必须要保证等号两边的类型是兼容的。完全不相关的两个类是不能够出现在等号左右两边的,即使可以使用强制类型转换通过编译,在运行时依然会抛出异常。
于是我们就联想到了子父类是否有可能进行兼容呢?会有两种情况:子类引用指向父类对象,父类引用指向子类对象,下面我们来一一讨论。

  • 子类引用指向父类对象为什么无法使用

子类引用指向父类对象指的是:等号左侧为子类型的声明定义,等号右侧为父类型的实例。首先,结论是这种用法是不存在的,我们从两方面来分析原因。
第一个方面,是否符合逻辑?也就是是否会有某种需求,让Java语言为开发者提供这样一种用法?显然是否定的,我们定义子类的目的就是为了扩展父类的功能,结果现在我们却在用老旧的、功能贫乏的父类实例(等号右侧)去满足已经具备了强劲的、功能更为强大的子类声明(等号左侧)的需要,这显然是不合理的。
另一方面,在程序运行时是否能够办到?如果我们真的写出了相关的代码,会要求我们添加强制转换的语句,否则无法通过编译,即使通过,在运行时也会提示无法进行类型转换。这就相当于把一个只能打电话发短信的老人机强制转换为能安装各种APP的智能机,这显然是办不到的。

  • 父类引用指向子类对象有什么样的意义

父类引用指向子类对象指的是:等号左侧为父类型的定义,等号右侧为子类型的实例。这种情况是会被经常使用的,类似的还有:接口指向实现类。那么,这种用法应该如何解释,又为什么要有这样的用法呢?
首先,我们先来理解一下这代表什么含义,假如:父类为图形,子类为矩形和圆形。这就好比我声明了一个图形对象,这个时候我们知道,可以调用出图形类中定义的方法,由于图形类是一个抽象类,是不能直接实例化的,我们只能用他的两个子类试试看。

public class Test{
    public static void main(String[] args){
        // figure1指向Rectangle实例
        Figure figure1 = new Rectangle(5,10);
        System.out.println(figure1.getC());// 得到矩形周长
        System.out.println(figure1.getS());// 得到矩形面积
        // figure2指向Circle实例
        Figure figure2 = new Circle(5);
        System.out.println(figure2.getC());// 得到圆形周长
        System.out.println(figure2.getS());// 得到圆形面积
    }
}

从上面的结果来看,这好像和子类引用指向子类对象的执行效果没什么区别呀?但是需要注意此时使用的是父类的引用,区别就在于,如果我们在子类中定义了独有的内容,是调用不到的。在上面已经解释了运行效果以等号右侧的实例为准,所以结果与直接创建的子类实例相同并不难理解。
重点要说明一下其中的含义:使用Figure(图形)声明,代表我现在只知道是一个图形,知道能执行哪些方法,如果再告知是一个矩形,那就能算出这个矩形的周长和面积;如果是一个圆形,那就能算出这个圆形的周长和面积。我们也可以这样去描述:这个图形是一个矩形或这个图形是一个圆形。
如果从程序运行的角度去解释,我们已经知道,子类对象在实例化时会先实例化父类对象,并且,如果子类重写了父类的方法,父类的方法将会隐藏。如果我们用一个父类引用去指向一个子类对象,这就相当于对象实例很强大,但是我们只能启用部分的功能,但是有一个好处就是相同的指令,不同的子类对象都能够执行,并且会存在差异。这就相当于一部老人机,只具备打电话和发短信的功能,小米手机和魅族手机都属于升级扩展后的智能机,当然保有手机最基本的通讯功能,这样使用是没问题的。

四、多态

学习了上面的内容后,其实你已经掌握了多态的用法,现在我们来明确总结一下。

1. 什么是多态

多态指的是同一个父类,或同一个接口,发出了一个相同的指令(调用了同一个方法),由于具体执行的实例(子类对象或实现类对象)不同,而有不同的表现形态(执行效果)。
就像上面例子中的图形一样,自身是一个抽象类,其中存在一些抽象方法,具体的执行可以由子类对象来完成。对于抽象类的抽象方法,由于子类必须进行重写,所以由子类去执行父类的抽象方法必然是多态的体现,对于其他的情况则未必构成多态,因此总结了以下三个必要条件。

2. 多态的必要条件

  • 存在子父类继承关系
  • 子类重写父类的方法
  • 父类引用指向子类对象

只有满足了这三个条件才能构成多态,这也就是文章前三点用这么长的篇幅来铺垫的原因。

3. 多态的优点

使用多态有多种好处,特别是一个抽象类有多个子类,或一个接口存在多个抽象类时,在进行参数传递时就会非常的灵活,在方法中只需要定义一个父类型作为声明,传入的参数可以是父类型本身,也可以是对应的任意子类型对象。于是,多态的优点可以总结如下:

  • 降低耦合:只需要与父类型产生关联即可
  • 可维护性(继承保证):只需要添加或修改某一子类型即可,不会影响其他类
  • 可扩展性(多态保证):使用子类,可以对已有功能进行快速扩展
  • 灵活性
  • 接口性

在这里插入图片描述

小山猪的沙塔 CSDN认证博客专家 全栈开发工程师 大数据高级开发 大数据金牌讲师
若非一番寒彻骨,哪得梅花扑鼻香。全栈开发工程师,大数据高级开发工程师。大数据金牌讲师,知名机构合作讲师,各云大学及平台合作讲师,高校外聘讲师。微信公众号:微光点亮星辰,在学习的道路上一同见证点点滴滴。