likes
comments
collection
share

从零开始学Java之如何正确实现面向对象的多态

作者站长头像
站长
· 阅读数 14

作者:孙玉昌,昵称【一一哥】,另外【壹壹哥】也是我哦

前言

我们知道,面向对象有三大特征:封装、继承和多态。现在我们已经了解了封装和继承,接下来在本文中,壹哥会给大家讲解多态的内容。在这篇文章中,我们要弄清楚多态的含义、特点、作用,以及如何用代码进行实现。

------------------------------前戏已做完,精彩即开始----------------------------

全文大约【6000】 字,不说废话,只讲可以让你学到技术、明白原理的纯干货!本文带有丰富的案例及配图视频,让你更好地理解和运用文中的技术概念,并可以给你带来具有足够启迪的思考......

一. 多态简介

1. 概念

多态(polymorphism)本来是生物学里的概念,表示地球上的生物在形态和状态方面的多样性。

而在java的面向对象中,多态则是指同一个行为可以有多个不同表现形式的能力。也就是说,在父类中定义的属性和方法,在子类继承后,可以有不同的数据类型或表现出不同的行为。这可以使得同一个属性或方法,在父类及其各个子类中,可能会有不同的表现或含义。 比如针对同一个接口,我们使用不同的实例对象可能会有不同的操作,同一事件发生在不同的实例对象上会产生不同的结果。

当然,如果我们只是看这样干巴巴的概念,可能大家还是有点懵,壹哥给大家举个栗子。

我们都听过“龙生九子”的故事。长子是囚牛,喜欢搞音乐;次子是睚眦,喜欢打架。后面还有喜欢冒险登高的嘲风,爱大喊大叫的蒲牢,喜欢吸烟的狻猊,爱好举重的霸下,好打官司的狴犴,喜欢斯文的负屃,会灭火的螭吻。他们都是龙的儿子,自然也都是龙,但每个龙都有不同的个性和技能。假如有一天玉帝对龙王说,“让你的儿子来给我秀个技能”。大家说这个任务的执行结果会怎么样?这是不是得看龙王让哪个儿子来秀了!如果是让老大来表演,就是演奏音乐;如果是让老二来表演,就是表演打架.....

从这个故事中,我们就可以感受到,九个龙子虽然都继承了共同的父类,但子类在运行某个方法时却可能会有不同的结果,这就是多态!

2. 作用

根据多态的概念可知,多态机制可以在不修改父类代码的基础上,允许多个子类进行功能的扩展。比如父类中定义了一个方法A,有N个子类继承该父类,这几个子类都可以重写这个A方法。并且子类的方法还可以将自己的参数类型改为父类方法的参数类型,或者将自己的返回值类型改为父类方法的返回值类型。这样就可以动态地调整对象的调用,降低对象之间的依存关系,消除类型之间的耦合,使程序有良好的扩展,并可以对所有类的对象进行通用处理,让代码实现更加的灵活和简洁。

3. 分类

Java中的多态,分为编译时多态和运行时多态

  • 编译时多态主要是通过 方法的重载(overload)来实现,Java会根据方法参数列表的不同来区分不同的方法,在编译时就能确定该执行重载方法中的哪一个。这是静态的多态,也称为静态多态性、静态绑定、前绑定。但也有一种特殊的方法重写的情况,属于编译时多态。在方法重写时,当对象的引用指向的是当前对象自己所属类的对象时,也是编译时多态,因为在编译阶段就能确定执行的方法到底属于哪个对象。
  • 运行时多态:主要是通过方法的重写(override)来实现,让子类继承父类并重写父类中已有的或抽象的方法。这是动态的多态,也称为”后绑定“,这是我们通常所说的多态性。

一句话,如果我们在编译时就能确定要执行的方法属于哪个对象、执行的是哪个方法,这就是编译时多态,否则就是运行时多态!

4. 特性

根据多态的要求,Java对象的类型可以分为编译类型和运行类型,多态有如下特性:

  • 一个对象的编译类型与运行类型可以不一致;
  • 编译类型在定义对象时就确定了,不能改变,而运行类型却是可以变化的;
  • 编译类型取决于定义对象时 =号的左边运行类型取决于 =号的右边。

所以我们在使用多态方式调用方法时,首先会检查父类中是否有该方法,如果没有,则会产生编译错误;如果有,再去调用子类中的同名方法。即编译时取决于父类,运行时取决于子类

5. 必要条件

我们要想实现多态,需要满足3个必要条件:

  • 继承:多态发生在继承关系中,必须存在有继承关系的父类和子类中,多态建立在封装和继承的基础之上;
  • 重写:必须要有方法的重写,子类对父类的某些方法重新定义;
  • 向上转型:就是要将父类引用指向子类对象,只有这样该引用才既能调用父类的方法,又能调用子类的方法。

只有满足了以上3个条件才能实现多态,开发人员也才能在同一个继承结构中,使用统一的代码实现来处理不同的对象,从而执行不同的行为。

二. 多态的实现

1. 实现方式

在Java中,多态的实现有如下几种方式:

  • 方法重载:重载可以根据实际参数的数据类型、个数和次序,在编译时确定执行重载方法中的哪一个。
  • 方法重写:这种方式是基于方法重写来实现的多态;
  • 接口实现:接口是一种无法被实例化但可以被实现的抽象类型,是对抽象方法的集合。定义一个接口可以有多个实现,这也是多态的一种实现形式,与继承中方法的重写类似。

2. 实现过程

2.1 需求分析

现在我们有一个需求:有一个客户要求我们给他生产设备器材,他需要的产品类型比较多,可能要圆形的器材,也可能需要三角形、矩形等各种形状的器材,我们该怎么生产实现?

如果是按照我们之前的经验,可以分别创建圆形类、三角形类、矩形类等,里面各自有对应的生产方法,负责生产出对应的产品。但是如果这样设计,其实不符合面向对象的要求。以后客户可能还会有很多其他的需求,如果针对每一个需求都设计一个类和方法,最终我们的项目代码就会很啰嗦。

实际上,在客户的这些需求中,有很多要求是具有共性的!比如,无论客户需要什么形状的器材,我们都要进行”绘制生产“,在绘制生产的过程中,可能用到的材料都是一样的,无非就是形状不同!就好比生产巧克力,有圆的方的奇形怪状的,不管怎么样,基础原料都是巧克力。既然如此,我们总不能针对每一种形状的器材都从头到尾搞一遍吧?

所以既然它们有很多内容都一样,我们就可以定义一个共同的父类,在父类中完成共性的功能和特征,然后由子类继承父类,每个子类再扩展实现自己个性化的功能。如下图所示:

从零开始学Java之如何正确实现面向对象的多态

这样就是符合面向对象特征的代码设计了!接下来壹哥就通过一些代码案例,来给大家演示该如何实现这个需求。

2.2 代码实现

接下来壹哥会采用实现接口的方式来演示多态的代码实现过程。方法重载和方法重写的方式,其实我们在前面的文章中已经有所讲解,这里不再赘述。

2.2.1 定义Shape接口

我们首先定义出一个Shape接口,这个接口就是一个父类。在Java中,子类可以继承父类,也可以实现接口。一个子类只能继承一个父类,但是却可以实现多个接口。 这些接口,属于是子类的”间接父类“,你可以理解为是子类的”干爹“或者爷爷等祖辈。关于接口的内容,壹哥会在后面的文章中专门讲解,敬请期待哦,此处大家先会使用即可。

/**
 * @author 一一哥Sun
 * 千锋教育
 * 定义一个“图形”接口----属于父类!
 */
public interface Shape {
    //绘制方法。接口中的方法一般没有实现,需要子类进行实现。
    void draw();
}

2.2.2 定义Circle类

定义一个Circle子类,实现Shape接口,注意我们这里使用了implements关键字!

/**
 * @author 一一哥Sun
 * 千锋教育
 * “圆形”类---实现Shape接口,并对接口中的方法进行实现
 */
public class Circle implements Shape{
    @Override
    public void draw() {
	System.out.println("绘制圆形");
    }

    //子类中定义了一个独有的方法。
    //当父类引用指向子类对象时,父类只能调用执行那些在父类中声明、被子类覆盖的子类方法,
    //而不能执行子类独有的成员方法。
    public void scroll(){
        System.out.println("圆形类独有的方法");
    }
}

2.2.3 定义Traingle类

然后再定义一个Traingle子类,也实现Shape接口。

/**
 * @author 一一哥Sun
 * 千锋教育
 * “矩形”类---实现Shape接口,并对接口中的方法进行实现
 */
public class Traingle implements Shape{
    @Override
    public void draw() {
	System.out.println("绘制矩形");
    }
}

2.2.4 定义Square类

最后定义一个Square子类,同样实现Shape接口。

/**
 * @author 一一哥Sun
 * 千锋教育
 * “三角形”类---实现Shape接口,并对接口中的方法进行实现
 */
public class Square implements Shape{
    @Override
    public void draw() {
	System.out.println("绘制三角形");
    }
}

2.4.5 定义测试类

父子关系确定好之后,接下来我们再定义一个额外的测试类。在这个测试类中,我们创建出以上三个图形对象。注意,在=等号左侧,变量的类型都是Shape父类;=等号右侧,变量的值是具体的子类!这种变量的定义过程,其实就是符合了多态的第三个必要条件,也就是所谓的”向上转型,父类引用指向子类对象“。

/**
 * @author 一一哥Sun
 * 千锋教育
 */
public class ShapeTest {
    public static void main(String[] args) {
	//多态测试
	Shape shape01=new Circle();
	shape01.draw();
        
        //当父类引用指向子类对象时,父类只能调用执行那些在父类中声明、被子类覆盖的子类方法,
    	//而不能执行子类独有的成员方法。否则在编译阶段就会出现:
        //The method drink() is undefined for the type Father
        //shape01.scroll();
	Shape shape02=new Traingle();
	shape02.draw();
		
	Shape shape03=new Square();
	shape03.draw();
    }
}

我们可以看到上述代码,满足了多态的3个必要条件:继承、重新、向上转型!有子类继承父类,有方法重写,有向上转型。而且根据这个案例,我们可以进一步理解多态的含义和特点。在多态中,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法!

本案例最终的执行结果如下图所示:

从零开始学Java之如何正确实现面向对象的多态

2.3 结果分析

在上述案例中,我们有如下一些代码:

Shape shape01=new Circle();
//无法确定运行时究竟调用哪个run()方法
shape01.draw();
		
Shape shape02=new Traingle();
shape02.draw();
		
Shape shape03=new Square();
shape03.draw();

上述代码中,我们实际的类型是Circle、Traingle、Square,他们共同的父类,其引用类型是Shape变量。当我们调用shape.draw()时,大家可以想一下,执行的是父类Shape的draw()方法还是具体子类的draw()方法?大多数同学应该能够想出来,执行的应该是具体子类的draw()方法!

基于以上这个案例,我们可以得出一个结论:

Java实例方法的调用,是基于运行时实际类型的动态调用,而非声明的变量类型!通俗地说,就是我们调用的到底是哪个对象的方法,不是由=号左侧声明的引用变量来决定的,而是由=号右侧的实际对象类型来决定的!

这也是多态的一个重要特征!所以我们说在多态中,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法!即只有在运行期,才能动态决定调用哪个子类的方法。 这种不确定性的方法调用,究竟有什么作用呢?其实主要就是允许我们能够添加更多类型的子类,实现对父类功能的扩展,而不需要修改父类的代码。

三. 扩展补充

1. 方法重写时的编译时多态

当对象的引用指向的是当前对象所属类的对象,即使是方法重写,依然属于编译时多态。

1.1 定义父类

我们先定义一个Father父类,内部定义一个eat()方法。

/**
 * @author 一一哥Sun 
 * 千锋教育
 * 定义父类
 */
public class Father {
    // 吃
    public void eat() {
	System.out.println("爹吃馒头");
    }
}

1.2 定义子类

接着定义一个Son子类继承Father父类,并重写eat()方法。

public class Son extends Father {
    // 吃
    @Override
    public void eat() {
	// 方法重写时,子类可以对父类的同名方法进行扩展实现,方法体的内容可以和父类中的实现不一样
	System.out.println("儿子吃肉");
    }

    public static void main(String[] args) {
	//创建出父类对象
	Father father=new Father();
	father.eat();
	//创建子类对象
	//虽然子类继承了父类,并重写了父类的方法,但对象的引用指向的是当前对象所属类的对象,即son引用指向的是new Son()对象,这也是编译时多态!
	Son son = new Son();
	son.eat();
    }
}

虽然这里的Son子类继承了父类Father,并重写了父类的方法,但对象的引用指向的是当前对象所属类的对象,即son引用指向的是new Son()对象,这也是编译时多态!

2. 实现多态时的若干细节

2.1 定义Father父类

我们定义一个Father父类,类中定义了name属性,成员方法eat(),静态方法play()。

/**
 * @author 一一哥Sun 
 * 千锋教育
 * 定义父类
 */
public class Father {
    // 父类中的成员变量
    String name = "老子";
    // 吃
    public void eat() {
	System.out.println("爹吃馒头");
    }
    // 父类中的静态方法。静态方法不能被重新,只会被子类隐藏!
    public static void play() {
	System.out.println("爹玩球");
    }
}

2.2 定义Son子类

接着再定义一个Son子类,类中定义了同名的name属性和特有的age属性,重写成员方法eat(),特有的drink()方法,并定义一个同名的静态方法play()。

//Son子类
public class Son extends Father {
    // 成员变量--变量隐藏
    // 变量的类型和名称,都与父类中的成员变量相同
    String name = "儿子";

    //子类中独有的属性
    int age = 10;

    // 吃
    @Override
    public void eat() {
	// 方法重写时,子类可以对父类的同名方法进行扩展实现,方法体的内容可以和父类中的实现不一样
	System.out.println("儿子吃肉");
    }

    // 喝---子类新增的方法。
    // 当父类引用指向子类对象时,父类只能调用执行那些在父类中声明、被子类覆盖的子类方法,而不能执行子类独有的成员方法。
    public void drink() {
	System.out.println("儿子喝酒");
    }

    // 子类中与父类同名的静态方法,这不是重写,而是子类对父类同名静态方法的隐藏!
    public static void play() {
	System.out.println("儿子玩火");
    }

    public static void main(String[] args) {
	// 当父类引用指向子类对象时,父类只能调用执行那些在父类中声明、被子类覆盖的子类方法,
        //而不能执行子类独有的成员方法。
	// Father son=new Son();
	// The method drink() is undefined for the type Father
	// son.drink();
		
	Father son=new Son();
	//当子类和父类有相同属性时,父类会调用自己的属性。
	System.out.println("name="+son.name);//老子
	//当父类引用指向子类对象向上转型时,若父类调用子类特有的属性,在编译时期就会报错:
	//age cannot be resolved or is not a field
	//son.age;

        //虽然我们不建议通过“对象.方法名”的形式来调用类的静态方法,但这么调用也不会出错。
	son.play();//爹玩球
    }
}

2.3 执行结果

上述代码执行结果如下图所示:

从零开始学Java之如何正确实现面向对象的多态

根据上述代码的执行结果可知,当父类引用指向子类对象时,父类只能调用执行那些在父类中声明、被子类覆盖的子类方法,而不能执行子类独有的成员方法。 否则在编译阶段就会出现”The method drink() is undefined for the type Father“异常。

另外当子类和父类有相同属性时,父类会调用自己的属性。 当父类引用指向子类对象向上转型时,若父类调用子类特有的属性,在编译时期就会报错”age cannot be resolved or is not a field“。

如果Father父类中定义了一个静态方法play(),子类也定义了一个同名的静态方法play(),上述代码中son.play()执行的是Father类中的play()方法。在进行向上转型时,父类引用调用同名的静态方法时,执行的是父类中的方法。这是因为在运行时,虚拟机已经确定了static方法属于哪个类。“方法重写”只适用于实例方法,对静态方法无效。静态方法,只能被隐藏、重载、继承,但不会被重写。 子类会将父类的静态方法隐藏,但不能覆盖父类的静态方法,所以子类的静态方法体现不了多态,这和子类属性隐藏父类属性一样。

------------------------------正片已结束,来根事后烟----------------------------

四. 结语

至此,我们就把面向对象的三大特征都学习完毕了,现在你对这三大特征都熟悉了吗?最后我们再来看看多态的要点都有哪些吧:

  • 多态指的是不同子类型的对象,对同一行为作出的不同响应;
  • 实现多态要满足继承、重新、向上转型的条件;
  • 多态分为编译时多态和运行时多态,我们常说的多态是指运行时多态;
  • 方法重载是编译时多态,方法重写是运行时多态,但重写有例外情况;
  • 父类引用指向子类对象时,调用的实例方法是子类重写的方法,父类引用不能调用子类新增的方法和子类特有属性;
  • 父类引用指向子类对象时,父类引用只会调用父类自己的属性和static方法,不会调用子类的;
  • 多态使得代码更加灵活,方便了代码扩展。

另外如果你独自学习觉得有很多困难,可以加入壹哥的学习互助群,大家一起交流学习。

五. 配套视频

如果你不习惯阅读技术文章,或是对文中的技术概念不能很好地理解,可以来看看壹哥帮你筛选出的视频教程。与本文配套的Java学习视频,链接如下:

player.bilibili.com/player.html…

六. 今日作业

1. 第一题

评论区写出封装、继承和多态各自的要求和特性。