Thinking in java Reading Note(8.多态)
1.多态概述
多态通过分离做什么和怎么做,从另一角度将接口和实现分离开来。多态不但能够改善代码的组织结构和可读性,还能够创建可扩展的程序---即无论在项目最初创建时还是在需要新功能时都可以“生长”的程序。
“封装”通过合并特征和行为来创建新的数据类型。“实现隐藏”则通过将细节“私有化”把接口和实现分离开来。
而多态的作用则是消除类型之间的耦合关系。在前一章中我们已经知道,继承允许将对象视为它自己本身的类型或其基类型来加以处理。这种能力极为重要,因为它允许将多种类型(从同一基类导出的)视为同一类型来处理,而同一份代码也就可以毫无差别的运行在这些不同的类型之上了。多态方法调用允许一种类型表现出与其他相似类型之间的区别,只要它们都是从同一基类导出来的。这种区别是根据方法行为的不同而表现出来的,虽然这些方法都可以通过同一个基类来调用。
2.转机
方法调用绑定
将一个方法调用同一个方法主体关联起来被称作绑定。若在程序执行前进行绑定(如果有的话,由编译器和连接程序实现),叫做前期绑定。
后期绑定的含义就是在运行时根据对象的类型进行绑定。后期绑定也叫作动态绑定或运行时绑定。如果一种语言想实现后期绑定,就必须具有某种机制,以便在运行时能判断对象的类型,从而调用恰当的方法。也就是说,编译器一直不知道对象的类型,但是方法调用机制能找到正确的方法体,并加以调用。后期绑定机制随编程语言的不同而有所不同,但是只要想以下就会得知,不管怎样都必须在对象中安置某种“类型信息”。
Java除了static方法和final方法(private方法属于final方法)之外,其他所有的方法都是后期绑定。这意味着通常情况下,我们不必判定是否应该进行后期绑定---它会自动发生。
为什么要将某个方法声明为final呢?正如前一章提到的那样,它可以防止其他人覆盖该方法。但最重要的一点或许是:这样做可以有效地“关闭”动态绑定,或者说,告诉编译器不需要对其进行动态绑定。这样,编译器就可以为final方法调动生成更有效的代码。然而,大多数情况下,这样做对程序的整体性能不会有什么改观。所以,最好根据设计来决定是否使用final,而不是出于试图提高性能的目的来使用final。
缺陷:“覆盖”私有方法
我们试图像下面这样做也是无可厚非的:
//: polymorphism/PrivateOverride.java // Trying to override a private method. package polymorphism; import static net.mindview.util.Print.*; public class PrivateOverride { private void f() { print("private f()"); } public static void main(String[] args) { PrivateOverride po = new Derived(); po.f(); } } class Derived extends PrivateOverride { public void f() { print("public f()"); } } /* Output: private f() *///:~
我们所期望的输出是public f(),但是由于private方法被自动认为是final方法,而且对导出类是屏蔽的。因此,在这种情况下,Derived类中的f()方法就是一个全新的方法;既然基类总的f()方法在子类Derived中不可见,因此甚至也不能被重载。
结论就是:只有非private方法才可以被覆盖;但是还需要密切注意覆盖private方法的现象,这时虽然编译器不会报错,但是也不会按照我们所期望的来执行。确切地说,在导出类中,对于基类中的private方法,最好采用不同的名字。
缺陷:域与静态方法
一旦你了解了多态机制,可能就会开始认为所有事物都可以多态地发生。然而,只有普通的方法调用可以是多态的。
例如,如果你直接访问某个域,这个访问就将在编译期进行解析,就像下面的示例所演示的:
//: polymorphism/FieldAccess.java // Direct field access is determined at compile time. class Super { public int field = 0; public int getField() { return field; } } class Sub extends Super { public int field = 1; public int getField() { return field; } public int getSuperField() { return super.field; } } public class FieldAccess { public static void main(String[] args) { Super sup = new Sub(); // Upcast 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()); } } /* Output: sup.field = 0, sup.getField() = 1 sub.field = 1, sub.getField() = 1, sub.getSuperField() = 0 *///:~
当sub对象转型为Super引用时,任何域访问操作都将由编译器解析,因此不是多态的。在本例中,为Super,field和Sub.field分配了不同的存储空间。这样,Sub实际包含两个称为field的域:它自己的和它从Super处得到的。然而,在引用Sub中的field时所产生的默认域并非Super版本的field域。因此,为了得到Super.field,必须显式的指明super.field。
如果某个方法是静态的,它的行为就不具有多态性。
3.构造器与多态
尽管构造器并不具有多态性(它们实际上是static方法,只不过该static声明是隐式的),但还是非常有必要理解构造器怎样通过多态在复杂的层次结构中运作。
构造器的调用顺序
复杂对象调用构造器要遵照下面的顺序:
1)调用基类构造器。这个步骤会不断地反复递归下去,首先是构造这种层次的根,然后是下一层导出类,等等,直至最底层的导出类。
2)按声明顺序调用成员的初始化方法。
3)调用导出类构造器的主体。
继承与清理(跳过)
构造器内部的多态方法的行为
如果在一个构造器的内部调用正在构造的对象的某个动态绑定方法,会发生什么?
在一般的方法内部,动态绑定的调用是在运行时才决定的,因为对象无法知道它是属于方法所在的那个类,还是属于那个类的导出类。
如果要调用构造器内部的一个动态绑定方法,就要用到那个方法的被覆盖后的定义。然而,因为被覆盖的方法在对象被完全构造之前就会被调用,这可能会造成一些难于发现的错误。
从概念上讲,构造器的工作实际上是创建对象(这并非是意见平常的工作)。在任何构造器内部,整个对象可能只是部分形成----我们只知道基类对象已经进行初始化。如果构造器只是在构建对象过程中的一个步骤,并且该对象所属的类是从这个构造器所属的类导出的,那么导出部分在当前构造器正在被调用的时刻仍旧是没有被初始化的(导出类方法未被覆盖)。然而,一个动态绑定的方法调用却会向外深入到继承层次结构的内部,它可以调用导出类里的方法。如果我们是在构造器内部这样做,那么就可能会调用某个方法,而这个方法所操纵的成员可能还未进行初始化----这肯定会招致灾难。
//: polymorphism/PolyConstructors.java // Constructors and polymorphism // don't produce what you might expect. import static net.mindview.util.Print.*; class Glyph { void draw() { print("Glyph.draw()"); } Glyph() { print("Glyph() before draw()"); draw(); print("Glyph() after draw()"); } } class RoundGlyph extends Glyph { private int radius = 1; RoundGlyph(int r) { radius = r; print("RoundGlyph.RoundGlyph(), radius = " + radius); } void draw() { print("RoundGlyph.draw(), radius = " + radius); } } public class PolyConstructors { public static void main(String[] args) { new RoundGlyph(5); } } /* Output: Glyph() before draw() RoundGlyph.draw(), radius = 0 Glyph() after draw() RoundGlyph.RoundGlyph(), radius = 5 *///:~
初始化的实际过程是:
1)在其他任何事物发生之前,将分配给对象的存储空间初始化为二进制的零。
2)如前所述那样调用基类构造器。此时,调用被覆盖后的draw()方法(要在调用RoundGlyph构造器之前调用),由于步骤1的缘故,我们此时会发现radius的值为0.
3)按照声明的顺序调用成员的初始化方法。
4)调用导出类的构造器主体。
因此,编写构造器有一条有效的准则:“用尽可能简单的方法使对象进入正常的状态;如果可以的话,避免调用其他方法”。在构造器内唯一能够安全调用的那些方法是基类中的final方法(也适用于private方法,它们自动属于final方法)。这些方法不能覆盖,因此也就不会出现上述令人惊讶的问题。
4.协变返回类型
Java SE5中添加了协变返回类型,它表示在导出类中的被覆盖方法可以返回基类方法的返回类型的某种导出类型。
5.用继承进行设计
首先选择“组合”,尤其是不能十分确定应该使用哪一种方式时。组合不会强制我们的程序设计进入继承的层次结构中。而且,组合更加灵活,因为它可以动态选择类型(因此也就选择了行为)。
一条通用的准则是:“用继承表达行为间的差异,并用字段表达状态上的变化”。
纯继承与扩展
采取“纯粹”的方式来创建继承层次结构似乎是最好的方式。也就是说,只有在基类中已经建立的方法才可以在导出类被覆盖。
这被称作是纯粹的“is-a”(是一种)关系,因为一个类的接口已经确定了它应该是什么。继承可以确保所有的导出类具有基类的接口,且绝对不会少。
也可以认为这是一种纯替代,因为导出类可以完全代替基类,而在使用它们时,完全不需要知道关于子类的任何额外信息。
也就是说,基类可以接收发送给导出类的任何消息,因为二者有着完全相同的接口。我们只需从导出类向上转型,永远不需知道正在处理的对象的确切类型。所有这一切,都是通过多态来处理的。
按这种方式考虑,似乎只有纯粹的is-a关系才是唯一明智的做法,而所有其他的设计都只会导致混乱和注定会失败。这其实也是一个陷阱,因为只要开始考虑,就会转向,并发现扩展接口(遗憾的是,extends关键字似乎在怂恿我们这样做)才是解决特定问题的完美方案。这可以称为“is-like-a”(像一个)关系,因为导出类就像是一个基类---它有着相同的基本接口,但是它还具有由额外方法实现的其他特性。
虽然这是一种有用且明智的方法(依赖于具体情况),但是它也有缺点。导出类中接口的扩展部分不能被基类访问,因此,一旦我们向上转型,就不能调用那些新方法。
向下转型与运行时类型识别
在Java中,所有转型都会得到检查!所以即使我们只是进行一次普通的加括弧形式的类型转换,在进入运行期时仍然会对其进行检查,以便保证它的确是我们希望的那种类型。如果不是,就会返回一个ClassCastException(类转型异常)。这种在运行期间对类型进行检查的行为称作“运行时类型识别”(RTTI)。