六. 多态
多态是面向对象编程语言中,继封装和继承之外的第三个重要特性。多态主要是消除类型之间的耦合。
1. 方法调用绑定
将一个方法调用和一个方法主体关联起来称作绑定。若绑定发生在程序运行前(如果有的话,由编译器和链接器实现),叫做前期绑定。
多态的一个特点是父类引用指向子类对象,在编译时编译器只看作父类的类型,无法得知究竟会调用哪个方法,而在运行时才确定对象类型。通过一种后期绑定也叫动态绑定的方法来实现。代码示例:
public class Test {
public static void test(Fruit fruit) {
fruit.func();
}
public static void main(String[] args) {
Fruit apple1 = new Apple();
test(apple1);
Apple apple2 = new Apple();
test(apple2);
Orange orange = new Orange();
test(orange);
}
}
class Fruit {
public void func() {
System.out.println("This is the Fruit.");
}
}
class Apple extends Fruit {
@Override
public void func() {
System.out.println("This is the apple.");
}
}
class Orange extends Fruit {
@Override
public void func() {
System.out.println("This is the orange.");
}
}
代码结果:
Java 中除了 static 和 final 方法(private 方法也是隐式的 final)外,其他所有方法都是后期绑定。
2. 可扩展性
可以从上面那个例子中看到,由于多态机制,可以向系统中添加任意多的新类型,而不需要修改 test()
方法。
3. 两个“陷阱”
第一个:“重写”私有方法
private 方法可以当作是 final 的,对于派生类来说是隐蔽的。因此,如果子类写了一个与父类中私有方法相同名的方法,实际是一个与父类无关的全新的方法,根本不算是重写方法。(可以使用@Override注解检测出问题)
第二个:属性与静态方法
只有普通的方法调用可以是多态的,如果直接访问一个属性,该访问会在编译时解析:
public class FieldAccess {
public static void main(String[] args) {
Super sup = new Sub(); // 向上转型
System.out.println("sup.field = " + sup.field +
", sup.getField() = " + sup.getField());
Sub sub = new Sub();
System.out.println("sub.field = " + sub.field +
", sub.getField() = " + sub.getField()
+ ", sub.getSuperField() = " + sub.getSuperField());
}
}
class Super {
public int field = 0;
public int getField() {
return field;
}
}
class Sub extends Super {
public int field = 1;
@Override
public int getField() {
return field;
}
public int getSuperField() {
return super.field;
}
}
输出结果:
当 Sub 对象向上转型为 Super 引用时,任何属性访问都被编译器解析,因此不是多态的。在这个例子中,Super.field 和 Sub.field 被分配了不同的存储空间,Sub 实际上包含了两个称为 field 的属性:它自己的和来自 Super 的。然而,在引用 Sub 的 field 时,默认的 field 属性并不是 Super 版本的 field 属性。为了获取 Super 的 field 属性,需要显式地指明 super.field。
当然,在实际中基本不会有上述情况,因为通常会将所有的属性都指明为 private,而不能直接访问它们,只能通过方法来访问。此外,一般也不会给基类属性和派生类属性起相同的名字,这样做会令人困惑。
此外,如果一个方法是静态(static)的,它的行为就不具有多态性。(静态的方法只与类关联,与单个的对象无关。)
4. 构造器调用顺序
构造器不同于其他类型的方法,它并不具有多态性(我们会把它看作是隐式声明的静态方法)。
在派生类的构造过程中总会调用基类的构造器,初始化会自动按继承层次结构上移,因此每个基类的构造器都会被调用到。
原因:构造器需要检查对象是否被正确地构造。由于属性通常声明为 private,派生类只能访问自己的成员而不能访问基类的私有成员。只有基类的构造器拥有权限来初始化自身的元素,因此必须得调用所有构造器。(如果在派生类的构造器主体中没有显式地调用基类构造器,编译器会默默调用无参构造器。)
下面的例子展示了使用了组合、继承和多态之后的构建顺序:
public class Sandwich extends PortableLunch {
private Bread b = new Bread();
private Cheese c = new Cheese();
private Lettuce l = new Lettuce();
public Sandwich() {
System.out.println("Sandwich()");
}
public static void main(String[] args) {
new Sandwich();
}
}
class Meal {
Meal() {
System.out.println("Meal()");
}
}
class Bread {
Bread() {
System.out.println("Bread()");
}
}
class Cheese {
Cheese() {
System.out.println("Cheese()");
}
}
class Lettuce {
Lettuce() {
System.out.println("Lettuce()");
}
}
class Lunch extends Meal {
Lunch() {
System.out.println("Lunch()");
}
}
class PortableLunch extends Lunch {
PortableLunch() {
System.out.println("PortableLunch()");
}
}
输出结果:
从创建 Sandwich 对象的输出中可以看出对象的构造器调用顺序如下:
- 先是基类构造器被调用。这个步骤被不断重复,使得顶级父类会被最先构造,然后是它的派生类,以此类推,直到最底层的派生类。
- 按声明顺序初始化成员。
- 调用派生类构造器的方法体。
5. 协变返回类型
Java 5 中引入了协变返回类型,这表示派生类的被重写方法可以返回基类方法返回类型的派生类型:
public class CovariantReturn {
public static void main(String[] args) {
Mill m = new Mill();
Grain g = m.process();
System.out.println(g);
m = new WheatMill();
g = m.process();
System.out.println(g);
}
}
class Grain {
@Override
public String toString() {
return "Grain";
}
}
class Wheat extends Grain {
@Override
public String toString() {
return "Wheat";
}
}
class Mill {
Grain process() {
return new Grain();
}
}
class WheatMill extends Mill {
@Override
Wheat process() {
return new Wheat();
}
}
输出结果:
关键区别在于 Java 5 之前的版本强制要求被重写的 process()
方法必须返回 Grain 而不是 Wheat,即使 Wheat 派生自 Grain,因而也应该是一种合法的返回类型。协变返回类型允许返回更具体的 Wheat 类型。
6. 向下转型与运行时类型信息
由于向上转型(在继承层次中向上移动)会丢失具体的类型信息,那么为了重新获取类型信息,就需要在继承层次中向下移动,使用向下转型。
向上转型永远是安全的,因为基类不会具有比派生类更多的接口。但是对于向下转型,无法知道一个 Fruit 是 Apple,还是 Orange 或其他一些类型。
在某些语言中(如 C++),必须执行一个特殊的操作来获得安全的向下转型,但是在 Java 中,每次转型都会被检查来确保它是希望的那种类型。如果不是,就会得到 ClassCastException (类转型异常)。
例如:Apple 向上转型为 Fruit 之后只能再向下转型为 Apple 而不能转为 Orange。
参考资料:On Java 8