八、多态
在面向对象的程序设计语言中,多态是继数据抽象和继承之后的第三种基本特征。
多态通过分离做什么和怎么做,从另一角度将接口和实现分离开来。
“封装”通过合并特征和行为来创建新的数据类型。“实现隐藏”则通过将细节“私有化”把接口和实现分离开来。而多态的作用则是消除类型之间的耦合关系。
继承允许将对象视为它自己本身的类型或其基类类型来加以处理。这种能力极其重要,因为它允许将多种类型视为同一类型来处理,而同一份代码也就可以毫无差别地运行在这些不同类型之上了。多态方法调用允许一种类型表现出与其他相似类型之间的区别,只要它们都是从同一基类导出而来的。这种区别是根据方法行为的不同而表现出来的,虽然这些方法都可以通过同一个基类来调用。
1.绑定
①方法调用绑定 将一个方法调用同一个方法主体关联起来被称作绑定。若在程序执行之前进行绑定(由编译器和连接程序实现),叫做前期绑定。
在运行时根据对象的类型进行绑定,叫做后期绑定(多态绑定或运行时绑定)。如果一种语言想实现后期绑定,就必须具有某种机制,以便在运行时能判断对象的类型,从而调用适当的方法。也就是说,编译器一直不知道对象的类型,但是方法调用机制能够找到正确的方法体,并加以调用。后期绑定机制不管怎样都必须在对象中安置某种“类型信息”。
Java中除了static方法和final方法(private方法属于final方法)之外,其他所有的方法都是后期绑定。
②产生正确的行为
Java中所有方法都是通过动态绑定实现多态的。
基类为自它那里继承而来的所有导出类建立了一个公用接口——也就是说,所有导出类都可以做出基类所有的行为。导出来通过覆盖这些行为的定义,来为每种特殊的对象提供单独的行为。
③可扩展性
只与基类通信,这样的程序是可扩展的,因为可以从通用的类型继承出新的数据类型,从而增添一些功能。那些操纵基类接口的方法不需要任何改动就可以应用于新类。
多态是一项让程序员“将改变的事物与未变的事物分离开来”的重要技术。
④缺陷:“覆盖”私有方法
若我们试图这样做:
public class PrivateOverride { private void f() { System.out.println("private f()"); } public static void main(String[] args) { PrivateOverride po = new Derived(); po.f(); } } class Derived extends PrivateOverride { public void f() { System.out.println("public f()"); } } /*Output private f() */
我们所期望的输出是public f(),但是由于private方法被自动认为是final方法(因此是前期绑定,根据引用类型判断),而且对导出类是屏蔽的。因此,在这种情形下,Derived类中的f()方法就是一个全新的方法;既然基类中的f()方法在子类中不可见,因此甚至不能被重载。
结论:只有非private方法才可以被覆盖。在导出类中,对于基类中的private方法,最好采用不同的名字。
⑤缺陷:域与静态方法
只有普通的方法调用可以是多态的。例如,如果你直接访问某个域,这个访问就将在编译器进行解析。任何域访问操作都将由编译器解析,因此不是多态的。
如果某个方法是静态的,它的行为就不具有多态性。静态方法是与类,而并非与单个的对象相关联的。
2.构造器和多态
尽管构造器并不具有多态性(它们实际上是static方法),但还是非常有必要理解构造器怎样通过多态在复杂的层次结构中运作。
※①构造器的调用顺序
基类的构造器总是在导出类的构造过程中被调用,而且按照继承层次逐渐向上链接,以使每个基类的构造器都能得到调用。这样做是有意义的,因为构造器具有一项特殊任务:检查对象是否被正确地构造。导出类只能访问它自己的成员,不能访问基类中的成员(基类成员通常是private类型)。只有基类的构造器具有恰当的知识和权限来对自己的元素进行初始化。因此,必须令所有构造器都得到调用,否则就不可能正确构造完整对象。这正是为什么编译器要强制每个导出类都必须调用构造器的原因。在导出类的构造器主体中,如果没有明确指定调用某个基类构造器,它都会“默默”地调用默认构造器。如果不存在默认构造器,编译器就会报错(若某个类没有构造器,编译器会自动合成出一个默认构造器)。
看下面这个例子,它展示组合、继承以及多态的构建顺序:
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 ProtableLunch extends Lunch { ProtableLunch() { System.out.println("ProtableLunch()"); } } public class Sandwich extends ProtableLunch { 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(); } } /*Output Meal() Lunch() PortableLunch() Bread() Cheese() Lettuce() Sandwich() */
上面的输出结果说明调用构造器要遵循下面的顺序:
1)调用基类构造器。这个步骤会不断地反复递归下去,首先是构造这种层次结构的根,然后是下一层导出类,等等,直到最低层的导出类。
2)按声明顺序调用成员的初始化方法。
3)调用导出类构造器的主体。
在构造器内部,我们必须确保所要使用的成员都已经构建完成。
②继承与清理
如果我们有其他作为垃圾回收一部分的特殊清理动作,就必须在导出类中覆盖dispose()方法。当覆盖被继承类的dispose()方法时,务必记住调用基类版本dispose()方法;否则基类的清理动作就不会发生。
销毁的顺序应该与初始化顺序相反。
③构造器内部的多态方法的行为
构造器调用的层次结构带来了一个有趣的两难问题——如果在一个构造器的内部调用正在构造的对象的某个动态绑定方法,那会发生什么情况呢?
在一般的方法内部,动态绑定的调用时在运行时才决定的,因为对象无法知道它是属于方法所在的那个类,还是属于那个类的导出类。
如果要调用构造器内部的一个动态绑定方法,就要用到那个方法的被覆盖后的定义。然而,这个调用的效果可能难以预料,因为被覆盖的方法在对象被完全构造前就会被调用,这可能会造成一些难以发现的隐藏错误。
上面介绍的初始化顺序并不完整,而这正是解决这个问题的关键。初始化的实际过程是:
1) 在其他任何事物发生之前,将分配给对象的存储空间初始化成二进制的零。
2) 如前述那样调用基类构造器。
3) 按照声明的顺序调用成员的初始化方法。
4) 调用导出类的构造器主体。
这样的优点是所有东西都至少初始化成“零”。
编写构造器时有一条有效的准则:“用尽可能简单的方法使对象进入正常状态;如果可以的话,避免调用其他方法。”在构造器中唯一能够安全调用的那些方法是基类中的final方法(也适用于private方法)。
3.协变返回类型
在Java SE5中添加了协变返回类型,它表示导出类中的被覆盖方法可以返回基类方法的返回类型的某种导出类型:
class Grain { public String toString() { return "Grain"; } } class Wheat extends Grain { public String toString() { return " Wheat" } } class Mill { Grain process() { return new Wheat(); } } class WheatMill extends Mill { Wheat process() { return new Wheat(); } } 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); } } /*Output Grain Wheat */
4.用继承进行设计
更好的方式是首先选择“组合”。组合更加灵活,因为它可以动态选择类型(因此就选择了行为);相反,继承在编译时就需要知道确切类型。
class Actor { public void act() {} } class HappyActor extends Actor { public void act() { System.out.println("HappyActor"); } } class SadActor extends Actor { public void act() { System.out.println("SadActor"); } } class Stage { private Actor actor = new HappyActor(); public void change() { actor = new SadActor(); } public void performPlay() { actor.act(); } } public class Transmogrify { public static void main(String[] args) { Stage stage = new Stage(); stage.performPlay(); stage.change(); stage.performPlay(); } } /*Output HappyActor SadActor */
Stage对象含有一个对Actor的引用,并可以在运行时改变实际对象,然后performPlay()产生的行为也随之改变。这样一来,我们在运行期间获得了动态灵活性(这也称为状态模式)。
①纯继承与扩展
纯继承是“is-a”关系。导出类具有和基类一样的接口,且基类可以接收发送给导出类的任何信息。
扩展是“is-like-a”关系,因为导出类就像是一个基类——它有着相同的基本接口,但是它还具有由额外方法实现的其他特性。导出类中接口的扩展部分不能被基类访问,因此,一旦我们向上转型,就不能调用那些新方法。
②向下转型与运行时类型识别
由于向上转型会丢失具体的类型信息,所以我们就想,通过向下转型应该能够获取类型信息,在Java语言中,所有转型都会得到检查。如果类型不符,就会返回一个ClassCastException。这种在运行期间对类型进行检查的行为称作“运行时类型识别”(RTTI)。
5.总结 多态意味着“不同的形式”。在面向对象的程序设计中,我们持有从基类继承而来的相同接口,以及使用该接口的不同形式:不同版本的动态绑定方法。