在面向对象的程序设计语言中,多态是继数据抽象和继承之后的第三种基本特征。
多态通过分离做什么和怎么做,从另一角度将接口和实现分离开来。多态不但能够改善代码的组织结构和可读性,还能创建可扩展的程序。
“封装”通过合并特征和行为来创建新的数据类型。“实现隐藏”则通过细节“私有化”把接口和实现分离开来。这种类型的组织机制对于那些拥有过程化程序设计背景的人来说容易理解。而多态的作用则是消除类型之间的耦合关系。继承运行将对象视为它自己本身的类型或其基类型来处理。多态方法调用允许一种类型表现出与其他相似类型之间的区别,只要它们都是从同一基类导出而来的。这种区别是根据方法行为的不同而表示出来的,虽然这些方法都可以通过同一个基类来调用。
多态,也被称作动态绑定、后期绑定或运行时绑定。
我们只写这样一个简单方法,它仅接受基类作为参数,而不是那些特殊的导出类。我们不管导出类的存在,编写的代码只是与基类打交道。
这正是多态所允许的。
将一个方法调用同一个方法主题关联起来被称作绑定。若在程序执行前进行绑定(如果有的话,由编译器和连接程序实现),叫做前期绑定。
后期绑定含义就是在运行时根据对象的类型进行绑定。后期绑定也叫做动态绑定或运行时绑定。如果一种语言想实现后期绑定,就必须具有某种机制以便在运行时能判断对象的类型,从而调用恰当的方法。
Java中除了static方法和final方法(private方法属于final方法)之外,其他所有的方法都是后期绑定。
声明final还有一点重要原因:有效“关闭”动态绑定,或者说,告诉编译器不需要对它进行动态绑定。但最好根据设计来决定是否使用final,而不是处于试图提高性能的目的。
在编译时,编译器不需要获得任何特殊信息就能进行正确的调用。
只有非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.field,必须显式地指明super.field。
尽管这看起来好像会成为一个容易令人混淆的问题,但是在实践中,它实际上从来不会发生。首先,你通常会将所有的域都设置成private,因此你不能直接访问他们,其副作用是只能通过方法来访问。另外,你可能不会对基类中的域和导出类中的域赋予相同的名字,因为这种做法容易令人混淆。
如果某个方法是静态的,它的行为就不具有多态性。
静态方法是与类,而并非与单个对象相关联的。
尽管构造器并不具有多态性(它们实际上是static方法,只不过该static声明是隐式的),但还是非常有必要理解构造器怎样通过多态在复杂的层次结构中运作,这一理解将有助于大家避免一些令人不快的困扰。
基类的构造器总是在导出类的构造过程中被调用,而且按照继承层次逐渐向上链接,以使每个基类的构造器都能得到调用。编译器强制每个导出类部分都必须调用构造器的原因:基类成员是private,只有基类构造器具有恰当的知识和权限来对自己的元素进行初始化,因此,必须令所有构造器都得到调用,否则就不可能正确构造完整对象。在导出类的构造器主体中,如果没有明确指定调用某个基类构造器,它就会默默地调用默认构造器,如果不存在默认构造器,编译器就会报错。
复杂对象调用构造器遵照下面顺序:
但遗憾的是,这种做法并不适用所有的情况,这一点我们会在下一节看到。
万一某个子对象要依赖于其他对象,销毁的顺序应该和初始化顺序相反。对于字段,则意味着与声明的顺序相反(因为字段的初始化是按照声明的顺序进行的)。对于基类(遵循C++中析构函数的形式),应该首先对其导出类进行清理,然后才是基类。这是因为导出类的清理可能会调用基类的某些方法。
引用计数来跟踪仍旧访问着共享对象的对象数量
如果一个构造器内部调用正在构造对象的某个动态绑定方法,那会发生什么情况呢?
如果要调用构造器内部的一个动态绑定方法,就要用到那个方法的被覆盖后的定义。然而,这个调用的效果可能相当难于预料,因为被覆盖的方法在对象完全构造之前就会被调用。这可能会造成一些难于发现的隐藏错误。
从概念上讲,构造器的工作实际上是创建对象(这并非是一件平常的工作)。在任何构造器内部,整个对象可能只是部分形成——我们只知道基类对象已经进行初始化。如果构造器只是在构建对象过程中的一个步骤,并且该对象所属的类是从这个构造器所属的类导出的,那么导出部分在当前构造器正在被调用的时刻仍然是没有被初始化的。然而,一个动态绑定方法的调用却会向外深入到继承层次结构内部,它可以调用导出类里的方法。如果我们是在构造器内部这样做,那么就可能会调用某个方法,而这个方法所操纵的成员可能还未进行初始化——这肯定会招致灾难。
初始化的实际过程是:
这样做有一个优点,那就是所有东西都至少初始化成零(或者是某些特殊数据类型中与“零”等价的值),而不是仅仅留作垃圾。其中包括通过“组合”而嵌入一个类的内部对象引用,其值是null,所以如果忘记为该引用进行初始化,就会在运行时出现异常。查看输出结果时,会发现所有东西的值都会是零,这通常也是发现问题的证据。
另一方面,我们应该对这个程序的结果相当震惊。在逻辑方面,我们做的已经十分完美,而它的行为却不可思议地错了,而且编译器也没有报错。(在这种情况下,C++语言会产生更加合理的行为。)诸如此类的错误会很容易被人忽略,而且要花很长时间才能发现。
因此,编写构造器时有一条有效的准则:“用尽可能简单的方法使对象进入正常状态;如果可以的话,避免调用其他方法”。在构造器内唯一能够安全调用的那些方法是基类的fianl方法(也适用于private方法,它们自动属于final方法)。这些方法不能被覆盖,因此也就不会出现上述令人惊讶的问题。
Java SE5中添加了协变返回类型,它表示导出类中的被覆盖方法可以返回基类方法的返回类型的某种导出类型。
Java SE5与Java较早版本之间的主要差异就是较早的版本将强制process()的覆盖版本必须返回Grain,而不能返回Wheat,尽管Wheat是从Grain导出的,因而也应该是一种合法的返回类型。协变返回类型允许返回更具体的Wheat类型。
更好的方式是首先选择“组合”,尤其是不能十分确定应该使用哪一种方式时。组合不会强制我们的程序设计进入继承的层次结构中。而且,组合更加灵活,因为它可以动态选择类型(因此也就选择了行为);相反,继承在编译时就需要知道确切类型。
状态模式
一条通用的准则是:“用继承表达行为间的差异,并用字段表达状态上的变化”。
采用“纯粹”的方式来创建继承层次结构似乎是最好的方式。也就是说,只有在基类中已经建立的方法才可以在导出类中被覆盖。
纯替代,因为导出类可以完全代替基类,而在使用它们时,完全不需要知道关于子类的任何额外信息。
由于向上转型会丢失具体的类型信息,所以我们就想,通过向下转型应该能够获取类型信息。然而,我们知道向上转型是安全的,因为基类不会具有大于导出类的接口。但是对于向下转型,我们无法知道确实是哪一个子类。
在某些程序设计语言(如C++)中,我们必须执行一个特殊的操作来获得安全的向下转型。但是在Java语言中,所有转型都会得到检查!所以即使我们只是进行一次普通的加括号形式的类型转换,在进入运行期时仍然会对其进行检查,以便保证它的确是我们希望的那种类型。如果不是,就会返回一个ClassCastException(类转型异常)。这种在运行期间对类型进行检查的行为称作“运行时类型识别”(RTTI)。