4.3 多态(polymorphism)
扫码看视频
4.3.1 何为多态
修改代码4.5,将main方法中的代码修改为下面两句代码:
由于Fish是Animal的子类,所以可以将Fish对象的直接隐式类型转换为Animal类的对象,这种类型转换很好理解,毕竟鱼也是一种动物。
接下来调用an.breathe(),程序会输出“动物呼吸”,还是“鱼吐泡”呢?输出结果是:
为什么an的类型是Animal,输出结果却是调用的子类的breathe方法呢?这就是Java的多态性,多态性是面向对象的一个重要特性,它是通过覆盖父类的方法来实现的,在运行时根据传递的实际对象引用,来调用相应的方法。
在这里,虽然an的类型是Animal,但它实际引用的是Fish对象,因此在运行时最终调用的是Fish的breathe方法。
4.3.2 多态的实际应用
读者现在知道了多态性是怎么回事,但是这个特性如何在实战中发挥作用,怕是依然很迷惑。下面我们给出一个例子,来帮助读者更好地理解多态性及其应用。在魔兽争霸这类游戏中,每个兵种都是一个单位,为此,我们可以先定义一个Unit类来表示任意单位,如代码4.6所示。
每个单位都有它的生命值(HP)和攻击力(AP),而且每个单位都可以移动和攻击。
接下来,我们以Unit类为基类,派生出具体的兵种,包括:步兵(Footman)、火枪手(Rifleman)、骑士(Knight)等,如代码4.7~代码4.9所示。
上述三个兵种子类都覆盖了父类Unit的move方法。
接下来我们编写一个游戏类,如代码4.10所示。
看到这里不知道读者对于多态性的应用是否有些感悟了。我们在编写程序时,由于有多态性的存在,程序的基本结构可以提前编写好,正如这里的moveUnit方法,它接受的参数是一个Unit类的对象,Unit类作为所有兵种类的基类,可以接受任意子类对象的传入,根据多态性的原理,最终调用的方法是子类对象覆盖的方法。这样做的好处是,如果后期有新的兵种加入,那么我们只需要编写一个继承Unit类的新的兵种类,覆盖相应的方法,然后构造一个新兵种对象,传入moveUnit方法即可,最终调用的是新兵种对象自己的方法。
本例的代码我们统一放到了game子目录下,在编译时,可以先进入该目录,然后执行下面的语句一次性编译所有的Java源文件。
运行时,执行java Game,程序的输出结果是:
4.3.3 Java编译器如何实现多态
有了多态特性,我们的代码会变得更加灵活。那么多态特性到底是如何实现的呢?这主要是通过动态绑定(dynamic binding)来实现的,也称为后期绑定(late binding)。既然有动态绑定,自然也有静态绑定(static binding),也称为早期绑定(early binding)。例如在C++中调用非虚函数时,采用的就是静态绑定,在编译阶段就直接在函数调用处填写好了函数代码所在内存的地址偏移量。对于静态绑定来说,函数(方法)的调用是被固化到程序的二进制代码当中的,在程序运行时是不能被改变的,所以它无法根据运行时提供的类型来改变自身的操作。
动态绑定是在编译时不确定具体调用的方法,而在程序运行时,根据对象的实际类型来调用对应的方法(函数)。Java天生就是多态的,它通过方法表来实现:每个类被加载到虚拟机时,在方法区保存元数据,其中,就有一个叫作方法表(method table)的东西,表中记录了这个类定义的方法所在的地址,每个表项指向一个具体的方法代码。如果一个子类重写了父类中的某个方法,则对应表项指向新的代码实现处。从父类继承来的方法位于子类定义的方法的前面。
面向对象程序设计语言都使用了动态绑定机制来实现多态,无论是Java、C++、C#,还是Ruby,虽然它们的实现方式可能不同,但是原理都是相同的。
4.3.4 类型转换
前面我们已经看到,在将子类对象赋给父类型变量的时候,不需要进行强制类型转换,例如:
由于在画UML类图的时候,基类通常放在上方,所以这种类型转换称为向上转型(upcasting)。如图4-1所示。
图4-1 向上转型
向上转型会让子类对象“缩小”,只能调用父类中的方法,对于子类中新增的方法就无法调用了。在这里,如果通过ui来调用,则只能调用Unit类中的方法。如果通过fm来调用,则可以调用继承的父类中的方法,以及子类Footmen中新增的方法。
既然有向上转型,自然也有向下转型(downcasting),通俗地讲,向下转型就是从父类型转换为子类型。我们看代码4.11。
从上面的代码中可以看出,在给arr数组赋值时,进行了一次向上转型,Child对象转换为Father类型。在转换之后,Child对象还是Child对象,只不过在arr数组中,它被当成Father对象来使用。接下来,分别调用数组中两个对象的a和b方法,这不会有任何问题,毕竟大家都有Father类中定义的方法。当调用arr[1].d()时,问题来了,由于Child对象现在是被当作Father对象来看待的,而Father类中并没有d方法,所以在编译时就会报错,提示找不到d方法。
我们知道arr[1]实际上就是Child对象,为了调用它的d方法,就需要进行向下转型,从父类型转换为具体的子类型。接下来的代码把arr数组的两个元素都转换为Child对象,然后调用d方法。从代码中可以看到,向下转型需要强制类型转换。
现在有了一个新的问题,对于arr[1]来说,它就是Child对象,类型转换后调用d方法没有问题,但对于arr[0]来说,它是一个Father对象,它根本就没有Child对象的特征,又怎么能够调用d方法呢?不幸的是,这种错误在编译期间是无法被发现的,编译不会报错,但在你运行程序时就会抛出一个类型转换异常。
所以在进行向下转型时一定要小心,要确保对象确实是转换后的类型的实例。为了避免不正确的类型转换所引发的错误,可以先用instanceof运算符来判断一下对象是不是某个类型的实例,如果是,则该运算符会返回true,否则返回false。更为安全的类型转换代码如下所示:
instanceof运算符左边是要判断的对象,右边是引用类型。如果arr[0]不是Child的实例,if语句中的代码就不会被执行,那么也就不会出现运行时的类型转换错误了。
4.3.5 协变返回类型
协变返回类型是从Java 5才加入的新特性,它可以让子类(派生类)覆盖的方法的返回值类型为父类(基类)方法返回值类型的某个子类。我们看代码4.12。
Beer是Liquor类的子类,LiquorFactory类的make方法的返回值类型是Liquor类,BeerFactory是LiquorFactory类的子类,覆盖了make方法,在正常情况下,重写的make方法的返回值类型也应该是Liquor类,但这里使用了Java 5新增的特性:协变返回类型,返回的是Liquor的子类型Beer。
程序运行的结果为:
若上面的例子在Java 5之前的版本编译,则编译器会提示BeerFactory类的make方法应该返回Liquor类型。
提示:当调用System.out.println方法打印一个对象时,会自动调用该对象的toString 方法,并打印输出该方法的返回值。
4.3.6 在构造方法中调用被覆盖的方法
如果我们在父类的构造方法中调用了一个被子类覆盖的方法会出现什么问题呢?如果在父类的构造方法中调用了一个被子类覆盖的方法,那么当构造子类对象时,由于Java的多态性,其结果就是父类的构造方法会毅然地调用子类覆盖的方法,而自己的方法会被弃之不用。
出现这种情况并不是Java解释器的问题,它确实在按照Java的规则做事。不过对于程序员来说,这种情况可能会导致灾难:基类的某些资源没有被正确地初始化。
我们看代码4.13。
程序运行的结果为:
可以看到,Music基类初始化时调用了Jazz类的play方法。虽然这个例子不会出现问题,但是如果基类初始化时调用的方法涉及资源的分配(如打开文件、建立网络连接等),那么就会造成一些难以查找的错误。因此,在编写构造方法时,最好不要调用其他方法。如果初始化代码较多或多个构造方法中存在重复的代码,就确实需要封装为一个方法来调用,那么可以将该方法声明为私有的(private)或者final方法,这样可以避免子类覆盖这些方法,从而解决多态性导致的初始化问题。
关于私有方法和final方法,请读者参看第4.5和第4.7节。