本文关键字:Java、面向对象、三大特征、多态
多态是面向对象编程的三大特征之一,是面向对象思想的终极体现之一。在理解多态之前需要先掌握继承、重写、父类引用指向子类对象的相关概念,对继承还没有完全明白的同学可进传送门: Java面向对象编程三大特征 - 继承 。
在继承中,我们已经了解了子父类的关系以及如何对子父类进行设计,如果已经存在多个实体类,再去定义父类其实是不断的抽取公共重合部分的过程,如果有需要将会产生多重继承关系。在抽取整理的过程中,除了属性可以复用,有很多方法一样也可以复用,假如以图形举例:矩形、圆形,都可以具有周长和面积两个方法,但是计算的方式完全不同,矩形和圆形之间肯定不能构成子父类关系,那么只能是同时去继承一个父类,那么问题就来了,这两个类都有什么共同点?
除了都是图形好像并没有什么共同点,矩形有两组边长,圆形是通过半径来描述,如果非要往一起联系的话。。。Wait a moment(灵光一闪中,请勿打扰)!!!难道说是都可以计算出周长和面积?细细想来,也是能说出一番道理的,但是这好抽象啊!
如果真的是这样,也只能有一个模糊的思路,既然描述图形的属性不能够共用那就分别放在两个子类中吧,那么计算周长和面积的方法要怎么搞?如果在父类中定义相应的方法,那参数列表怎么写?方法体怎么填?这个坑好像有点大,接下来,我们就要华丽地将这个坑填平。
在上面的例子中,我们遇到了一个情况,有两个在逻辑上看似相关的类,我们想要把他们联系起来,因为这样做可以提高效率,但是在实施的过程中发现这个共同点有点太过模糊,难以用代码描述,甚至于还不如分开用来的方便,这时就要引出抽象的概念,对应的关键词为:abstract。
那么使用了abstract又能如何呢?这代表指定的方法和类很难表述,那么。。。就不用表述了!对于矩形类(Rectangle)与圆形类(Circle)的父类:图形类(Figure),我们只能总结出他具有计算周长和面积的方法,而具体的实现方法我们无法给出,只有明确了图形之后,才能给出具体的实现,于是我们使用抽象来描述这两个方法, 被abstract修饰的方法不需要有方法体,且不能为private ,由于抽象方法没有方法体,那么如果被代码调用到了怎么办呢?以下两个限制规则可以杜绝这个问题:
既然抽象类不能被实例化,那么自然也就不会调用到没有方法体的那些方法了,那这些方法该怎么被调用呢?我们需要一步一步的来梳理,至少目前我们已经能够清晰的得到如下的关系图了:
抽象类的本质依然是一个类(class),所以具备着一个普通类的所有功能,包括构造方法等的定义,总结一下,抽象类具有以下的几个特点:
现在,我们已经可以将抽象父类用代码描述出来:
// 定义抽象类:图形类 public abstract class Figure{ // 定义计算周长的抽象方法:getC() public abstract double getC(); // 定义计算面积的抽象方法:getS() public abstract double getS(); // 定义描述图形的非抽象方法:print() public void print(){ System.out.println("这是一个图形"); } }
现在我们已经有了一个抽象类,其中也定义了抽象方法,抽象类不能被直接实例化保证了抽象方法不会被直接调用到。回忆一下我们的出发点,费劲巴力的弄出个抽象类就是为了提取出两个类比较抽象的共同点,那么下一步自然是继承了。
重写指的是子父类之间方法构成的关系,当子类继承父类时,父类中可能已经存在了某些方法,那么子类实例就可以直接进行调用。在有些时候由于子父类之间的差异,对于已经存在的方法想要做一些修改,这个时候我们可以利用重写, 在子类中定义一个与父类中的方法完全相同的方法,包括返回值类型和方法签名(方法名 + 参数列表) ,此时就会构成重写。这样,子类实例在调用方法时就可以覆盖父类中的方法,具体的过程在后半部分阐述。
我们在刚开始接触方法的时候了解到了一个概念:重载,与重写有些类似,容易混淆,如果知识点已经模糊可以进传送门: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("圆形"); } }
从上面的代码中可以看到,子类继承父类后,如果存在抽象方法则比如重写,由于父类中的方法是抽象的,所以无法调用。对于普通的方法,可以选择性的重写,一旦重写我们可以认为父类的方法被覆盖了,其实这样的形容是不准确的,在初学阶段可以认为是覆盖。
比较规范的说法是:通过子类实例无法直接调用到父类中的同名方法了,但是在内存中依然存在着父类方法的结构,只不过访问不到而已。另外,我们同样可以在子类中显式的调用出父类方法,这要用到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("圆形"); } }
前面提到的概念消化完毕后,我们看一下子父类对象实例化的形式以及方法的执行效果。
如果父类是一个抽象类,则在等号右侧不能直接使用new加构造方法的方式实例化,如果一定要得到父类实例,就要使用匿名内部类的用法,这里不做讨论。
如果父类是一个普通类,那么我们在初始化时,等号左侧为父类型引用,等号右侧为父类型对象(实例),这个时候其实和我们去创建一个类的对象并没有什么分别,不需要想着他是某某类的父类,因为 此时他不会和任何子类产生关系,只是一个默认继承了Object类的普通类
,正常使用就好,能调用出的内容也都是父类中已定义的。
在进行子类实例化时,由于在子类的定义中继承了父类,所以在创建子类对象时,会先一步创建父类对象。在进行调用时,根据权限修饰符,可以调用出子类及父类中可访问的属性和方法。
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());// 得到圆形面积 } }
在刚开始学习编程时,我们接触了基本数据类型,可以直接用关键字声明,定义变量赋值后使用,并不需要使用new关键字。对于引用与对象的关系可以先参考之前的文章回顾一下: Java中的基本操作单元 - 类和对象 。在这里我们重点要说明的是:等号左侧的引用部分,与等号右侧的部分在程序运行层面有怎样的关联。
与基本数据类型不同,在类中可以定义各种属性和方法,使用时也需要先创建对象。等号左侧的部分依然是一个类型的声明,未赋值时虽然默认情况下是null,但在程序编译运行时,也会在栈中进行存储,记录了相应的结构信息,他所指向的对象必须是一个和它 兼容 的类型。
类的声明引用存放在栈中,实例化得到的对象存放在堆中。
下图为引用与实例在内存中的关系示意图,有关于Java对象在内存中的分布将在另外的文章中说明:
了解了引用与对象的关系之后,就有了一个疑问,如果等号左侧的声明类型与等号右侧的实例类型不一致会怎么样呢?如果我们要保证程序能够通过编译,并且顺利执行,必须要保证等号两边的类型是兼容的。完全不相关的两个类是不能够出现在等号左右两边的,即使可以使用强制类型转换通过编译,在运行时依然会抛出异常。
于是我们就联想到了子父类是否有可能进行兼容呢?会有两种情况:子类引用指向父类对象,父类引用指向子类对象,下面我们来一一讨论。
子类引用指向父类对象指的是:等号左侧为子类型的声明定义,等号右侧为父类型的实例。首先,结论是这种用法是不存在的,我们从两方面来分析原因。
第一个方面,是否符合逻辑?也就是是否会有某种需求,让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(图形)声明,代表我现在只知道是一个图形,知道能执行哪些方法,如果再告知是一个矩形,那就能算出这个矩形的周长和面积;如果是一个圆形,那就能算出这个圆形的周长和面积。我们也可以这样去描述:这个图形是一个矩形或这个图形是一个圆形。
如果从程序运行的角度去解释,我们已经知道,子类对象在实例化时会先实例化父类对象,并且,如果子类重写了父类的方法,父类的方法将会隐藏。如果我们用一个父类引用去指向一个子类对象,这就相当于对象实例很强大,但是我们只能启用部分的功能,但是有一个好处就是 相同的指令,不同的子类对象都能够执行,并且会存在差异 。这就相当于一部老人机,只具备打电话和发短信的功能,小米手机和魅族手机都属于升级扩展后的智能机,当然保有手机最基本的通讯功能,这样使用是没问题的。
学习了上面的内容后,其实你已经掌握了多态的用法,现在我们来明确总结一下。
多态指的是同一个父类,或同一个接口,发出了一个相同的指令(调用了同一个方法),由于具体执行的实例(子类对象或实现类对象)不同,而有不同的表现形态(执行效果)。
就像上面例子中的图形一样,自身是一个抽象类,其中存在一些抽象方法,具体的执行可以由子类对象来完成。对于抽象类的抽象方法,由于子类必须进行重写,所以由子类去执行父类的抽象方法必然是多态的体现,对于其他的情况则未必构成多态,因此总结了以下三个必要条件。
只有满足了这三个条件才能构成多态,这也就是文章前三点用这么长的篇幅来铺垫的原因。
使用多态有多种好处,特别是一个抽象类有多个子类,或一个接口存在多个抽象类时,在进行参数传递时就会非常的灵活,在方法中只需要定义一个父类型作为声明,传入的参数可以是父类型本身,也可以是对应的任意子类型对象。于是,多态的优点可以总结如下: