第6章 类再生
“Java引人注目的一项特性是代码的重复使用或者再生。但最具革命意义的是,除代码的复制和修改以外,我们还能做多得多的其他事情。”
在象C那样的程序化语言里,代码的重复使用早已可行,但效果不是特别显著。与Java的其他地方一样,这个方案解决的也是与类有关的问题。我们通过创建新类来重复使用代码,但却用不着重新创建,可以直接使用别人已建好并调试好的现成类。
但这样做必须保证不会干扰原有的代码。在这一章里,我们将介绍两个达到这一目标的方法。第一个最简单:在新类里简单地创建原有类的对象。我们把这种方法叫作“合成”,因为新类由现有类的对象合并而成。我们只是简单地重复利用代码的功能,而不是采用它的形式。
第二种方法则显得稍微有些技巧。它创建一个新类,将其作为现有类的一个“类型”。我们可以原样采取现有类的形式,并在其中加入新代码,同时不会对现有的类产生影响。这种魔术般的行为叫作“继承”(Inheritance),涉及的大多数工作都是由编译器完成的。对于面向对象的程序设计,“继承”是最重要的基础概念之一。它对我们下一章要讲述的内容会产生一些额外的影响。
对于合成与继承这两种方法,大多数语法和行为都是类似的(因为它们都要根据现有的类型生成新类型)。在本章,我们将深入学习这些代码再生或者重复使用的机制。
6.1 合成的语法
就以前的学习情况来看,事实上已进行了多次“合成”操作。为进行合成,我们只需在新类里简单地置入对象句柄即可。举个例子来说,假定需要在一个对象里容纳几个String对象、两种基本数据类型以及属于另一个类的一个对象。对于非基本类型的对象来说,只需将句柄置于新类即可;而对于基本数据类型来说,则需在自己的类中定义它们。如下所示(若执行该程序时有麻烦,请参见第3章3.1.2小节“赋值”):
//: SprinklerSystem.java // Composition for code reuse package c06; class WaterSource { private String s; WaterSource() { System.out.println("WaterSource()"); s = new String("Constructed"); } public String toString() { return s; } } public class SprinklerSystem { private String valve1, valve2, valve3, valve4; WaterSource source; int i; float f; void print() { System.out.println("valve1 = " + valve1); System.out.println("valve2 = " + valve2); System.out.println("valve3 = " + valve3); System.out.println("valve4 = " + valve4); System.out.println("i = " + i); System.out.println("f = " + f); System.out.println("source = " + source); } public static void main(String[] args) { SprinklerSystem x = new SprinklerSystem(); x.print(); } } ///:~
WaterSource内定义的一个方法是比较特别的:toString()。大家不久就会知道,每种非基本类型的对象都有一个toString()方法。若编译器本来希望一个String,但却获得某个这样的对象,就会调用这个方法。所以在下面这个表达式中:
System.out.println("source = " + source) ;
编译器会发现我们试图向一个WaterSource添加一个String对象("source =")。这对它来说是不可接受的,因为我们只能将一个字串“添加”到另一个字串,所以它会说:“我要调用toString(),把source转换成字串!”经这样处理后,它就能编译两个字串,并将结果字串传递给一个System.out.println()。每次随同自己创建的一个类允许这种行为的时候,都只需要写一个toString()方法。
如果不深究,可能会草率地认为编译器会为上述代码中的每个句柄都自动构造对象(由于Java的安全和谨慎的形象)。例如,可能以为它会为WaterSource调用默认构建器,以便初始化source。打印语句的输出事实上是:
valve1 = null valve2 = null valve3 = null valve4 = null i = 0 f = 0.0 source = null
在类内作为字段使用的基本数据会初始化成零,就象第2章指出的那样。但对象句柄会初始化成null。而且假若试图为它们中的任何一个调用方法,就会产生一次“违例”。这种结果实际是相当好的(而且很有用),我们可在不丢弃一次违例的前提下,仍然把它们打印出来。
编译器并不只是为每个句柄创建一个默认对象,因为那样会在许多情况下招致不必要的开销。如希望句柄得到初始化,可在下面这些地方进行:
(1) 在对象定义的时候。这意味着它们在构建器调用之前肯定能得到初始化。
(2) 在那个类的构建器中。
(3) 紧靠在要求实际使用那个对象之前。这样做可减少不必要的开销——假如对象并不需要创建的话。
下面向大家展示了所有这三种方法:
//: Bath.java // Constructor initialization with composition class Soap { private String s; Soap() { System.out.println("Soap()"); s = new String("Constructed"); } public String toString() { return s; } } public class Bath { private String // Initializing at point of definition: s1 = new String("Happy"), s2 = "Happy", s3, s4; Soap castille; int i; float toy; Bath() { System.out.println("Inside Bath()"); s3 = new String("Joy"); i = 47; toy = 3.14f; castille = new Soap(); } void print() { // Delayed initialization: if(s4 == null) s4 = new String("Joy"); System.out.println("s1 = " + s1); System.out.println("s2 = " + s2); System.out.println("s3 = " + s3); System.out.println("s4 = " + s4); System.out.println("i = " + i); System.out.println("toy = " + toy); System.out.println("castille = " + castille); } public static void main(String[] args) { Bath b = new Bath(); b.print(); } } ///:~
请注意在Bath构建器中,在所有初始化开始之前执行了一个语句。如果不在定义时进行初始化,仍然不能保证能在将一条消息发给一个对象句柄之前会执行任何初始化——除非出现不可避免的运行期违例。
下面是该程序的输出:
Inside Bath() Soap() s1 = Happy s2 = Happy s3 = Joy s4 = Joy i = 47 toy = 3.14 castille = Constructed
调用print()时,它会填充s4,使所有字段在使用之前都获得正确的初始化。
6.2 继承的语法
继承与Java(以及其他OOP语言)非常紧密地结合在一起。我们早在第1章就为大家引入了继承的概念,并在那章之后到本章之前的各章里不时用到,因为一些特殊的场合要求必须使用继承。除此以外,创建一个类时肯定会进行继承,因为若非如此,会从Java的标准根类Object中继承。
用于合成的语法是非常简单且直观的。但为了进行继承,必须采用一种全然不同的形式。需要继承的时候,我们会说:“这个新类和那个旧类差不多。”为了在代码里表面这一观念,需要给出类名。但在类主体的起始花括号之前,需要放置一个关键字extends,在后面跟随“基础类”的名字。若采取这种做法,就可自动获得基础类的所有数据成员以及方法。下面是一个例子:
//: Detergent.java // Inheritance syntax & properties class Cleanser { private String s = new String("Cleanser"); public void append(String a) { s += a; } public void dilute() { append(" dilute()"); } public void apply() { append(" apply()"); } public void scrub() { append(" scrub()"); } public void print() { System.out.println(s); } public static void main(String[] args) { Cleanser x = new Cleanser(); x.dilute(); x.apply(); x.scrub(); x.print(); } } public class Detergent extends Cleanser { // Change a method: public void scrub() { append(" Detergent.scrub()"); super.scrub(); // Call base-class version } // Add methods to the interface: public void foam() { append(" foam()"); } // Test the new class: public static void main(String[] args) { Detergent x = new Detergent(); x.dilute(); x.apply(); x.scrub(); x.foam(); x.print(); System.out.println("Testing base class:"); Cleanser.main(args); } } ///:~
这个例子向大家展示了大量特性。首先,在Cleanser append()方法里,字串同一个s连接起来。这是用“+=”运算符实现的。同“+”一样,“+=”被Java用于对字串进行“过载”处理。
其次,无论Cleanser还是Detergent都包含了一个main()方法。我们可为自己的每个类都创建一个main()。通常建议大家象这样进行编写代码,使自己的测试代码能够封装到类内。即便在程序中含有数量众多的类,但对于在命令行请求的public类,只有main()才会得到调用。所以在这种情况下,当我们使用“java Detergent”的时候,调用的是Degergent.main()——即使Cleanser并非一个public类。采用这种将main()置入每个类的做法,可方便地为每个类都进行单元测试。而且在完成测试以后,毋需将main()删去;可把它保留下来,用于以后的测试。
在这里,大家可看到Deteregent.main()对Cleanser.main()的调用是明确进行的。
需要着重强调的是Cleanser中的所有类都是public属性。请记住,倘若省略所有访问指示符,则成员默认为“友好的”。这样一来,就只允许对包成员进行访问。在这个包内,任何人都可使用那些没有访问指示符的方法。例如,Detergent将不会遇到任何麻烦。然而,假设来自另外某个包的类准备继承Cleanser,它就只能访问那些public成员。所以在计划继承的时候,一个比较好的规则是将所有字段都设为private,并将所有方法都设为public(protected成员也允许衍生出来的类访问它;以后还会深入探讨这一问题)。当然,在一些特殊的场合,我们仍然必须作出一些调整,但这并不是一个好的做法。
注意Cleanser在它的接口中含有一系列方法:append(),dilute(),apply(),scrub()以及print()。由于Detergent是从Cleanser衍生出来的(通过extends关键字),所以它会自动获得接口内的所有这些方法——即使我们在Detergent里并未看到对它们的明确定义。这样一来,就可将继承想象成“对接口的重复利用”或者“接口的再生”(以后的实施细节可以自由设置,但那并非我们强调的重点)。
正如在scrub()里看到的那样,可以获得在基础类里定义的一个方法,并对其进行修改。在这种情况下,我们通常想在新版本里调用来自基础类的方法。但在scrub()里,不可只是简单地发出对scrub()的调用。那样便造成了递归调用,我们不愿看到这一情况。为解决这个问题,Java提供了一个super关键字,它引用当前类已从中继承的一个“超类”(Superclass)。所以表达式super.scrub()调用的是方法scrub()的基础类版本。
进行继承时,我们并不限于只能使用基础类的方法。亦可在衍生出来的类里加入自己的新方法。这时采取的做法与在普通类里添加其他任何方法是完全一样的:只需简单地定义它即可。extends关键字提醒我们准备将新方法加入基础类的接口里,对其进行“扩展”。foam()便是这种做法的一个产物。
在Detergent.main()里,我们可看到对于Detergent对象,可调用Cleanser以及Detergent内所有可用的方法(如foam())。
6.2.1 初始化基础类
由于这儿涉及到两个类——基础类及衍生类,而不再是以前的一个,所以在想象衍生类的结果对象时,可能会产生一些迷惑。从外部看,似乎新类拥有与基础类相同的接口,而且可包含一些额外的方法和字段。但继承并非仅仅简单地复制基础类的接口了事。创建衍生类的一个对象时,它在其中包含了基础类的一个“子对象”。这个子对象就象我们根据基础类本身创建了它的一个对象。从外部看,基础类的子对象已封装到衍生类的对象里了。
当然,基础类子对象应该正确地初始化,而且只有一种方法能保证这一点:在构建器中执行初始化,通过调用基础类构建器,后者有足够的能力和权限来执行对基础类的初始化。在衍生类的构建器中,Java会自动插入对基础类构建器的调用。下面这个例子向大家展示了对这种三级继承的应用:
//: Cartoon.java // Constructor calls during inheritance class Art { Art() { System.out.println("Art constructor"); } } class Drawing extends Art { Drawing() { System.out.println("Drawing constructor"); } } public class Cartoon extends Drawing { Cartoon() { System.out.println("Cartoon constructor"); } public static void main(String[] args) { Cartoon x = new Cartoon(); } } ///:~
该程序的输出显示了自动调用:
Art constructor
Drawing constructor
Cartoon constructor
可以看出,构建是在基础类的“外部”进行的,所以基础类会在衍生类访问它之前得到正确的初始化。
即使没有为Cartoon()创建一个构建器,编译器也会为我们自动合成一个默认构建器,并发出对基础类构建器的调用。
1. 含有自变量的构建器
上述例子有自己默认的构建器;也就是说,它们不含任何自变量。编译器可以很容易地调用它们,因为不存在具体传递什么自变量的问题。如果类没有默认的自变量,或者想调用含有一个自变量的某个基础类构建器,必须明确地编写对基础类的调用代码。这是用super关键字以及适当的自变量列表实现的,如下所示:
//: Chess.java // Inheritance, constructors and arguments class Game { Game(int i) { System.out.println("Game constructor"); } } class BoardGame extends Game { BoardGame(int i) { super(i); System.out.println("BoardGame constructor"); } } public class Chess extends BoardGame { Chess() { super(11); System.out.println("Chess constructor"); } public static void main(String[] args) { Chess x = new Chess(); } } ///:~
如果不调用BoardGames()内的基础类构建器,编译器就会报告自己找不到Games()形式的一个构建器。除此以外,在衍生类构建器中,对基础类构建器的调用是必须做的第一件事情(如操作失当,编译器会向我们指出)。
2. 捕获基本构建器的违例
正如刚才指出的那样,编译器会强迫我们在衍生类构建器的主体中首先设置对基础类构建器的调用。这意味着在它之前不能出现任何东西。正如大家在第9章会看到的那样,这同时也会防止衍生类构建器捕获来自一个基础类的任何违例事件。显然,这有时会为我们造成不便。
6.3 合成与继承的结合
许多时候都要求将合成与继承两种技术结合起来使用。下面这个例子展示了如何同时采用继承与合成技术,从而创建一个更复杂的类,同时进行必要的构建器初始化工作:
//: PlaceSetting.java // Combining composition & inheritance class Plate { Plate(int i) { System.out.println("Plate constructor"); } } class DinnerPlate extends Plate { DinnerPlate(int i) { super(i); System.out.println( "DinnerPlate constructor"); } } class Utensil { Utensil(int i) { System.out.println("Utensil constructor"); } } class Spoon extends Utensil { Spoon(int i) { super(i); System.out.println("Spoon constructor"); } } class Fork extends Utensil { Fork(int i) { super(i); System.out.println("Fork constructor"); } } class Knife extends Utensil { Knife(int i) { super(i); System.out.println("Knife constructor"); } } // A cultural way of doing something: class Custom { Custom(int i) { System.out.println("Custom constructor"); } } public class PlaceSetting extends Custom { Spoon sp; Fork frk; Knife kn; DinnerPlate pl; PlaceSetting(int i) { super(i + 1); sp = new Spoon(i + 2); frk = new Fork(i + 3); kn = new Knife(i + 4); pl = new DinnerPlate(i + 5); System.out.println( "PlaceSetting constructor"); } public static void main(String[] args) { PlaceSetting x = new PlaceSetting(9); } } ///:~
尽管编译器会强迫我们对基础类进行初始化,并要求我们在构建器最开头做这一工作,但它并不会监视我们是否正确初始化了成员对象。所以对此必须特别加以留意。
6.3.1 确保正确的清除
Java不具备象C++的“破坏器”那样的概念。在C++中,一旦破坏(清除)一个对象,就会自动调用破坏器方法。之所以将其省略,大概是由于在Java中只需简单地忘记对象,不需强行破坏它们。垃圾收集器会在必要的时候自动回收内存。
垃圾收集器大多数时候都能很好地工作,但在某些情况下,我们的类可能在自己的存在时期采取一些行动,而这些行动要求必须进行明确的清除工作。正如第4章已经指出的那样,我们并不知道垃圾收集器什么时候才会显身,或者说不知它何时会调用。所以一旦希望为一个类清除什么东西,必须写一个特别的方法,明确、专门地来做这件事情。同时,还要让客户程序员知道他们必须调用这个方法。而在所有这一切的后面,就如第9章(违例控制)要详细解释的那样,必须将这样的清除代码置于一个finally从句中,从而防范任何可能出现的违例事件。
下面介绍的是一个计算机辅助设计系统的例子,它能在屏幕上描绘图形:
//: CADSystem.java // Ensuring proper cleanup import java.util.*; class Shape { Shape(int i) { System.out.println("Shape constructor"); } void cleanup() { System.out.println("Shape cleanup"); } } class Circle extends Shape { Circle(int i) { super(i); System.out.println("Drawing a Circle"); } void cleanup() { System.out.println("Erasing a Circle"); super.cleanup(); } } class Triangle extends Shape { Triangle(int i) { super(i); System.out.println("Drawing a Triangle"); } void cleanup() { System.out.println("Erasing a Triangle"); super.cleanup(); } } class Line extends Shape { private int start, end; Line(int start, int end) { super(start); this.start = start; this.end = end; System.out.println("Drawing a Line: " + start + ", " + end); } void cleanup() { System.out.println("Erasing a Line: " + start + ", " + end); super.cleanup(); } } public class CADSystem extends Shape { private Circle c; private Triangle t; private Line[] lines = new Line[10]; CADSystem(int i) { super(i + 1); for(int j = 0; j < 10; j++) lines[j] = new Line(j, j*j); c = new Circle(1); t = new Triangle(1); System.out.println("Combined constructor"); } void cleanup() { System.out.println("CADSystem.cleanup()"); t.cleanup(); c.cleanup(); for(int i = 0; i < lines.length; i++) lines[i].cleanup(); super.cleanup(); } public static void main(String[] args) { CADSystem x = new CADSystem(47); try { // Code and exception handling... } finally { x.cleanup(); } } } ///:~
这个系统中的所有东西都属于某种Shape(几何形状)。Shape本身是一种Object(对象),因为它是从根类明确继承的。每个类都重新定义了Shape的cleanup()方法,同时还要用super调用那个方法的基础类版本。尽管对象存在期间调用的所有方法都可负责做一些要求清除的工作,但对于特定的Shape类——Circle(圆)、Triangle(三角形)以及Line(直线),它们都拥有自己的构建器,能完成“作图”(draw)任务。每个类都有它们自己的cleanup()方法,用于将非内存的东西恢复回对象存在之前的景象。
在main()中,可看到两个新关键字:try和finally。我们要到第9章才会向大家正式引荐它们。其中,try关键字指出后面跟随的块(由花括号定界)是一个“警戒区”。也就是说,它会受到特别的待遇。其中一种待遇就是:该警戒区后面跟随的finally从句的代码肯定会得以执行——不管try块到底存不存在(通过违例控制技术,try块可有多种不寻常的应用)。在这里,finally从句的意思是“总是为x调用cleanup(),无论会发生什么事情”。这些关键字将在第9章进行全面、完整的解释。
在自己的清除方法中,必须注意对基础类以及成员对象清除方法的调用顺序——假若一个子对象要以另一个为基础。通常,应采取与C++编译器对它的“破坏器”采取的同样的形式:首先完成与类有关的所有特殊工作(可能要求基础类元素仍然可见),然后调用基础类清除方法,就象这儿演示的那样。
许多情况下,清除可能并不是个问题;只需让垃圾收集器尽它的职责即可。但一旦必须由自己明确清除,就必须特别谨慎,并要求周全的考虑。
1. 垃圾收集的顺序
不能指望自己能确切知道何时会开始垃圾收集。垃圾收集器可能永远不会得到调用。即使得到调用,它也可能以自己愿意的任何顺序回收对象。除此以外,Java 1.0实现的垃圾收集器机制通常不会调用finalize()方法。除内存的回收以外,其他任何东西都最好不要依赖垃圾收集器进行回收。若想明确地清除什么,请制作自己的清除方法,而且不要依赖finalize()。然而正如以前指出的那样,可强迫Java1.1调用所有收尾模块(Finalizer)。
6.3.2 名字的隐藏
只有C++程序员可能才会惊讶于名字的隐藏,因为它的工作原理与在C++里是完全不同的。如果Java基础类有一个方法名被“过载”使用多次,在衍生类里对那个方法名的重新定义就不会隐藏任何基础类的版本。所以无论方法在这一级还是在一个基础类中定义,过载都会生效:
//: Hide.java // Overloading a base-class method name // in a derived class does not hide the // base-class versions class Homer { char doh(char c) { System.out.println("doh(char)"); return 'd'; } float doh(float f) { System.out.println("doh(float)"); return 1.0f; } } class Milhouse {} class Bart extends Homer { void doh(Milhouse m) {} } class Hide { public static void main(String[] args) { Bart b = new Bart(); b.doh(1); // doh(float) used b.doh('x'); b.doh(1.0f); b.doh(new Milhouse()); } } ///:~
正如下一章会讲到的那样,很少会用与基础类里完全一致的签名和返回类型来覆盖同名的方法,否则会使人感到迷惑(这正是C++不允许那样做的原因,所以能够防止产生一些不必要的错误)。
6.4 到底选择合成还是继承
无论合成还是继承,都允许我们将子对象置于自己的新类中。大家或许会奇怪两者间的差异,以及到底该如何选择。
如果想利用新类内部一个现有类的特性,而不想使用它的接口,通常应选择合成。也就是说,我们可嵌入一个对象,使自己能用它实现新类的特性。但新类的用户会看到我们已定义的接口,而不是来自嵌入对象的接口。考虑到这种效果,我们需在新类里嵌入现有类的private对象。
有些时候,我们想让类用户直接访问新类的合成。也就是说,需要将成员对象的属性变为public。成员对象会将自身隐藏起来,所以这是一种安全的做法。而且在用户知道我们准备合成一系列组件时,接口就更容易理解。car(汽车)对象便是一个很好的例子:
//: Car.java // Composition with public objects class Engine { public void start() {} public void rev() {} public void stop() {} } class Wheel { public void inflate(int psi) {} } class Window { public void rollup() {} public void rolldown() {} } class Door { public Window window = new Window(); public void open() {} public void close() {} } public class Car { public Engine engine = new Engine(); public Wheel[] wheel = new Wheel[4]; public Door left = new Door(), right = new Door(); // 2-door Car() { for(int i = 0; i < 4; i++) wheel[i] = new Wheel(); } public static void main(String[] args) { Car car = new Car(); car.left.window.rollup(); car.wheel[0].inflate(72); } } ///:~
由于汽车的装配是故障分析时需要考虑的一项因素(并非只是基础设计简单的一部分),所以有助于客户程序员理解如何使用类,而且类创建者的编程复杂程度也会大幅度降低。
如选择继承,就需要取得一个现成的类,并制作它的一个特殊版本。通常,这意味着我们准备使用一个常规用途的类,并根据特定的需求对其进行定制。只需稍加想象,就知道自己不能用一个车辆对象来合成一辆汽车——汽车并不“包含”车辆;相反,它“属于”车辆的一种类别。“属于”关系是用继承来表达的,而“包含”关系是用合成来表达的。
6.5 protected
现在我们已理解了继承的概念,protected这个关键字最后终于有了意义。在理想情况下,private成员随时都是“私有”的,任何人不得访问。但在实际应用中,经常想把某些东西深深地藏起来,但同时允许访问衍生类的成员。protected关键字可帮助我们做到这一点。它的意思是“它本身是私有的,但可由从这个类继承的任何东西或者同一个包内的其他任何东西访问”。也就是说,Java中的protected会成为进入“友好”状态。
我们采取的最好的做法是保持成员的private状态——无论如何都应保留对基 础的实施细节进行修改的权利。在这一前提下,可通过protected方法允许类的继承者进行受到控制的访问:
//: Orc.java // The protected keyword import java.util.*; class Villain { private int i; protected int read() { return i; } protected void set(int ii) { i = ii; } public Villain(int ii) { i = ii; } public int value(int m) { return m*i; } } public class Orc extends Villain { private int j; public Orc(int jj) { super(jj); j = jj; } public void change(int x) { set(x); } } ///:~
可以看到,change()拥有对set()的访问权限,因为它的属性是protected(受到保护的)。
6.6 累积开发
继承的一个好处是它支持“累积开发”,允许我们引入新的代码,同时不会为现有代码造成错误。这样可将新错误隔离到新代码里。通过从一个现成的、功能性的类继承,同时增添成员新的数据成员及方法(并重新定义现有方法),我们可保持现有代码原封不动(另外有人也许仍在使用它),不会为其引入自己的编程错误。一旦出现错误,就知道它肯定是由于自己的新代码造成的。这样一来,与修改现有代码的主体相比,改正错误所需的时间和精力就可以少很多。
类的隔离效果非常好,这是许多程序员事先没有预料到的。甚至不需要方法的源代码来实现代码的再生。最多只需要导入一个包(这对于继承和合并都是成立的)。
大家要记住这样一个重点:程序开发是一个不断递增或者累积的过程,就象人们学习知识一样。当然可根据要求进行尽可能多的分析,但在一个项目的设计之初,谁都不可能提前获知所有的答案。如果能将自己的项目看作一个有机的、能不断进步的生物,从而不断地发展和改进它,就有望获得更大的成功以及更直接的反馈。
尽管继承是一种非常有用的技术,但在某些情况下,特别是在项目稳定下来以后,仍然需要从新的角度考察自己的类结构,将其收缩成一个更灵活的结构。请记住,继承是对一种特殊关系的表达,意味着“这个新类属于那个旧类的一种类型”。我们的程序不应纠缠于一些细树末节,而应着眼于创建和操作各种类型的对象,用它们表达出来自“问题空间”的一个模型。
6.7 上溯造型
继承最值得注意的地方就是它没有为新类提供方法。继承是对新类和基础类之间的关系的一种表达。可这样总结该关系:“新类属于现有类的一种类型”。
这种表达并不仅仅是对继承的一种形象化解释,继承是直接由语言提供支持的。作为一个例子,大家可考虑一个名为Instrument的基础类,它用于表示乐器;另一个衍生类叫作Wind。由于继承意味着基础类的所有方法亦可在衍生出来的类中使用,所以我们发给基础类的任何消息亦可发给衍生类。若Instrument类有一个play()方法,则Wind设备也会有这个方法。这意味着我们能肯定地认为一个Wind对象也是Instrument的一种类型。下面这个例子揭示出编译器如何提供对这一概念的支持:
//: Wind.java // Inheritance & upcasting import java.util.*; class Instrument { public void play() {} static void tune(Instrument i) { // ... i.play(); } } // Wind objects are instruments // because they have the same interface: class Wind extends Instrument { public static void main(String[] args) { Wind flute = new Wind(); Instrument.tune(flute); // Upcasting } } ///:~
这个例子中最有趣的无疑是tune()方法,它能接受一个Instrument句柄。但在Wind.main()中,tune()方法是通过为其赋予一个Wind句柄来调用的。由于Java对类型检查特别严格,所以大家可能会感到很奇怪,为什么接收一种类型的方法也能接收另一种类型呢?但是,我们一定要认识到一个Wind对象也是一个Instrument对象。而且对于不在Wind中的一个Instrument(乐器),没有方法可以由tune()调用。在tune()中,代码适用于Instrument以及从Instrument衍生出来的任何东西。在这里,我们将从一个Wind句柄转换成一个Instrument句柄的行为叫作“上溯造型”。
6.7.1 何谓“上溯造型”?
之所以叫作这个名字,除了有一定的历史原因外,也是由于在传统意义上,类继承图的画法是根位于最顶部,再逐渐向下扩展(当然,可根据自己的习惯用任何方法描绘这种图)。因素,Wind.java的继承图就象下面这个样子:
由于造型的方向是从衍生类到基础类,箭头朝上,所以通常把它叫作“上溯造型”,即Upcasting。上溯造型肯定是安全的,因为我们是从一个更特殊的类型到一个更常规的类型。换言之,衍生类是基础类的一个超集。它可以包含比基础类更多的方法,但它至少包含了基础类的方法。进行上溯造型的时候,类接口可能出现的唯一一个问题是它可能丢失方法,而不是赢得这些方法。这便是在没有任何明确的造型或者其他特殊标注的情况下,编译器为什么允许上溯造型的原因所在。
也可以执行下溯造型,但这时会面临第11章要详细讲述的一种困境。
1. 再论合成与继承
在面向对象的程序设计中,创建和使用代码最可能采取的一种做法是:将数据和方法统一封装到一个类里,并且使用那个类的对象。有些时候,需通过“合成”技术用现成的类来构造新类。而继承是最少见的一种做法。因此,尽管继承在学习OOP的过程中得到了大量的强调,但并不意味着应该尽可能地到处使用它。相反,使用它时要特别慎重。只有在清楚知道继承在所有方法中最有效的前提下,才可考虑它。为判断自己到底应该选用合成还是继承,一个最简单的办法就是考虑是否需要从新类上溯造型回基础类。若必须上溯,就需要继承。但如果不需要上溯造型,就应提醒自己防止继承的滥用。在下一章里(多形性),会向大家介绍必须进行上溯造型的一种场合。但只要记住经常问自己“我真的需要上溯造型吗”,对于合成还是继承的选择就不应该是个太大的问题。
6.8 final关键字
由于语境(应用环境)不同,final关键字的含义可能会稍微产生一些差异。但它最一般的意思就是声明“这个东西不能改变”。之所以要禁止改变,可能是考虑到两方面的因素:设计或效率。由于这两个原因颇有些区别,所以也许会造成final关键字的误用。
在接下去的小节里,我们将讨论final关键字的三种应用场合:数据、方法以及类。
6.8.1 final数据
许多程序设计语言都有自己的办法告诉编译器某个数据是“常数”。常数主要应用于下述两个方面:
(1) 编译期常数,它永远不会改变
(2) 在运行期初始化的一个值,我们不希望它发生变化
对于编译期的常数,编译器(程序)可将常数值“封装”到需要的计算过程里。也就是说,计算可在编译期间提前执行,从而节省运行时的一些开销。在Java中,这些形式的常数必须属于基本数据类型(Primitives),而且要用final关键字进行表达。在对这样的一个常数进行定义的时候,必须给出一个值。
无论static还是final字段,都只能存储一个数据,而且不得改变。
若随同对象句柄使用final,而不是基本数据类型,它的含义就稍微让人有点儿迷糊了。对于基本数据类型,final会将值变成一个常数;但对于对象句柄,final会将句柄变成一个常数。进行声明时,必须将句柄初始化到一个具体的对象。而且永远不能将句柄变成指向另一个对象。然而,对象本身是可以修改的。Java对此未提供任何手段,可将一个对象直接变成一个常数(但是,我们可自己编写一个类,使其中的对象具有“常数”效果)。这一限制也适用于数组,它也属于对象。
下面是演示final字段用法的一个例子:
//: FinalData.java // The effect of final on fields class Value { int i = 1; } public class FinalData { // Can be compile-time constants final int i1 = 9; static final int I2 = 99; // Typical public constant: public static final int I3 = 39; // Cannot be compile-time constants: final int i4 = (int)(Math.random()*20); static final int i5 = (int)(Math.random()*20); Value v1 = new Value(); final Value v2 = new Value(); static final Value v3 = new Value(); //! final Value v4; // Pre-Java 1.1 Error: // no initializer // Arrays: final int[] a = { 1, 2, 3, 4, 5, 6 }; public void print(String id) { System.out.println( id + ": " + "i4 = " + i4 + ", i5 = " + i5); } public static void main(String[] args) { FinalData fd1 = new FinalData(); //! fd1.i1++; // Error: can't change value fd1.v2.i++; // Object isn't constant! fd1.v1 = new Value(); // OK -- not final for(int i = 0; i < fd1.a.length; i++) fd1.a[i]++; // Object isn't constant! //! fd1.v2 = new Value(); // Error: Can't //! fd1.v3 = new Value(); // change handle //! fd1.a = new int[3]; fd1.print("fd1"); System.out.println("Creating new FinalData"); FinalData fd2 = new FinalData(); fd1.print("fd1"); fd2.print("fd2"); } } ///:~
由于i1和I2都是具有final属性的基本数据类型,并含有编译期的值,所以它们除了能作为编译期的常数使用外,在任何导入方式中也不会出现任何不同。I3是我们体验此类常数定义时更典型的一种方式:public表示它们可在包外使用;Static强调它们只有一个;而final表明它是一个常数。注意对于含有固定初始化值(即编译期常数)的fianl static基本数据类型,它们的名字根据规则要全部采用大写。也要注意i5在编译期间是未知的,所以它没有大写。
不能由于某样东西的属性是final,就认定它的值能在编译时期知道。i4和i5向大家证明了这一点。它们在运行期间使用随机生成的数字。例子的这一部分也向大家揭示出将final值设为static和非static之间的差异。只有当值在运行期间初始化的前提下,这种差异才会揭示出来。因为编译期间的值被编译器认为是相同的。这种差异可从输出结果中看出:
fd1: i4 = 15, i5 = 9 Creating new FinalData fd1: i4 = 15, i5 = 9 fd2: i4 = 10, i5 = 9
注意对于fd1和fd2来说,i4的值是唯一的,但i5的值不会由于创建了另一个FinalData对象而发生改变。那是因为它的属性是static,而且在载入时初始化,而非每创建一个对象时初始化。
从v1到v4的变量向我们揭示出final句柄的含义。正如大家在main()中看到的那样,并不能认为由于v2属于final,所以就不能再改变它的值。然而,我们确实不能再将v2绑定到一个新对象,因为它的属性是final。这便是final对于一个句柄的确切含义。我们会发现同样的含义亦适用于数组,后者只不过是另一种类型的句柄而已。将句柄变成final看起来似乎不如将基本数据类型变成final那么有用。
2. 空白final
Java 1.1允许我们创建“空白final”,它们属于一些特殊的字段。尽管被声明成final,但却未得到一个初始值。无论在哪种情况下,空白final都必须在实际使用前得到正确的初始化。而且编译器会主动保证这一规定得以贯彻。然而,对于final关键字的各种应用,空白final具有最大的灵活性。举个例子来说,位于类内部的一个final字段现在对每个对象都可以有所不同,同时依然保持其“不变”的本质。下面列出一个例子:
//: BlankFinal.java // "Blank" final data members class Poppet { } class BlankFinal { final int i = 0; // Initialized final final int j; // Blank final final Poppet p; // Blank final handle // Blank finals MUST be initialized // in the constructor: BlankFinal() { j = 1; // Initialize blank final p = new Poppet(); } BlankFinal(int x) { j = x; // Initialize blank final p = new Poppet(); } public static void main(String[] args) { BlankFinal bf = new BlankFinal(); } } ///:~
现在强行要求我们对final进行赋值处理——要么在定义字段时使用一个表达 式,要么在每个构建器中。这样就可以确保final字段在使用前获得正确的初始化。
3. final自变量
Java 1.1允许我们将自变量设成final属性,方法是在自变量列表中对它们进行适当的声明。这意味着在一个方法的内部,我们不能改变自变量句柄指向的东西。如下所示:
//: FinalArguments.java // Using "final" with method arguments class Gizmo { public void spin() {} } public class FinalArguments { void with(final Gizmo g) { //! g = new Gizmo(); // Illegal -- g is final g.spin(); } void without(Gizmo g) { g = new Gizmo(); // OK -- g not final g.spin(); } // void f(final int i) { i++; } // Can't change // You can only read from a final primitive: int g(final int i) { return i + 1; } public static void main(String[] args) { FinalArguments bf = new FinalArguments(); bf.without(null); bf.with(null); } } ///:~
注意此时仍然能为final自变量分配一个null(空)句柄,同时编译器不会捕获它。这与我们对非final自变量采取的操作是一样的。
方法f()和g()向我们展示出基本类型的自变量为final时会发生什么情况:我们只能读取自变量,不可改变它。
6.8.2 final方法
之所以要使用final方法,可能是出于对两方面理由的考虑。第一个是为方法“上锁”,防止任何继承类改变它的本来含义。设计程序时,若希望一个方法的行为在继承期间保持不变,而且不可被覆盖或改写,就可以采取这种做法。
采用final方法的第二个理由是程序执行的效率。将一个方法设成final后,编译器就可以把对那个方法的所有调用都置入“嵌入”调用里。只要编译器发现一个final方法调用,就会(根据它自己的判断)忽略为执行方法调用机制而采取的常规代码插入方法(将自变量压入堆栈;跳至方法代码并执行它;跳回来;清除堆栈自变量;最后对返回值进行处理)。相反,它会用方法主体内实际代码的一个副本来替换方法调用。这样做可避免方法调用时的系统开销。当然,若方法体积太大,那么程序也会变得雍肿,可能受到到不到嵌入代码所带来的任何性能提升。因为任何提升都被花在方法内部的时间抵消了。Java编译器能自动侦测这些情况,并颇为“明智”地决定是否嵌入一个final方法。然而,最好还是不要完全相信编译器能正确地作出所有判断。通常,只有在方法的代码量非常少,或者想明确禁止方法被覆盖的时候,才应考虑将一个方法设为final。
类内所有private方法都自动成为final。由于我们不能访问一个private方法,所以它绝对不会被其他方法覆盖(若强行这样做,编译器会给出错误提示)。可为一个private方法添加final指示符,但却不能为那个方法提供任何额外的含义。
6.8.3 final类
如果说整个类都是final(在它的定义前冠以final关键字),就表明自己不希望从这个类继承,或者不允许其他任何人采取这种操作。换言之,出于这样或那样的原因,我们的类肯定不需要进行任何改变;或者出于安全方面的理由,我们不希望进行子类化(子类处理)。
除此以外,我们或许还考虑到执行效率的问题,并想确保涉及这个类各对象的所有行动都要尽可能地有效。如下所示:
//: Jurassic.java // Making an entire class final class SmallBrain {} final class Dinosaur { int i = 7; int j = 1; SmallBrain x = new SmallBrain(); void f() {} } //! class Further extends Dinosaur {} // error: Cannot extend final class 'Dinosaur' public class Jurassic { public static void main(String[] args) { Dinosaur n = new Dinosaur(); n.f(); n.i = 40; n.j++; } } ///:~
注意数据成员既可以是final,也可以不是,取决于我们具体选择。应用于final的规则同样适用于数据成员,无论类是否被定义成final。将类定义成final后,结果只是禁止进行继承——没有更多的限制。然而,由于它禁止了继承,所以一个final类中的所有方法都默认为final。因为此时再也无法覆盖它们。所以与我们将一个方法明确声明为final一样,编译器此时有相同的效率选择。
可为final类内的一个方法添加final指示符,但这样做没有任何意义。
6.8.4 final的注意事项
设计一个类时,往往需要考虑是否将一个方法设为final。可能会觉得使用自己的类时执行效率非常重要,没有人想覆盖自己的方法。这种想法在某些时候是正确的。
但要慎重作出自己的假定。通常,我们很难预测一个类以后会以什么样的形式再生或重复利用。常规用途的类尤其如此。若将一个方法定义成final,就可能杜绝了在其他程序员的项目中对自己的类进行继承的途径,因为我们根本没有想到它会象那样使用。
标准Java库是阐述这一观点的最好例子。其中特别常用的一个类是Vector。如果我们考虑代码的执行效率,就会发现只有不把任何方法设为final,才能使其发挥更大的作用。我们很容易就会想到自己应继承和覆盖如此有用的一个类,但它的设计者却否定了我们的想法。但我们至少可以用两个理由来反驳他们。首先,Stack(堆栈)是从Vector继承来的,亦即Stack“是”一个Vector,这种说法是不确切的。其次,对于Vector许多重要的方法,如addElement()以及elementAt()等,它们都变成了synchronized(同步的)。正如在第14章要讲到的那样,这会造成显著的性能开销,可能会把final提供的性能改善抵销得一干二净。因此,程序员不得不猜测到底应该在哪里进行优化。在标准库里居然采用了如此笨拙的设计,真不敢想象会在程序员里引发什么样的情绪。
另一个值得注意的是Hashtable(散列表),它是另一个重要的标准类。该类没有采用任何final方法。正如我们在本书其他地方提到的那样,显然一些类的设计人员与其他设计人员有着全然不同的素质(注意比较Hashtable极短的方法名与Vecor的方法名)。对类库的用户来说,这显然是不应该如此轻易就能看出的。一个产品的设计变得不一致后,会加大用户的工作量。这也从另一个侧面强调了代码设计与检查时需要很强的责任心。
6.9 初始化和类装载
在许多传统语言里,程序都是作为启动过程的一部分一次性载入的。随后进行的是初始化,再是正式执行程序。在这些语言中,必须对初始化过程进行慎重的控制,保证static数据的初始化不会带来麻烦。比如在一个static数据获得初始化之前,就有另一个static数据希望它是一个有效值,那么在C++中就会造成问题。
Java则没有这样的问题,因为它采用了不同的装载方法。由于Java中的一切东西都是对象,所以许多活动变得更加简单,这个问题便是其中的一例。正如下一章会讲到的那样,每个对象的代码都存在于独立的文件中。除非真的需要代码,否则那个文件是不会载入的。通常,我们可认为除非那个类的一个对象构造完毕,否则代码不会真的载入。由于static方法存在一些细微的歧义,所以也能认为“类代码在首次使用的时候载入”。
首次使用的地方也是static初始化发生的地方。装载的时候,所有static对象和static代码块都会按照本来的顺序初始化(亦即它们在类定义代码里写入的顺序)。当然,static数据只会初始化一次。
6.9.1 继承初始化
我们有必要对整个初始化过程有所认识,其中包括继承,对这个过程中发生的事情有一个整体性的概念。请观察下述代码:
//: Beetle.java // The full process of initialization. class Insect { int i = 9; int j; Insect() { prt("i = " + i + ", j = " + j); j = 39; } static int x1 = prt("static Insect.x1 initialized"); static int prt(String s) { System.out.println(s); return 47; } } public class Beetle extends Insect { int k = prt("Beetle.k initialized"); Beetle() { prt("k = " + k); prt("j = " + j); } static int x2 = prt("static Beetle.x2 initialized"); static int prt(String s) { System.out.println(s); return 63; } public static void main(String[] args) { prt("Beetle constructor"); Beetle b = new Beetle(); } } ///:~
该程序的输出如下:
static Insect.x initialized static Beetle.x initialized Beetle constructor i = 9, j = 0 Beetle.k initialized k = 63 j = 39
对Beetle运行Java时,发生的第一件事情是装载程序到外面找到那个类。在装载过程中,装载程序注意它有一个基础类(即extends关键字要表达的意思),所以随之将其载入。无论是否准备生成那个基础类的一个对象,这个过程都会发生(请试着将对象的创建代码当作注释标注出来,自己去证实)。
若基础类含有另一个基础类,则另一个基础类随即也会载入,以此类推。接下来,会在根基础类(此时是Insect)执行static初始化,再在下一个衍生类执行,以此类推。保证这个顺序是非常关键的,因为衍生类的初始化可能要依赖于对基础类成员的正确初始化。
此时,必要的类已全部装载完毕,所以能够创建对象。首先,这个对象中的所有基本数据类型都会设成它们的默认值,而将对象句柄设为null。随后会调用基础类构建器。在这种情况下,调用是自动进行的。但也完全可以用super来自行指定构建器调用(就象在Beetle()构建器中的第一个操作一样)。基础类的构建采用与衍生类构建器完全相同的处理过程。基础顺构建器完成以后,实例变量会按本来的顺序得以初始化。最后,执行构建器剩余的主体部分。
6.10 总结
无论继承还是合成,我们都可以在现有类型的基础上创建一个新类型。但在典型情况下,我们通过合成来实现现有类型的“再生”或“重复使用”,将其作为新类型基础实施过程的一部分使用。但如果想实现接口的“再生”,就应使用继承。由于衍生或派生出来的类拥有基础类的接口,所以能够将其“上溯造型”为基础类。对于下一章要讲述的多形性问题,这一点是至关重要的。
尽管继承在面向对象的程序设计中得到了特别的强调,但在实际启动一个设计时,最好还是先考虑采用合成技术。只有在特别必要的时候,才应考虑采用继承技术(下一章还会讲到这个问题)。合成显得更加灵活。但是,通过对自己的成员类型应用一些继承技巧,可在运行期准确改变那些成员对象的类型,由此可改变它们的行为。
尽管对于快速项目开发来说,通过合成和继承实现的代码再生具有很大的帮助作用。但在允许其他程序员完全依赖它之前,一般都希望能重新设计自己的类结构。我们理想的类结构应该是每个类都有自己特定的用途。它们不能过大(如集成的功能太多,则很难实现它的再生),也不能过小(造成不能由自己使用,或者不能增添新功能)。最终实现的类应该能够方便地再生。
6.11 练习
(1) 用默认构建器(空自变量列表)创建两个类:A和B,令它们自己声明自己。从A继承一个名为C的新类,并在C内创建一个成员B。不要为C创建一个构建器。创建类C的一个对象,并观察结果。
(2) 修改练习1,使A和B都有含有自变量的构建器,则不是采用默认构建器。为C写一个构建器,并在C的构建器中执行所有初始化工作。
(3) 使用文件Cartoon.java,将Cartoon类的构建器代码变成注释内容标注出去。解释会发生什么事情。
(4) 使用文件Chess.java,将Chess类的构建器代码作为注释标注出去。同样解释会发生什么。
第7章 多形性
“对于面向对象的程序设计语言,多型性是第三种最基本的特征(前两种是数据抽象和继承。”
“多形性”(Polymorphism)从另一个角度将接口从具体的实施细节中分离出来,亦即实现了“是什么”与“怎样做”两个模块的分离。利用多形性的概念,代码的组织以及可读性均能获得改善。此外,还能创建“易于扩展”的程序。无论在项目的创建过程中,还是在需要加入新特性的时候,它们都可以方便地“成长”。
通过合并各种特征与行为,封装技术可创建出新的数据类型。通过对具体实施细节的隐藏,可将接口与实施细节分离,使所有细节成为“private”(私有)。这种组织方式使那些有程序化编程背景人感觉颇为舒适。但多形性却涉及对“类型”的分解。通过上一章的学习,大家已知道通过继承可将一个对象当作它自己的类型或者它自己的基础类型对待。这种能力是十分重要的,因为多个类型(从相同的基础类型中衍生出来)可被当作同一种类型对待。而且只需一段代码,即可对所有不同的类型进行同样的处理。利用具有多形性的方法调用,一种类型可将自己与另一种相似的类型区分开,只要它们都是从相同的基础类型中衍生出来的。这种区分是通过各种方法在行为上的差异实现的,可通过基础类实现对那些方法的调用。
在这一章中,大家要由浅入深地学习有关多形性的问题(也叫作动态绑定、推迟绑定或者运行期绑定)。同时举一些简单的例子,其中所有无关的部分都已剥除,只保留与多形性有关的代码。
7.1 上溯造型
在第6章,大家已知道可将一个对象作为它自己的类型使用,或者作为它的基础类型的一个对象使用。取得一个对象句柄,并将其作为基础类型句柄使用的行为就叫作“上溯造型”——因为继承树的画法是基础类位于最上方。
但这样做也会遇到一个问题,如下例所示(若执行这个程序遇到麻烦,请参考第3章的3.1.2小节“赋值”):
//: Music.java // Inheritance & upcasting package c07; class Note { private int value; private Note(int val) { value = val; } public static final Note middleC = new Note(0), cSharp = new Note(1), cFlat = new Note(2); } // Etc. class Instrument { public void play(Note n) { System.out.println("Instrument.play()"); } } // Wind objects are instruments // because they have the same interface: class Wind extends Instrument { // Redefine interface method: public void play(Note n) { System.out.println("Wind.play()"); } } public class Music { public static void tune(Instrument i) { // ... i.play(Note.middleC); } public static void main(String[] args) { Wind flute = new Wind(); tune(flute); // Upcasting } } ///:~
其中,方法Music.tune()接收一个Instrument句柄,同时也接收从Instrument衍生出来的所有东西。当一个Wind句柄传递给tune()的时候,就会出现这种情况。此时没有造型的必要。这样做是可以接受的;Instrument里的接口必须存在于Wind中,因为Wind是从Instrument里继承得到的。从Wind向Instrument的上溯造型可能“缩小”那个接口,但不可能把它变得比Instrument的完整接口还要小。
7.1.1 为什么要上溯造型
这个程序看起来也许显得有些奇怪。为什么所有人都应该有意忘记一个对象的类型呢?进行上溯造型时,就可能产生这方面的疑惑。而且如果让tune()简单地取得一个Wind句柄,将其作为自己的自变量使用,似乎会更加简单、直观得多。但要注意:假如那样做,就需为系统内Instrument的每种类型写一个全新的tune()。假设按照前面的推论,加入Stringed(弦乐)和Brass(铜管)这两种Instrument(乐器):
//: Music2.java // Overloading instead of upcasting class Note2 { private int value; private Note2(int val) { value = val; } public static final Note2 middleC = new Note2(0), cSharp = new Note2(1), cFlat = new Note2(2); } // Etc. class Instrument2 { public void play(Note2 n) { System.out.println("Instrument2.play()"); } } class Wind2 extends Instrument2 { public void play(Note2 n) { System.out.println("Wind2.play()"); } } class Stringed2 extends Instrument2 { public void play(Note2 n) { System.out.println("Stringed2.play()"); } } class Brass2 extends Instrument2 { public void play(Note2 n) { System.out.println("Brass2.play()"); } } public class Music2 { public static void tune(Wind2 i) { i.play(Note2.middleC); } public static void tune(Stringed2 i) { i.play(Note2.middleC); } public static void tune(Brass2 i) { i.play(Note2.middleC); } public static void main(String[] args) { Wind2 flute = new Wind2(); Stringed2 violin = new Stringed2(); Brass2 frenchHorn = new Brass2(); tune(flute); // No upcasting tune(violin); tune(frenchHorn); } } ///:~
这样做当然行得通,但却存在一个极大的弊端:必须为每种新增的Instrument2类编写与类紧密相关的方法。这意味着第一次就要求多得多的编程量。以后,假如想添加一个象tune()那样的新方法或者为Instrument添加一个新类型,仍然需要进行大量编码工作。此外,即使忘记对自己的某个方法进行过载设置,编译器也不会提示任何错误。这样一来,类型的整个操作过程就显得极难管理,有失控的危险。
但假如只写一个方法,将基础类作为自变量或参数使用,而不是使用那些特定的衍生类,岂不是会简单得多?也就是说,如果我们能不顾衍生类,只让自己的代码与基础类打交道,那么省下的工作量将是难以估计的。
这正是“多形性”大显身手的地方。然而,大多数程序员(特别是有程序化编程背景的)对于多形性的工作原理仍然显得有些生疏。
7.2 深入理解
对于Music.java的困难性,可通过运行程序加以体会。输出是Wind.play()。这当然是我们希望的输出,但它看起来似乎并不愿按我们的希望行事。请观察一下tune()方法:
public static void tune(Instrument i) {
// ...
i.play(Note.middleC);
}
它接收Instrument句柄。所以在这种情况下,编译器怎样才能知道Instrument句柄指向的是一个Wind,而不是一个Brass或Stringed呢?编译器无从得知。为了深入了理解这个问题,我们有必要探讨一下“绑定”这个主题。
7.2.1 方法调用的绑定
将一个方法调用同一个方法主体连接到一起就称为“绑定”(Binding)。若在程序运行以前执行绑定(由编译器和链接程序,如果有的话),就叫作“早期绑定”。大家以前或许从未听说过这个术语,因为它在任何程序化语言里都是不可能的。C编译器只有一种方法调用,那就是“早期绑定”。
上述程序最令人迷惑不解的地方全与早期绑定有关,因为在只有一个Instrument句柄的前提下,编译器不知道具体该调用哪个方法。
解决的方法就是“后期绑定”,它意味着绑定在运行期间进行,以对象的类型为基础。后期绑定也叫作“动态绑定”或“运行期绑定”。若一种语言实现了后期绑定,同时必须提供一些机制,可在运行期间判断对象的类型,并分别调用适当的方法。也就是说,编译器此时依然不知道对象的类型,但方法调用机制能自己去调查,找到正确的方法主体。不同的语言对后期绑定的实现方法是有所区别的。但我们至少可以这样认为:它们都要在对象中安插某些特殊类型的信息。
Java中绑定的所有方法都采用后期绑定技术,除非一个方法已被声明成final。这意味着我们通常不必决定是否应进行后期绑定——它是自动发生的。
为什么要把一个方法声明成final呢?正如上一章指出的那样,它能防止其他人覆盖那个方法。但也许更重要的一点是,它可有效地“关闭”动态绑定,或者告诉编译器不需要进行动态绑定。这样一来,编译器就可为final方法调用生成效率更高的代码。
7.2.2 产生正确的行为
知道Java里绑定的所有方法都通过后期绑定具有多形性以后,就可以相应地编写自己的代码,令其与基础类沟通。此时,所有的衍生类都保证能用相同的代码正常地工作。或者换用另一种方法,我们可以“将一条消息发给一个对象,让对象自行判断要做什么事情。”
在面向对象的程序设计中,有一个经典的“形状”例子。由于它很容易用可视化的形式表现出来,所以经常都用它说明问题。但很不幸的是,它可能误导初学者认为OOP只是为图形化编程设计的,这种认识当然是错误的。
形状例子有一个基础类,名为Shape;另外还有大量衍生类型:Circle(圆形),Square(方形),Triangle(三角形)等等。大家之所以喜欢这个例子,因为很容易理解“圆属于形状的一种类型”等概念。下面这幅继承图向我们展示了它们的关系:
上溯造型可用下面这个语句简单地表现出来:
Shape s = new Circle();
在这里,我们创建了Circle对象,并将结果句柄立即赋给一个Shape。这表面看起来似乎属于错误操作(将一种类型分配给另一个),但实际是完全可行的——因为按照继承关系,Circle属于Shape的一种。因此编译器认可上述语句,不会向我们提示一条出错消息。
当我们调用其中一个基础类方法时(已在衍生类里覆盖):
s.draw();
同样地,大家也许认为会调用Shape的draw(),因为这毕竟是一个Shape句柄。那么编译器怎样才能知道该做其他任何事情呢?但此时实际调用的是Circle.draw(),因为后期绑定已经介入(多形性)。
下面这个例子从一个稍微不同的角度说明了问题:
//: Shapes.java // Polymorphism in Java class Shape { void draw() {} void erase() {} } class Circle extends Shape { void draw() { System.out.println("Circle.draw()"); } void erase() { System.out.println("Circle.erase()"); } } class Square extends Shape { void draw() { System.out.println("Square.draw()"); } void erase() { System.out.println("Square.erase()"); } } class Triangle extends Shape { void draw() { System.out.println("Triangle.draw()"); } void erase() { System.out.println("Triangle.erase()"); } } public class Shapes { public static Shape randShape() { switch((int)(Math.random() * 3)) { default: // To quiet the compiler case 0: return new Circle(); case 1: return new Square(); case 2: return new Triangle(); } } public static void main(String[] args) { Shape[] s = new Shape[9]; // Fill up the array with shapes: for(int i = 0; i < s.length; i++) s[i] = randShape(); // Make polymorphic method calls: for(int i = 0; i < s.length; i++) s[i].draw(); } } ///:~
针对从Shape衍生出来的所有东西,Shape建立了一个通用接口——也就是说,所有(几何)形状都可以描绘和删除。衍生类覆盖了这些定义,为每种特殊类型的几何形状都提供了独一无二的行为。
在主类Shapes里,包含了一个static方法,名为randShape()。它的作用是在每次调用它时为某个随机选择的Shape对象生成一个句柄。请注意上溯造型是在每个return语句里发生的。这个语句取得指向一个Circle,Square或者Triangle的句柄,并将其作为返回类型Shape发给方法。所以无论什么时候调用这个方法,就绝对没机会了解它的具体类型到底是什么,因为肯定会获得一个单纯的Shape句柄。
main()包含了Shape句柄的一个数组,其中的数据通过对randShape()的调用填入。在这个时候,我们知道自己拥有Shape,但不知除此之外任何具体的情况(编译器同样不知)。然而,当我们在这个数组里步进,并为每个元素调用draw()的时候,与各类型有关的正确行为会魔术般地发生,就象下面这个输出示例展示的那样:
Circle.draw() Triangle.draw() Circle.draw() Circle.draw() Circle.draw() Square.draw() Triangle.draw() Square.draw() Square.draw()
当然,由于几何形状是每次随机选择的,所以每次运行都可能有不同的结果。之所以要突出形状的随机选择,是为了让大家深刻体会这一点:为了在编译的时候发出正确的调用,编译器毋需获得任何特殊的情报。对draw()的所有调用都是通过动态绑定进行的。
7.2.3 扩展性
现在,让我们仍然返回乐器(Instrument)示例。由于存在多形性,所以可根据自己的需要向系统里加入任意多的新类型,同时毋需更改true()方法。在一个设计良好的OOP程序中,我们的大多数或者所有方法都会遵从tune()的模型,而且只与基础类接口通信。我们说这样的程序具有“扩展性”,因为可以从通用的基础类继承新的数据类型,从而新添一些功能。如果是为了适应新类的要求,那么对基础类接口进行操纵的方法根本不需要改变,
对于乐器例子,假设我们在基础类里加入更多的方法,以及一系列新类,那么会出现什么情况呢?下面是示意图:
所有这些新类都能与老类——tune()默契地工作,毋需对tune()作任何调整。即使tune()位于一个独立的文件里,而将新方法添加到Instrument的接口,tune()也能正确地工作,不需要重新编译。下面这个程序是对上述示意图的具体实现:
//: Music3.java // An extensible program import java.util.*; class Instrument3 { public void play() { System.out.println("Instrument3.play()"); } public String what() { return "Instrument3"; } public void adjust() {} } class Wind3 extends Instrument3 { public void play() { System.out.println("Wind3.play()"); } public String what() { return "Wind3"; } public void adjust() {} } class Percussion3 extends Instrument3 { public void play() { System.out.println("Percussion3.play()"); } public String what() { return "Percussion3"; } public void adjust() {} } class Stringed3 extends Instrument3 { public void play() { System.out.println("Stringed3.play()"); } public String what() { return "Stringed3"; } public void adjust() {} } class Brass3 extends Wind3 { public void play() { System.out.println("Brass3.play()"); } public void adjust() { System.out.println("Brass3.adjust()"); } } class Woodwind3 extends Wind3 { public void play() { System.out.println("Woodwind3.play()"); } public String what() { return "Woodwind3"; } } public class Music3 { // Doesn't care about type, so new types // added to the system still work right: static void tune(Instrument3 i) { // ... i.play(); } static void tuneAll(Instrument3[] e) { for(int i = 0; i < e.length; i++) tune(e[i]); } public static void main(String[] args) { Instrument3[] orchestra = new Instrument3[5]; int i = 0; // Upcasting during addition to the array: orchestra[i++] = new Wind3(); orchestra[i++] = new Percussion3(); orchestra[i++] = new Stringed3(); orchestra[i++] = new Brass3(); orchestra[i++] = new Woodwind3(); tuneAll(orchestra); } } ///:~
新方法是what()和adjust()。前者返回一个String句柄,同时返回对那个类的说明;后者使我们能对每种乐器进行调整。
在main()中,当我们将某样东西置入Instrument3数组时,就会自动上溯造型到Instrument3。
可以看到,在围绕tune()方法的其他所有代码都发生变化的同时,tune()方法却丝毫不受它们的影响,依然故我地正常工作。这正是利用多形性希望达到的目标。我们对代码进行修改后,不会对程序中不应受到影响的部分造成影响。此外,我们认为多形性是一种至关重要的技术,它允许程序员“将发生改变的东西同没有发生改变的东西区分开”。
7.3 覆盖与过载
现在让我们用不同的眼光来看看本章的头一个例子。在下面这个程序中,方法play()的接口会在被覆盖的过程中发生变化。这意味着我们实际并没有“覆盖”方法,而是使其“过载”。编译器允许我们对方法进行过载处理,使其不报告出错。但这种行为可能并不是我们所希望的。下面是这个例子:
//: WindError.java // Accidentally changing the interface class NoteX { public static final int MIDDLE_C = 0, C_SHARP = 1, C_FLAT = 2; } class InstrumentX { public void play(int NoteX) { System.out.println("InstrumentX.play()"); } } class WindX extends InstrumentX { // OOPS! Changes the method interface: public void play(NoteX n) { System.out.println("WindX.play(NoteX n)"); } } public class WindError { public static void tune(InstrumentX i) { // ... i.play(NoteX.MIDDLE_C); } public static void main(String[] args) { WindX flute = new WindX(); tune(flute); // Not the desired behavior! } } ///:~
这里还向大家引入了另一个易于混淆的概念。在InstrumentX中,play()方法采用了一个int(整数)数值,它的标识符是NoteX。也就是说,即使NoteX是一个类名,也可以把它作为一个标识符使用,编译器不会报告出错。但在WindX中,play()采用一个NoteX句柄,它有一个标识符n。即便我们使用“play(NoteX NoteX)”,编译器也不会报告错误。这样一来,看起来就象是程序员有意覆盖play()的功能,但对方法的类型定义却稍微有些不确切。然而,编译器此时假定的是程序员有意进行“过载”,而非“覆盖”。请仔细体会这两个术语的区别。“过载”是指同一样东西在不同的地方具有多种含义;而“覆盖”是指它随时随地都只有一种含义,只是原先的含义完全被后来的含义取代了。请注意如果遵守标准的Java命名规范,自变量标识符就应该是noteX,这样可把它与类名区分开。
在tune中,“InstrumentX i”会发出play()消息,同时将某个NoteX成员作为自变量使用(MIDDLE_C)。由于NoteX包含了int定义,过载的play()方法的int版本会得到调用。同时由于它尚未被“覆盖”,所以会使用基础类版本。
输出是:
InstrumentX.play()
7.4 抽象类和方法
在我们所有乐器(Instrument)例子中,基础类Instrument内的方法都肯定是“伪”方法。若去调用这些方法,就会出现错误。那是由于Instrument的意图是为从它衍生出去的所有类都创建一个通用接口。
之所以要建立这个通用接口,唯一的原因就是它能为不同的子类型作出不同的表示。它为我们建立了一种基本形式,使我们能定义在所有衍生类里“通用”的一些东西。为阐述这个观念,另一个方法是把Instrument称为“抽象基础类”(简称“抽象类”)。若想通过该通用接口处理一系列类,就需要创建一个抽象类。对所有与基础类声明的签名相符的衍生类方法,都可以通过动态绑定机制进行调用(然而,正如上一节指出的那样,如果方法名与基础类相同,但自变量或参数不同,就会出现过载现象,那或许并非我们所愿意的)。
如果有一个象Instrument那样的抽象类,那个类的对象几乎肯定没有什么意义。换言之,Instrument的作用仅仅是表达接口,而不是表达一些具体的实施细节。所以创建一个Instrument对象是没有意义的,而且我们通常都应禁止用户那样做。为达到这个目的,可令Instrument内的所有方法都显示出错消息。但这样做会延迟信息到运行期,并要求在用户那一面进行彻底、可靠的测试。无论如何,最好的方法都是在编译期间捕捉到问题。
针对这个问题,Java专门提供了一种机制,名为“抽象方法”。它属于一种不完整的方法,只含有一个声明,没有方法主体。下面是抽象方法声明时采用的语法:
abstract void X();
包含了抽象方法的一个类叫作“抽象类”。如果一个类里包含了一个或多个抽象方法,类就必须指定成abstract(抽象)。否则,编译器会向我们报告一条出错消息。
若一个抽象类是不完整的,那么一旦有人试图生成那个类的一个对象,编译器又会采取什么行动呢?由于不能安全地为一个抽象类创建属于它的对象,所以会从编译器那里获得一条出错提示。通过这种方法,编译器可保证抽象类的“纯洁性”,我们不必担心会误用它。
如果从一个抽象类继承,而且想生成新类型的一个对象,就必须为基础类中的所有抽象方法提供方法定义。如果不这样做(完全可以选择不做),则衍生类也会是抽象的,而且编译器会强迫我们用abstract关键字标志那个类的“抽象”本质。
即使不包括任何abstract方法,亦可将一个类声明成“抽象类”。如果一个类没必要拥有任何抽象方法,而且我们想禁止那个类的所有实例,这种能力就会显得非常有用。
Instrument类可很轻松地转换成一个抽象类。只有其中一部分方法会变成抽象方法,因为使一个类抽象以后,并不会强迫我们将它的所有方法都同时变成抽象。下面是它看起来的样子:
下面是我们修改过的“管弦”乐器例子,其中采用了抽象类以及方法:
//: Music4.java // Abstract classes and methods import java.util.*; abstract class Instrument4 { int i; // storage allocated for each public abstract void play(); public String what() { return "Instrument4"; } public abstract void adjust(); } class Wind4 extends Instrument4 { public void play() { System.out.println("Wind4.play()"); } public String what() { return "Wind4"; } public void adjust() {} } class Percussion4 extends Instrument4 { public void play() { System.out.println("Percussion4.play()"); } public String what() { return "Percussion4"; } public void adjust() {} } class Stringed4 extends Instrument4 { public void play() { System.out.println("Stringed4.play()"); } public String what() { return "Stringed4"; } public void adjust() {} } class Brass4 extends Wind4 { public void play() { System.out.println("Brass4.play()"); } public void adjust() { System.out.println("Brass4.adjust()"); } } class Woodwind4 extends Wind4 { public void play() { System.out.println("Woodwind4.play()"); } public String what() { return "Woodwind4"; } } public class Music4 { // Doesn't care about type, so new types // added to the system still work right: static void tune(Instrument4 i) { // ... i.play(); } static void tuneAll(Instrument4[] e) { for(int i = 0; i < e.length; i++) tune(e[i]); } public static void main(String[] args) { Instrument4[] orchestra = new Instrument4[5]; int i = 0; // Upcasting during addition to the array: orchestra[i++] = new Wind4(); orchestra[i++] = new Percussion4(); orchestra[i++] = new Stringed4(); orchestra[i++] = new Brass4(); orchestra[i++] = new Woodwind4(); tuneAll(orchestra); } } ///:~
可以看出,除基础类以外,实际并没有进行什么改变。
创建抽象类和方法有时对我们非常有用,因为它们使一个类的抽象变成明显的事实,可明确告诉用户和编译器自己打算如何用它。
7.5 接口
“interface”(接口)关键字使抽象的概念更深入了一层。我们可将其想象为一个“纯”抽象类。它允许创建者规定一个类的基本形式:方法名、自变量列表以及返回类型,但不规定方法主体。接口也包含了基本数据类型的数据成员,但它们都默认为static和final。接口只提供一种形式,并不提供实施的细节。
接口这样描述自己:“对于实现我的所有类,看起来都应该象我现在这个样子”。因此,采用了一个特定接口的所有代码都知道对于那个接口可能会调用什么方法。这便是接口的全部含义。所以我们常把接口用于建立类和类之间的一个“协议”。有些面向对象的程序设计语言采用了一个名为“protocol”(协议)的关键字,它做的便是与接口相同的事情。
为创建一个接口,请使用interface关键字,而不要用class。与类相似,我们可在interface关键字的前面增加一个public关键字(但只有接口定义于同名的一个文件内);或者将其省略,营造一种“友好的”状态。
为了生成与一个特定的接口(或一组接口)相符的类,要使用implements(实现)关键字。我们要表达的意思是“接口看起来就象那个样子,这儿是它具体的工作细节”。除这些之外,我们其他的工作都与继承极为相似。下面是乐器例子的示意图:
具体实现了一个接口以后,就获得了一个普通的类,可用标准方式对其进行扩展。
可决定将一个接口中的方法声明明确定义为“public”。但即便不明确定义,它们也会默认为public。所以在实现一个接口的时候,来自接口的方法必须定义成public。否则的话,它们会默认为“友好的”,而且会限制我们在继承过程中对一个方法的访问——Java编译器不允许我们那样做。
在Instrument例子的修改版本中,大家可明确地看出这一点。注意接口中的每个方法都严格地是一个声明,它是编译器唯一允许的。除此以外,Instrument5中没有一个方法被声明为public,但它们都会自动获得public属性。如下所示:
//: Music5.java // Interfaces import java.util.*; interface Instrument5 { // Compile-time constant: int i = 5; // static & final // Cannot have method definitions: void play(); // Automatically public String what(); void adjust(); } class Wind5 implements Instrument5 { public void play() { System.out.println("Wind5.play()"); } public String what() { return "Wind5"; } public void adjust() {} } class Percussion5 implements Instrument5 { public void play() { System.out.println("Percussion5.play()"); } public String what() { return "Percussion5"; } public void adjust() {} } class Stringed5 implements Instrument5 { public void play() { System.out.println("Stringed5.play()"); } public String what() { return "Stringed5"; } public void adjust() {} } class Brass5 extends Wind5 { public void play() { System.out.println("Brass5.play()"); } public void adjust() { System.out.println("Brass5.adjust()"); } } class Woodwind5 extends Wind5 { public void play() { System.out.println("Woodwind5.play()"); } public String what() { return "Woodwind5"; } } public class Music5 { // Doesn't care about type, so new types // added to the system still work right: static void tune(Instrument5 i) { // ... i.play(); } static void tuneAll(Instrument5[] e) { for(int i = 0; i < e.length; i++) tune(e[i]); } public static void main(String[] args) { Instrument5[] orchestra = new Instrument5[5]; int i = 0; // Upcasting during addition to the array: orchestra[i++] = new Wind5(); orchestra[i++] = new Percussion5(); orchestra[i++] = new Stringed5(); orchestra[i++] = new Brass5(); orchestra[i++] = new Woodwind5(); tuneAll(orchestra); } } ///:~
代码剩余的部分按相同的方式工作。我们可以自由决定上溯造型到一个名为Instrument5的“普通”类,一个名为Instrument5的“抽象”类,或者一个名为Instrument5的“接口”。所有行为都是相同的。事实上,我们在tune()方法中可以发现没有任何证据显示Instrument5到底是个“普通”类、“抽象”类还是一个“接口”。这是做是故意的:每种方法都使程序员能对对象的创建与使用进行不同的控制。
7.5.1 Java的“多重继承”
接口只是比抽象类“更纯”的一种形式。它的用途并不止那些。由于接口根本没有具体的实施细节——也就是说,没有与存储空间与“接口”关联在一起——所以没有任何办法可以防止多个接口合并到一起。这一点是至关重要的,因为我们经常都需要表达这样一个意思:“x从属于a,也从属于b,也从属于c”。在C++中,将多个类合并到一起的行动称作“多重继承”,而且操作较为不便,因为每个类都可能有一套自己的实施细节。在Java中,我们可采取同样的行动,但只有其中一个类拥有具体的实施细节。所以在合并多个接口的时候,C++的问题不会在Java中重演。如下所示:
在一个衍生类中,我们并不一定要拥有一个抽象或具体(没有抽象方法)的基础类。如果确实想从一个非接口继承,那么只能从一个继承。剩余的所有基本元素都必须是“接口”。我们将所有接口名置于implements关键字的后面,并用逗号分隔它们。可根据需要使用多个接口,而且每个接口都会成为一个独立的类型,可对其进行上溯造型。下面这个例子展示了一个“具体”类同几个接口合并的情况,它最终生成了一个新类:
//: Adventure.java // Multiple interfaces import java.util.*; interface CanFight { void fight(); } interface CanSwim { void swim(); } interface CanFly { void fly(); } class ActionCharacter { public void fight() {} } class Hero extends ActionCharacter implements CanFight, CanSwim, CanFly { public void swim() {} public void fly() {} } public class Adventure { static void t(CanFight x) { x.fight(); } static void u(CanSwim x) { x.swim(); } static void v(CanFly x) { x.fly(); } static void w(ActionCharacter x) { x.fight(); } public static void main(String[] args) { Hero i = new Hero(); t(i); // Treat it as a CanFight u(i); // Treat it as a CanSwim v(i); // Treat it as a CanFly w(i); // Treat it as an ActionCharacter } } ///:~
从中可以看到,Hero将具体类ActionCharacter同接口CanFight,CanSwim以及CanFly合并起来。按这种形式合并一个具体类与接口的时候,具体类必须首先出现,然后才是接口(否则编译器会报错)。
请注意fight()的签名在CanFight接口与ActionCharacter类中是相同的,而且没有在Hero中为fight()提供一个具体的定义。接口的规则是:我们可以从它继承(稍后就会看到),但这样得到的将是另一个接口。如果想创建新类型的一个对象,它就必须是已提供所有定义的一个类。尽管Hero没有为fight()明确地提供一个定义,但定义是随同ActionCharacter来的,所以这个定义会自动提供,我们可以创建Hero的对象。
在类Adventure中,我们可看到共有四个方法,它们将不同的接口和具体类作为自己的自变量使用。创建一个Hero对象后,它可以传递给这些方法中的任何一个。这意味着它们会依次上溯造型到每一个接口。由于接口是用Java设计的,所以这样做不会有任何问题,而且程序员不必对此加以任何特别的关注。
注意上述例子已向我们揭示了接口最关键的作用,也是使用接口最重要的一个原因:能上溯造型至多个基础类。使用接口的第二个原因与使用抽象基础类的原因是一样的:防止客户程序员制作这个类的一个对象,以及规定它仅仅是一个接口。这样便带来了一个问题:到底应该使用一个接口还是一个抽象类呢?若使用接口,我们可以同时获得抽象类以及接口的好处。所以假如想创建的基础类没有任何方法定义或者成员变量,那么无论如何都愿意使用接口,而不要选择抽象类。事实上,如果事先知道某种东西会成为基础类,那么第一个选择就是把它变成一个接口。只有在必须使用方法定义或者成员变量的时候,才应考虑采用抽象类。
7.5.2 通过继承扩展接口
利用继承技术,可方便地为一个接口添加新的方法声明,也可以将几个接口合并成一个新接口。在这两种情况下,最终得到的都是一个新接口,如下例所示:
//: HorrorShow.java // Extending an interface with inheritance interface Monster { void menace(); } interface DangerousMonster extends Monster { void destroy(); } interface Lethal { void kill(); } class DragonZilla implements DangerousMonster { public void menace() {} public void destroy() {} } interface Vampire extends DangerousMonster, Lethal { void drinkBlood(); } class HorrorShow { static void u(Monster b) { b.menace(); } static void v(DangerousMonster d) { d.menace(); d.destroy(); } public static void main(String[] args) { DragonZilla if2 = new DragonZilla(); u(if2); v(if2); } } ///:~
DangerousMonster是对Monster的一个简单的扩展,最终生成了一个新接口。这是在DragonZilla里实现的。
Vampire的语法仅在继承接口时才可使用。通常,我们只能对单独一个类应用extends(扩展)关键字。但由于接口可能由多个其他接口构成,所以在构建一个新接口时,extends可能引用多个基础接口。正如大家看到的那样,接口的名字只是简单地使用逗号分隔。
7.5.3 常数分组
由于置入一个接口的所有字段都自动具有static和final属性,所以接口是对常数值进行分组的一个好工具,它具有与C或C++的enum非常相似的效果。如下例所示:
//: Months.java // Using interfaces to create groups of constants package c07; public interface Months { int JANUARY = 1, FEBRUARY = 2, MARCH = 3, APRIL = 4, MAY = 5, JUNE = 6, JULY = 7, AUGUST = 8, SEPTEMBER = 9, OCTOBER = 10, NOVEMBER = 11, DECEMBER = 12; } ///:~
注意根据Java命名规则,拥有固定标识符的static final基本数据类型(亦即编译期常数)都全部采用大写字母(用下划线分隔单个标识符里的多个单词)。
接口中的字段会自动具备public属性,所以没必要专门指定。
现在,通过导入c07.*或c07.Months,我们可以从包的外部使用常数——就象对其他任何包进行的操作那样。此外,也可以用类似Months.JANUARY的表达式对值进行引用。当然,我们获得的只是一个int,所以不象C++的enum那样拥有额外的类型安全性。但与将数字强行编码(硬编码)到自己的程序中相比,这种(常用的)技术无疑已经是一个巨大的进步。我们通常把“硬编码”数字的行为称为“魔术数字”,它产生的代码是非常难以维护的。
如确实不想放弃额外的类型安全性,可构建象下面这样的一个类(注释①):
//: Month2.java // A more robust enumeration system package c07; public final class Month2 { private String name; private Month2(String nm) { name = nm; } public String toString() { return name; } public final static Month2 JAN = new Month2("January"), FEB = new Month2("February"), MAR = new Month2("March"), APR = new Month2("April"), MAY = new Month2("May"), JUN = new Month2("June"), JUL = new Month2("July"), AUG = new Month2("August"), SEP = new Month2("September"), OCT = new Month2("October"), NOV = new Month2("November"), DEC = new Month2("December"); public final static Month2[] month = { JAN, JAN, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC }; public static void main(String[] args) { Month2 m = Month2.JAN; System.out.println(m); m = Month2.month[12]; System.out.println(m); System.out.println(m == Month2.DEC); System.out.println(m.equals(Month2.DEC)); } } ///:~
①:是Rich Hoffarth的一封E-mail触发了我这样编写程序的灵感。
这个类叫作Month2,因为标准Java库里已经有一个Month。它是一个final类,并含有一个private构建器,所以没有人能从它继承,或制作它的一个实例。唯一的实例就是那些final static对象,它们是在类本身内部创建的,包括:JAN,FEB,MAR等等。这些对象也在month数组中使用,后者让我们能够按数字挑选月份,而不是按名字(注意数组中提供了一个多余的JAN,使偏移量增加了1,也使December确实成为12月)。在main()中,我们可注意到类型的安全性:m是一个Month2对象,所以只能将其分配给Month2。在前面的Months.java例子中,只提供了int值,所以本来想用来代表一个月份的int变量可能实际获得一个整数值,那样做可能不十分安全。
这儿介绍的方法也允许我们交换使用==或者equals(),就象main()尾部展示的那样。
7.5.4 初始化接口中的字段
接口中定义的字段会自动具有static和final属性。它们不能是“空白final”,但可初始化成非常数表达式。例如:
//: RandVals.java // Initializing interface fields with // non-constant initializers import java.util.*; public interface RandVals { int rint = (int)(Math.random() * 10); long rlong = (long)(Math.random() * 10); float rfloat = (float)(Math.random() * 10); double rdouble = Math.random() * 10; } ///:~
由于字段是static的,所以它们会在首次装载类之后、以及首次访问任何字段之前获得初始化。下面是一个简单的测试:
//: TestRandVals.java public class TestRandVals { public static void main(String[] args) { System.out.println(RandVals.rint); System.out.println(RandVals.rlong); System.out.println(RandVals.rfloat); System.out.println(RandVals.rdouble); } } ///:~
当然,字段并不是接口的一部分,而是保存于那个接口的static存储区域中。
7.6 内部类
在Java 1.1中,可将一个类定义置入另一个类定义中。这就叫作“内部类”。内部类对我们非常有用,因为利用它可对那些逻辑上相互联系的类进行分组,并可控制一个类在另一个类里的“可见性”。然而,我们必须认识到内部类与以前讲述的“合成”方法存在着根本的区别。
通常,对内部类的需要并不是特别明显的,至少不会立即感觉到自己需要使用内部类。在本章的末尾,介绍完内部类的所有语法之后,大家会发现一个特别的例子。通过它应该可以清晰地认识到内部类的好处。
创建内部类的过程是平淡无奇的:将类定义置入一个用于封装它的类内部(若执行这个程序遇到麻烦,请参见第3章的3.1.2小节“赋值”):
//: Parcel1.java // Creating inner classes package c07.parcel1; public class Parcel1 { class Contents { private int i = 11; public int value() { return i; } } class Destination { private String label; Destination(String whereTo) { label = whereTo; } String readLabel() { return label; } } // Using inner classes looks just like // using any other class, within Parcel1: public void ship(String dest) { Contents c = new Contents(); Destination d = new Destination(dest); } public static void main(String[] args) { Parcel1 p = new Parcel1(); p.ship("Tanzania"); } } ///:~
若在ship()内部使用,内部类的使用看起来和其他任何类都没什么分别。在这里,唯一明显的区别就是它的名字嵌套在Parcel1里面。但大家不久就会知道,这其实并非唯一的区别。
更典型的一种情况是,一个外部类拥有一个特殊的方法,它会返回指向一个内部类的句柄。就象下面这样:
//: Parcel2.java // Returning a handle to an inner class package c07.parcel2; public class Parcel2 { class Contents { private int i = 11; public int value() { return i; } } class Destination { private String label; Destination(String whereTo) { label = whereTo; } String readLabel() { return label; } } public Destination to(String s) { return new Destination(s); } public Contents cont() { return new Contents(); } public void ship(String dest) { Contents c = cont(); Destination d = to(dest); } public static void main(String[] args) { Parcel2 p = new Parcel2(); p.ship("Tanzania"); Parcel2 q = new Parcel2(); // Defining handles to inner classes: Parcel2.Contents c = q.cont(); Parcel2.Destination d = q.to("Borneo"); } } ///:~
若想在除外部类非static方法内部之外的任何地方生成内部类的一个对象,必须将那个对象的类型设为“外部类名.内部类名”,就象main()中展示的那样。
7.6.1 内部类和上溯造型
迄今为止,内部类看起来仍然没什么特别的地方。毕竟,用它实现隐藏显得有些大题小做。Java已经有一个非常优秀的隐藏机制——只允许类成为“友好的”(只在一个包内可见),而不是把它创建成一个内部类。
然而,当我们准备上溯造型到一个基础类(特别是到一个接口)的时候,内部类就开始发挥其关键作用(从用于实现的对象生成一个接口句柄具有与上溯造型至一个基础类相同的效果)。这是由于内部类随后可完全进入不可见或不可用状态——对任何人都将如此。所以我们可以非常方便地隐藏实施细节。我们得到的全部回报就是一个基础类或者接口的句柄,而且甚至有可能不知道准确的类型。就象下面这样:
//: Parcel3.java // Returning a handle to an inner class package c07.parcel3; abstract class Contents { abstract public int value(); } interface Destination { String readLabel(); } public class Parcel3 { private class PContents extends Contents { private int i = 11; public int value() { return i; } } protected class PDestination implements Destination { private String label; private PDestination(String whereTo) { label = whereTo; } public String readLabel() { return label; } } public Destination dest(String s) { return new PDestination(s); } public Contents cont() { return new PContents(); } } class Test { public static void main(String[] args) { Parcel3 p = new Parcel3(); Contents c = p.cont(); Destination d = p.dest("Tanzania"); // Illegal -- can't access private class: //! Parcel3.PContents c = p.new PContents(); } } ///:~
现在,Contents和Destination代表可由客户程序员使用的接口(记住接口会将自己的所有成员都变成public属性)。为方便起见,它们置于单独一个文件里,但原始的Contents和Destination在它们自己的文件中是相互public的。
在Parcel3中,一些新东西已经加入:内部类PContents被设为private,所以除了Parcel3之外,其他任何东西都不能访问它。PDestination被设为protected,所以除了Parcel3,Parcel3包内的类(因为protected也为包赋予了访问权;也就是说,protected也是“友好的”),以及Parcel3的继承者之外,其他任何东西都不能访问PDestination。这意味着客户程序员对这些成员的认识与访问将会受到限制。事实上,我们甚至不能下溯造型到一个private内部类(或者一个protected内部类,除非自己本身便是一个继承者),因为我们不能访问名字,就象在classTest里看到的那样。所以,利用private内部类,类设计人员可完全禁止其他人依赖类型编码,并可将具体的实施细节完全隐藏起来。除此以外,从客户程序员的角度来看,一个接口的范围没有意义的,因为他们不能访问不属于公共接口类的任何额外方法。这样一来,Java编译器也有机会生成效率更高的代码。
普通(非内部)类不可设为private或protected——只允许public或者“友好的”。
注意Contents不必成为一个抽象类。在这儿也可以使用一个普通类,但这种设计最典型的起点依然是一个“接口”。
7.6.2 方法和作用域中的内部类
至此,我们已基本理解了内部类的典型用途。对那些涉及内部类的代码,通常表达的都是“单纯”的内部类,非常简单,且极易理解。然而,内部类的设计非常全面,不可避免地会遇到它们的其他大量用法——假若我们在一个方法甚至一个任意的作用域内创建内部类。有两方面的原因促使我们这样做:
(1) 正如前面展示的那样,我们准备实现某种形式的接口,使自己能创建和返回一个句柄。
(2) 要解决一个复杂的问题,并希望创建一个类,用来辅助自己的程序方案。同时不愿意把它公开。
在下面这个例子里,将修改前面的代码,以便使用:
(1) 在一个方法内定义的类
(2) 在方法的一个作用域内定义的类
(3) 一个匿名类,用于实现一个接口
(4) 一个匿名类,用于扩展拥有非默认构建器的一个类
(5) 一个匿名类,用于执行字段初始化
(6) 一个匿名类,通过实例初始化进行构建(匿名内部类不可拥有构建器)
所有这些都在innerscopes包内发生。首先,来自前述代码的通用接口会在它们自己的文件里获得定义,使它们能在所有的例子里使用:
//: Destination.java package c07.innerscopes; interface Destination { String readLabel(); } ///:~
由于我们已认为Contents可能是一个抽象类,所以可采取下面这种更自然的形式,就象一个接口那样:
//: Contents.java package c07.innerscopes; interface Contents { int value(); } ///:~
尽管是含有具体实施细节的一个普通类,但Wrapping也作为它所有衍生类的一个通用“接口”使用:
//: Wrapping.java package c07.innerscopes; public class Wrapping { private int i; public Wrapping(int x) { i = x; } public int value() { return i; } } ///:~
在上面的代码中,我们注意到Wrapping有一个要求使用自变量的构建器,这就使情况变得更加有趣了。
第一个例子展示了如何在一个方法的作用域(而不是另一个类的作用域)中创建一个完整的类:
//: Parcel4.java // Nesting a class within a method package c07.innerscopes; public class Parcel4 { public Destination dest(String s) { class PDestination implements Destination { private String label; private PDestination(String whereTo) { label = whereTo; } public String readLabel() { return label; } } return new PDestination(s); } public static void main(String[] args) { Parcel4 p = new Parcel4(); Destination d = p.dest("Tanzania"); } } ///:~
PDestination类属于dest()的一部分,而不是Parcel4的一部分(同时注意可为相同目录内每个类内部的一个内部类使用类标识符PDestination,这样做不会发生命名的冲突)。因此,PDestination不可从dest()的外部访问。请注意在返回语句中发生的上溯造型——除了指向基础类Destination的一个句柄之外,没有任何东西超出dest()的边界之外。当然,不能由于类PDestination的名字置于dest()内部,就认为在dest()返回之后PDestination不是一个有效的对象。
下面这个例子展示了如何在任意作用域内嵌套一个内部类:
//: Parcel5.java // Nesting a class within a scope package c07.innerscopes; public class Parcel5 { private void internalTracking(boolean b) { if(b) { class TrackingSlip { private String id; TrackingSlip(String s) { id = s; } String getSlip() { return id; } } TrackingSlip ts = new TrackingSlip("slip"); String s = ts.getSlip(); } // Can't use it here! Out of scope: //! TrackingSlip ts = new TrackingSlip("x"); } public void track() { internalTracking(true); } public static void main(String[] args) { Parcel5 p = new Parcel5(); p.track(); } } ///:~
TrackingSlip类嵌套于一个if语句的作用域内。这并不意味着类是有条件创建的——它会随同其他所有东西得到编译。然而,在定义它的那个作用域之外,它是不可使用的。除这些以外,它看起来和一个普通类并没有什么区别。
下面这个例子看起来有些奇怪:
//: Parcel6.java // A method that returns an anonymous inner class package c07.innerscopes; public class Parcel6 { public Contents cont() { return new Contents() { private int i = 11; public int value() { return i; } }; // Semicolon required in this case } public static void main(String[] args) { Parcel6 p = new Parcel6(); Contents c = p.cont(); } } ///:~
cont()方法同时合并了返回值的创建代码,以及用于表示那个返回值的类。除此以外,这个类是匿名的——它没有名字。而且看起来似乎更让人摸不着头脑的是,我们准备创建一个Contents对象:
return new Contents()
但在这之后,在遇到分号之前,我们又说:“等一等,让我先在一个类定义里再耍一下花招”:
return new Contents() {
private int i = 11;
public int value() { return i; }
};
这种奇怪的语法要表达的意思是:“创建从Contents衍生出来的匿名类的一个对象”。由new表达式返回的句柄会自动上溯造型成一个Contents句柄。匿名内部类的语法其实要表达的是:
class MyContents extends Contents {
private int i = 11;
public int value() { return i; }
}
return new MyContents();
在匿名内部类中,Contents是用一个默认构建器创建的。下面这段代码展示了基础类需要含有自变量的一个构建器时做的事情:
//: Parcel7.java // An anonymous inner class that calls the // base-class constructor package c07.innerscopes; public class Parcel7 { public Wrapping wrap(int x) { // Base constructor call: return new Wrapping(x) { public int value() { return super.value() * 47; } }; // Semicolon required } public static void main(String[] args) { Parcel7 p = new Parcel7(); Wrapping w = p.wrap(10); } } ///:~
也就是说,我们将适当的自变量简单地传递给基础类构建器,在这儿表现为在“new Wrapping(x)”中传递x。匿名类不能拥有一个构建器,这和在调用super()时的常规做法不同。
在前述的两个例子中,分号并不标志着类主体的结束(和C++不同)。相反,它标志着用于包含匿名类的那个表达式的结束。因此,它完全等价于在其他任何地方使用分号。
若想对匿名内部类的一个对象进行某种形式的初始化,此时会出现什么情况呢?由于它是匿名的,没有名字赋给构建器,所以我们不能拥有一个构建器。然而,我们可在定义自己的字段时进行初始化:
//: Parcel8.java // An anonymous inner class that performs // initialization. A briefer version // of Parcel5.java. package c07.innerscopes; public class Parcel8 { // Argument must be final to use inside // anonymous inner class: public Destination dest(final String dest) { return new Destination() { private String label = dest; public String readLabel() { return label; } }; } public static void main(String[] args) { Parcel8 p = new Parcel8(); Destination d = p.dest("Tanzania"); } } ///:~
若试图定义一个匿名内部类,并想使用在匿名内部类外部定义的一个对象,则编译器要求外部对象为final属性。这正是我们将dest()的自变量设为final的原因。如果忘记这样做,就会得到一条编译期出错提示。
只要自己只是想分配一个字段,上述方法就肯定可行。但假如需要采取一些类似于构建器的行动,又应怎样操作呢?通过Java 1.1的实例初始化,我们可以有效地为一个匿名内部类创建一个构建器:
//: Parcel9.java // Using "instance initialization" to perform // construction on an anonymous inner class package c07.innerscopes; public class Parcel9 { public Destination dest(final String dest, final float price) { return new Destination() { private int cost; // Instance initialization for each object: { cost = Math.round(price); if(cost > 100) System.out.println("Over budget!"); } private String label = dest; public String readLabel() { return label; } }; } public static void main(String[] args) { Parcel9 p = new Parcel9(); Destination d = p.dest("Tanzania", 101.395F); } } ///:~
在实例初始化模块中,我们可看到代码不能作为类初始化模块(即if语句)的一部分执行。所以实际上,一个实例初始化模块就是一个匿名内部类的构建器。当然,它的功能是有限的;我们不能对实例初始化模块进行过载处理,所以只能拥有这些构建器的其中一个。
7.6.3 链接到外部类
迄今为止,我们见到的内部类好象仅仅是一种名字隐藏以及代码组织方案。尽管这些功能非常有用,但似乎并不特别引人注目。然而,我们还忽略了另一个重要的事实。创建自己的内部类时,那个类的对象同时拥有指向封装对象(这些对象封装或生成了内部类)的一个链接。所以它们能访问那个封装对象的成员——毋需取得任何资格。除此以外,内部类拥有对封装类所有元素的访问权限(注释②)。下面这个例子阐示了这个问题:
//: Sequence.java // Holds a sequence of Objects interface Selector { boolean end(); Object current(); void next(); } public class Sequence { private Object[] o; private int next = 0; public Sequence(int size) { o = new Object[size]; } public void add(Object x) { if(next < o.length) { o[next] = x; next++; } } private class SSelector implements Selector { int i = 0; public boolean end() { return i == o.length; } public Object current() { return o[i]; } public void next() { if(i < o.length) i++; } } public Selector getSelector() { return new SSelector(); } public static void main(String[] args) { Sequence s = new Sequence(10); for(int i = 0; i < 10; i++) s.add(Integer.toString(i)); Selector sl = s.getSelector(); while(!sl.end()) { System.out.println((String)sl.current()); sl.next(); } } } ///:~
②:这与C++“嵌套类”的设计颇有不同,后者只是一种单纯的名字隐藏机制。在C++中,没有指向一个封装对象的链接,也不存在默认的访问权限。
其中,Sequence只是一个大小固定的对象数组,有一个类将其封装在内部。我们调用add(),以便将一个新对象添加到Sequence末尾(如果还有地方的话)。为了取得Sequence中的每一个对象,要使用一个名为Selector的接口,它使我们能够知道自己是否位于最末尾(end()),能观看当前对象(current() Object),以及能够移至Sequence内的下一个对象(next() Object)。由于Selector是一个接口,所以其他许多类都能用它们自己的方式实现接口,而且许多方法都能将接口作为一个自变量使用,从而创建一般的代码。
在这里,SSelector是一个私有类,它提供了Selector功能。在main()中,大家可看到Sequence的创建过程,在它后面是一系列字串对象的添加。随后,通过对getSelector()的一个调用生成一个Selector。并用它在Sequence中移动,同时选择每一个项目。
从表面看,SSelector似乎只是另一个内部类。但不要被表面现象迷惑。请注意观察end(),current()以及next(),它们每个方法都引用了o。o是个不属于SSelector一部分的句柄,而是位于封装类里的一个private字段。然而,内部类可以从封装类访问方法与字段,就象已经拥有了它们一样。这一特征对我们来说是非常方便的,就象在上面的例子中看到的那样。
因此,我们现在知道一个内部类可以访问封装类的成员。这是如何实现的呢?内部类必须拥有对封装类的特定对象的一个引用,而封装类的作用就是创建这个内部类。随后,当我们引用封装类的一个成员时,就利用那个(隐藏)的引用来选择那个成员。幸运的是,编译器会帮助我们照管所有这些细节。但我们现在也可以理解内部类的一个对象只能与封装类的一个对象联合创建。在这个创建过程中,要求对封装类对象的句柄进行初始化。若不能访问那个句柄,编译器就会报错。进行所有这些操作的时候,大多数时候都不要求程序员的任何介入。
7.6.4 static内部类
为正确理解static在应用于内部类时的含义,必须记住内部类的对象默认持有创建它的那个封装类的一个对象的句柄。然而,假如我们说一个内部类是static的,这种说法却是不成立的。static内部类意味着:
(1) 为创建一个static内部类的对象,我们不需要一个外部类对象。
(2) 不能从static内部类的一个对象中访问一个外部类对象。
但在存在一些限制:由于static成员只能位于一个类的外部级别,所以内部类不可拥有static数据或static内部类。
倘若为了创建内部类的对象而不需要创建外部类的一个对象,那么可将所有东西都设为static。为了能正常工作,同时也必须将内部类设为static。如下所示:
//: Parcel10.java // Static inner classes package c07.parcel10; abstract class Contents { abstract public int value(); } interface Destination { String readLabel(); } public class Parcel10 { private static class PContents extends Contents { private int i = 11; public int value() { return i; } } protected static class PDestination implements Destination { private String label; private PDestination(String whereTo) { label = whereTo; } public String readLabel() { return label; } } public static Destination dest(String s) { return new PDestination(s); } public static Contents cont() { return new PContents(); } public static void main(String[] args) { Contents c = cont(); Destination d = dest("Tanzania"); } } ///:~
在main()中,我们不需要Parcel10的对象;相反,我们用常规的语法来选择一个static成员,以便调用将句柄返回Contents和Destination的方法。
通常,我们不在一个接口里设置任何代码,但static内部类可以成为接口的一部分。由于类是“静态”的,所以它不会违反接口的规则——static内部类只位于接口的命名空间内部:
//: IInterface.java // Static inner classes inside interfaces interface IInterface { static class Inner { int i, j, k; public Inner() {} void f() {} } } ///:~
在本书早些时候,我建议大家在每个类里都设置一个main(),将其作为那个类的测试床使用。这样做的一个缺点就是额外代码的数量太多。若不愿如此,可考虑用一个static内部类容纳自己的测试代码。如下所示:
//: TestBed.java // Putting test code in a static inner class class TestBed { TestBed() {} void f() { System.out.println("f()"); } public static class Tester { public static void main(String[] args) { TestBed t = new TestBed(); t.f(); } } } ///:~
这样便生成一个独立的、名为TestBed$Tester的类(为运行程序,请使用“java TestBed$Tester”命令)。可将这个类用于测试,但不需在自己的最终发行版本中包含它。
7.6.5 引用外部类对象
若想生成外部类对象的句柄,就要用一个点号以及一个this来命名外部类。举个例子来说,在Sequence.SSelector类中,它的所有方法都能产生外部类Sequence的存储句柄,方法是采用Sequence.this的形式。结果获得的句柄会自动具备正确的类型(这会在编译期间检查并核实,所以不会出现运行期的开销)。
有些时候,我们想告诉其他某些对象创建它某个内部类的一个对象。为达到这个目的,必须在new表达式中提供指向其他外部类对象的一个句柄,就象下面这样:
//: Parcel11.java // Creating inner classes package c07.parcel11; public class Parcel11 { class Contents { private int i = 11; public int value() { return i; } } class Destination { private String label; Destination(String whereTo) { label = whereTo; } String readLabel() { return label; } } public static void main(String[] args) { Parcel11 p = new Parcel11(); // Must use instance of outer class // to create an instances of the inner class: Parcel11.Contents c = p.new Contents(); Parcel11.Destination d = p.new Destination("Tanzania"); } } ///:~
为直接创建内部类的一个对象,不能象大家或许猜想的那样——采用相同的形式,并引用外部类名Parcel11。此时,必须利用外部类的一个对象生成内部类的一个对象:
Parcel11.Contents c = p.new Contents();
因此,除非已拥有外部类的一个对象,否则不可能创建内部类的一个对象。这是由于内部类的对象已同创建它的外部类的对象“默默”地连接到一起。然而,如果生成一个static内部类,就不需要指向外部类对象的一个句柄。
7.6.6 从内部类继承
由于内部类构建器必须同封装类对象的一个句柄联系到一起,所以从一个内部类继承的时候,情况会稍微变得有些复杂。这儿的问题是封装类的“秘密”句柄必须获得初始化,而且在衍生类中不再有一个默认的对象可以连接。解决这个问题的办法是采用一种特殊的语法,明确建立这种关联:
//: InheritInner.java // Inheriting an inner class class WithInner { class Inner {} } public class InheritInner extends WithInner.Inner { //! InheritInner() {} // Won't compile InheritInner(WithInner wi) { wi.super(); } public static void main(String[] args) { WithInner wi = new WithInner(); InheritInner ii = new InheritInner(wi); } } ///:~
从中可以看到,InheritInner只对内部类进行了扩展,没有扩展外部类。但在需要创建一个构建器的时候,默认对象已经没有意义,我们不能只是传递封装对象的一个句柄。此外,必须在构建器中采用下述语法:
enclosingClassHandle.super();
它提供了必要的句柄,以便程序正确编译。
7.6.7 内部类可以覆盖吗?
若创建一个内部类,然后从封装类继承,并重新定义内部类,那么会出现什么情况呢?也就是说,我们有可能覆盖一个内部类吗?这看起来似乎是一个非常有用的概念,但“覆盖”一个内部类——好象它是外部类的另一个方法——这一概念实际不能做任何事情:
//: BigEgg.java // An inner class cannot be overriden // like a method class Egg { protected class Yolk { public Yolk() { System.out.println("Egg.Yolk()"); } } private Yolk y; public Egg() { System.out.println("New Egg()"); y = new Yolk(); } } public class BigEgg extends Egg { public class Yolk { public Yolk() { System.out.println("BigEgg.Yolk()"); } } public static void main(String[] args) { new BigEgg(); } } ///:~
默认构建器是由编译器自动合成的,而且会调用基础类的默认构建器。大家或许会认为由于准备创建一个BigEgg,所以会使用Yolk的“被覆盖”版本。但实际情况并非如此。输出如下:
New Egg()
Egg.Yolk()
这个例子简单地揭示出当我们从外部类继承的时候,没有任何额外的内部类继续下去。然而,仍然有可能“明确”地从内部类继承:
//: BigEgg2.java // Proper inheritance of an inner class class Egg2 { protected class Yolk { public Yolk() { System.out.println("Egg2.Yolk()"); } public void f() { System.out.println("Egg2.Yolk.f()"); } } private Yolk y = new Yolk(); public Egg2() { System.out.println("New Egg2()"); } public void insertYolk(Yolk yy) { y = yy; } public void g() { y.f(); } } public class BigEgg2 extends Egg2 { public class Yolk extends Egg2.Yolk { public Yolk() { System.out.println("BigEgg2.Yolk()"); } public void f() { System.out.println("BigEgg2.Yolk.f()"); } } public BigEgg2() { insertYolk(new Yolk()); } public static void main(String[] args) { Egg2 e2 = new BigEgg2(); e2.g(); } } ///:~
现在,BigEgg2.Yolk明确地扩展了Egg2.Yolk,而且覆盖了它的方法。方法insertYolk()允许BigEgg2将它自己的某个Yolk对象上溯造型至Egg2的y句柄。所以当g()调用y.f()的时候,就会使用f()被覆盖版本。输出结果如下:
Egg2.Yolk()
New Egg2()
Egg2.Yolk()
BigEgg2.Yolk()
BigEgg2.Yolk.f()
对Egg2.Yolk()的第二个调用是BigEgg2.Yolk构建器的基础类构建器调用。调用
g()的时候,可发现使用的是f()的被覆盖版本。
7.6.8 内部类标识符
由于每个类都会生成一个.class文件,用于容纳与如何创建这个类型的对象有关的所有信息(这种信息产生了一个名为Class对象的元类),所以大家或许会猜到内部类也必须生成相应的.class文件,用来容纳与它们的Class对象有关的信息。这些文件或类的名字遵守一种严格的形式:先是封装类的名字,再跟随一个$,再跟随内部类的名字。例如,由InheritInner.java创建的.class文件包括:
InheritInner.class
WithInner$Inner.class
WithInner.class
如果内部类是匿名的,那么编译器会简单地生成数字,把它们作为内部类标识符使用。若内部类嵌套于其他内部类中,则它们的名字简单地追加在一个$以及外部类标识符的后面。
这种生成内部名称的方法除了非常简单和直观以外,也非常“健壮”,可适应大多数场合的要求(注释③)。由于它是Java的标准命名机制,所以产生的文件会自动具备“与平台无关”的能力(注意Java编译器会根据情况改变内部类,使其在不同的平台中能正常工作)。
③:但在另一方面,由于“$”也是Unix外壳的一个元字符,所以有时会在列出.class文件时遇到麻烦。对一家以Unix为基础的公司——Sun——来说,采取这种方案显得有些奇怪。我的猜测是他们根本没有仔细考虑这方面的问题,而是认为我们会将全部注意力自然地放在源码文件上。
7.6.9 为什么要用内部类:控制框架
到目前为止,大家已接触了对内部类的运作进行描述的大量语法与概念。但这些并不能真正说明内部类存在的原因。为什么Sun要如此麻烦地在Java 1.1里添加这样的一种基本语言特性呢?答案就在于我们在这里要学习的“控制框架”。
一个“应用程序框架”是指一个或一系列类,它们专门设计用来解决特定类型的问题。为应用应用程序框架,我们可从一个或多个类继承,并覆盖其中的部分方法。我们在覆盖方法中编写的代码用于定制由那些应用程序框架提供的常规方案,以便解决自己的实际问题。“控制框架”属于应用程序框架的一种特殊类型,受到对事件响应的需要的支配;主要用来响应事件的一个系统叫作“由事件驱动的系统”。在应用程序设计语言中,最重要的问题之一便是“图形用户界面”(GUI),它几乎完全是由事件驱动的。正如大家会在第13章学习的那样,Java 1.1 AWT属于一种控制框架,它通过内部类完美地解决了GUI的问题。
为理解内部类如何简化控制框架的创建与使用,可认为一个控制框架的工作就是在事件“就绪”以后执行它们。尽管“就绪”的意思很多,但在目前这种情况下,我们却是以计算机时钟为基础。随后,请认识到针对控制框架需要控制的东西,框架内并未包含任何特定的信息。首先,它是一个特殊的接口,描述了所有控制事件。它可以是一个抽象类,而非一个实际的接口。由于默认行为是根据时间控制的,所以部分实施细节可能包括:
//: Event.java // The common methods for any control event package c07.controller; abstract public class Event { private long evtTime; public Event(long eventTime) { evtTime = eventTime; } public boolean ready() { return System.currentTimeMillis() >= evtTime; } abstract public void action(); abstract public String description(); } ///:~
希望Event(事件)运行的时候,构建器即简单地捕获时间。同时ready()告诉我们何时该运行它。当然,ready()也可以在一个衍生类中被覆盖,将事件建立在除时间以外的其他东西上。
action()是事件就绪后需要调用的方法,而description()提供了与事件有关的文字信息。
下面这个文件包含了实际的控制框架,用于管理和触发事件。第一个类实际只是一个“助手”类,它的职责是容纳Event对象。可用任何适当的集合替换它。而且通过第8章的学习,大家会知道另一些集合可简化我们的工作,不需要我们编写这些额外的代码:
//: Controller.java // Along with Event, the generic // framework for all control systems: package c07.controller; // This is just a way to hold Event objects. class EventSet { private Event[] events = new Event[100]; private int index = 0; private int next = 0; public void add(Event e) { if(index >= events.length) return; // (In real life, throw exception) events[index++] = e; } public Event getNext() { boolean looped = false; int start = next; do { next = (next + 1) % events.length; // See if it has looped to the beginning: if(start == next) looped = true; // If it loops past start, the list // is empty: if((next == (start + 1) % events.length) && looped) return null; } while(events[next] == null); return events[next]; } public void removeCurrent() { events[next] = null; } } public class Controller { private EventSet es = new EventSet(); public void addEvent(Event c) { es.add(c); } public void run() { Event e; while((e = es.getNext()) != null) { if(e.ready()) { e.action(); System.out.println(e.description()); es.removeCurrent(); } } } } ///:~
EventSet可容纳100个事件(若在这里使用来自第8章的一个“真实”集合,就不必担心它的最大尺寸,因为它会根据情况自动改变大小)。index(索引)在这里用于跟踪下一个可用的空间,而next(下一个)帮助我们寻找列表中的下一个事件,了解自己是否已经循环到头。在对getNext()的调用中,这一点是至关重要的,因为一旦运行,Event对象就会从列表中删去(使用removeCurrent())。所以getNext()会在列表中向前移动时遇到“空洞”。
注意removeCurrent()并不只是指示一些标志,指出对象不再使用。相反,它将句柄设为null。这一点是非常重要的,因为假如垃圾收集器发现一个句柄仍在使用,就不会清除对象。若认为自己的句柄可能象现在这样被挂起,那么最好将其设为null,使垃圾收集器能够正常地清除它们。
Controller是进行实际工作的地方。它用一个EventSet容纳自己的Event对象,而且addEvent()允许我们向这个列表加入新事件。但最重要的方法是run()。该方法会在EventSet中遍历,搜索一个准备运行的Event对象——ready()。对于它发现ready()的每一个对象,都会调用action()方法,打印出description(),然后将事件从列表中删去。
注意在迄今为止的所有设计中,我们仍然不能准确地知道一个“事件”要做什么。这正是整个设计的关键;它怎样“将发生变化的东西同没有变化的东西区分开”?或者用我的话来讲,“改变的意图”造成了各类Event对象的不同行动。我们通过创建不同的Event子类,从而表达出不同的行动。
这里正是内部类大显身手的地方。它们允许我们做两件事情:
(1) 在单独一个类里表达一个控制框架应用的全部实施细节,从而完整地封装与那个实施有关的所有东西。内部类用于表达多种不同类型的action(),它们用于解决实际的问题。除此以外,后续的例子使用了private内部类,所以实施细节会完全隐藏起来,可以安全地修改。
(2) 内部类使我们具体的实施变得更加巧妙,因为能方便地访问外部类的任何成员。若不具备这种能力,代码看起来就可能没那么使人舒服,最后不得不寻找其他方法解决。
现在要请大家思考控制框架的一种具体实施方式,它设计用来控制温室(Greenhouse)功能(注释④)。每个行动都是完全不同的:控制灯光、供水以及温度自动调节的开与关,控制响铃,以及重新启动系统。但控制框架的设计宗旨是将不同的代码方便地隔离开。对每种类型的行动,都要继承一个新的Event内部类,并在action()内编写相应的控制代码。
④:由于某些特殊原因,这对我来说是一个经常需要解决的、非常有趣的问题;原来的例子在《C++ Inside & Out》一书里也出现过,但Java提供了一种更令人舒适的解决方案。
作为应用程序框架的一种典型行为,GreenhouseControls类是从Controller继承的:
//: GreenhouseControls.java // This produces a specific application of the // control system, all in a single class. Inner // classes allow you to encapsulate different // functionality for each type of event. package c07.controller; public class GreenhouseControls extends Controller { private boolean light = false; private boolean water = false; private String thermostat = "Day"; private class LightOn extends Event { public LightOn(long eventTime) { super(eventTime); } public void action() { // Put hardware control code here to // physically turn on the light. light = true; } public String description() { return "Light is on"; } } private class LightOff extends Event { public LightOff(long eventTime) { super(eventTime); } public void action() { // Put hardware control code here to // physically turn off the light. light = false; } public String description() { return "Light is off"; } } private class WaterOn extends Event { public WaterOn(long eventTime) { super(eventTime); } public void action() { // Put hardware control code here water = true; } public String description() { return "Greenhouse water is on"; } } private class WaterOff extends Event { public WaterOff(long eventTime) { super(eventTime); } public void action() { // Put hardware control code here water = false; } public String description() { return "Greenhouse water is off"; } } private class ThermostatNight extends Event { public ThermostatNight(long eventTime) { super(eventTime); } public void action() { // Put hardware control code here thermostat = "Night"; } public String description() { return "Thermostat on night setting"; } } private class ThermostatDay extends Event { public ThermostatDay(long eventTime) { super(eventTime); } public void action() { // Put hardware control code here thermostat = "Day"; } public String description() { return "Thermostat on day setting"; } } // An example of an action() that inserts a // new one of itself into the event list: private int rings; private class Bell extends Event { public Bell(long eventTime) { super(eventTime); } public void action() { // Ring bell every 2 seconds, rings times: System.out.println("Bing!"); if(--rings > 0) addEvent(new Bell( System.currentTimeMillis() + 2000)); } public String description() { return "Ring bell"; } } private class Restart extends Event { public Restart(long eventTime) { super(eventTime); } public void action() { long tm = System.currentTimeMillis(); // Instead of hard-wiring, you could parse // configuration information from a text // file here: rings = 5; addEvent(new ThermostatNight(tm)); addEvent(new LightOn(tm + 1000)); addEvent(new LightOff(tm + 2000)); addEvent(new WaterOn(tm + 3000)); addEvent(new WaterOff(tm + 8000)); addEvent(new Bell(tm + 9000)); addEvent(new ThermostatDay(tm + 10000)); // Can even add a Restart object! addEvent(new Restart(tm + 20000)); } public String description() { return "Restarting system"; } } public static void main(String[] args) { GreenhouseControls gc = new GreenhouseControls(); long tm = System.currentTimeMillis(); gc.addEvent(gc.new Restart(tm)); gc.run(); } } ///:~
注意light(灯光)、water(供水)、thermostat(调温)以及rings都隶属于外部类GreenhouseControls,所以内部类可以毫无阻碍地访问那些字段。此外,大多数action()方法也涉及到某些形式的硬件控制,这通常都要求发出对非Java代码的调用。
大多数Event类看起来都是相似的,但Bell(铃)和Restart(重启)属于特殊情况。Bell会发出响声,若尚未响铃足够的次数,它会在事件列表里添加一个新的Bell对象,所以以后会再度响铃。请注意内部类看起来为什么总是类似于多重继承:Bell拥有Event的所有方法,而且也拥有外部类GreenhouseControls的所有方法。
Restart负责对系统进行初始化,所以会添加所有必要的事件。当然,一种更灵活的做法是避免进行“硬编码”,而是从一个文件里读入它们(第10章的一个练习会要求大家修改这个例子,从而达到这个目标)。由于Restart()仅仅是另一个Event对象,所以也可以在Restart.action()里添加一个Restart对象,使系统能够定期重启。在main()中,我们需要做的全部事情就是创建一个GreenhouseControls对象,并添加一个Restart对象,令其工作起来。
这个例子应该使大家对内部类的价值有一个更加深刻的认识,特别是在一个控制框架里使用它们的时候。此外,在第13章的后半部分,大家还会看到如何巧妙地利用内部类描述一个图形用户界面的行为。完成那里的学习后,对内部类的认识将上升到一个前所未有的新高度。
7.7 构建器和多形性
同往常一样,构建器与其他种类的方法是有区别的。在涉及到多形性的问题后,这种方法依然成立。尽管构建器并不具有多形性(即便可以使用一种“虚拟构建器”——将在第11章介绍),但仍然非常有必要理解构建器如何在复杂的分级结构中以及随同多形性使用。这一理解将有助于大家避免陷入一些令人不快的纠纷。
7.7.1 构建器的调用顺序
构建器调用的顺序已在第4章进行了简要说明,但那是在继承和多形性问题引入之前说的话。
用于基础类的构建器肯定在一个衍生类的构建器中调用,而且逐渐向上链接,使每个基础类使用的构建器都能得到调用。之所以要这样做,是由于构建器负有一项特殊任务:检查对象是否得到了正确的构建。一个衍生类只能访问它自己的成员,不能访问基础类的成员(这些成员通常都具有private属性)。只有基础类的构建器在初始化自己的元素时才知道正确的方法以及拥有适当的权限。所以,必须令所有构建器都得到调用,否则整个对象的构建就可能不正确。那正是编译器为什么要强迫对衍生类的每个部分进行构建器调用的原因。在衍生类的构建器主体中,若我们没有明确指定对一个基础类构建器的调用,它就会“默默”地调用默认构建器。如果不存在默认构建器,编译器就会报告一个错误(若某个类没有构建器,编译器会自动组织一个默认构建器)。
下面让我们看看一个例子,它展示了按构建顺序进行合成、继承以及多形性的效果:
//: Sandwich.java // Order of constructor calls 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()"); } } class Sandwich extends PortableLunch { Bread b = new Bread(); Cheese c = new Cheese(); Lettuce l = new Lettuce(); Sandwich() { System.out.println("Sandwich()"); } public static void main(String[] args) { new Sandwich(); } } ///:~
这个例子在其他类的外部创建了一个复杂的类,而且每个类都有一个构建器对自己进行了宣布。其中最重要的类是Sandwich,它反映出了三个级别的继承(若将从Object的默认继承算在内,就是四级)以及三个成员对象。在main()里创建了一个Sandwich对象后,输出结果如下:
Meal() Lunch() PortableLunch() Bread() Cheese() Lettuce() Sandwich()
这意味着对于一个复杂的对象,构建器的调用遵照下面的顺序:
(1) 调用基础类构建器。这个步骤会不断重复下去,首先得到构建的是分级结构的根部,然后是下一个衍生类,等等。直到抵达最深一层的衍生类。
(2) 按声明顺序调用成员初始化模块。
(3) 调用衍生构建器的主体。
构建器调用的顺序是非常重要的。进行继承时,我们知道关于基础类的一切,并且能访问基础类的任何public和protected成员。这意味着当我们在衍生类的时候,必须能假定基础类的所有成员都是有效的。采用一种标准方法,构建行动已经进行,所以对象所有部分的成员均已得到构建。但在构建器内部,必须保证使用的所有成员都已构建。为达到这个要求,唯一的办法就是首先调用基础类构建器。然后在进入衍生类构建器以后,我们在基础类能够访问的所有成员都已得到初始化。此外,所有成员对象(亦即通过合成方法置于类内的对象)在类内进行定义的时候(比如上例中的b,c和l),由于我们应尽可能地对它们进行初始化,所以也应保证构建器内部的所有成员均为有效。若坚持按这一规则行事,会有助于我们确定所有基础类成员以及当前对象的成员对象均已获得正确的初始化。但不幸的是,这种做法并不适用于所有情况,这将在下一节具体说明。
7.7.2 继承和finalize()
通过“合成”方法创建新类时,永远不必担心对那个类的成员对象的收尾工作。每个成员都是一个独立的对象,所以会得到正常的垃圾收集以及收尾处理——无论它是不是不自己某个类一个成员。但在进行初始化的时候,必须覆盖衍生类中的finalize()方法——如果已经设计了某个特殊的清除进程,要求它必须作为垃圾收集的一部分进行。覆盖衍生类的finalize()时,务必记住调用finalize()的基础类版本。否则,基础类的初始化根本不会发生。下面这个例子便是明证:
//: Frog.java // Testing finalize with inheritance class DoBaseFinalization { public static boolean flag = false; } class Characteristic { String s; Characteristic(String c) { s = c; System.out.println( "Creating Characteristic " + s); } protected void finalize() { System.out.println( "finalizing Characteristic " + s); } } class LivingCreature { Characteristic p = new Characteristic("is alive"); LivingCreature() { System.out.println("LivingCreature()"); } protected void finalize() { System.out.println( "LivingCreature finalize"); // Call base-class version LAST! if(DoBaseFinalization.flag) try { super.finalize(); } catch(Throwable t) {} } } class Animal extends LivingCreature { Characteristic p = new Characteristic("has heart"); Animal() { System.out.println("Animal()"); } protected void finalize() { System.out.println("Animal finalize"); if(DoBaseFinalization.flag) try { super.finalize(); } catch(Throwable t) {} } } class Amphibian extends Animal { Characteristic p = new Characteristic("can live in water"); Amphibian() { System.out.println("Amphibian()"); } protected void finalize() { System.out.println("Amphibian finalize"); if(DoBaseFinalization.flag) try { super.finalize(); } catch(Throwable t) {} } } public class Frog extends Amphibian { Frog() { System.out.println("Frog()"); } protected void finalize() { System.out.println("Frog finalize"); if(DoBaseFinalization.flag) try { super.finalize(); } catch(Throwable t) {} } public static void main(String[] args) { if(args.length != 0 && args[0].equals("finalize")) DoBaseFinalization.flag = true; else System.out.println("not finalizing bases"); new Frog(); // Instantly becomes garbage System.out.println("bye!"); // Must do this to guarantee that all // finalizers will be called: System.runFinalizersOnExit(true); } } ///:~
DoBasefinalization类只是简单地容纳了一个标志,向分级结构中的每个类指出是否应调用super.finalize()。这个标志的设置建立在命令行参数的基础上,所以能够在进行和不进行基础类收尾工作的前提下查看行为。
分级结构中的每个类也包含了Characteristic类的一个成员对象。大家可以看到,无论是否调用了基础类收尾模块,Characteristic成员对象都肯定会得到收尾(清除)处理。
每个被覆盖的finalize()至少要拥有对protected成员的访问权力,因为Object类中的finalize()方法具有protected属性,而编译器不允许我们在继承过程中消除访问权限(“友好的”比“受到保护的”具有更小的访问权限)。
在Frog.main()中,DoBaseFinalization标志会得到配置,而且会创建单独一个Frog对象。请记住垃圾收集(特别是收尾工作)可能不会针对任何特定的对象发生,所以为了强制采取这一行动,System.runFinalizersOnExit(true)添加了额外的开销,以保证收尾工作的正常进行。若没有基础类初始化,则输出结果是:
not finalizing bases Creating Characteristic is alive LivingCreature() Creating Characteristic has heart Animal() Creating Characteristic can live in water Amphibian() Frog() bye! Frog finalize finalizing Characteristic is alive finalizing Characteristic has heart finalizing Characteristic can live in water
从中可以看出确实没有为基础类Frog调用收尾模块。但假如在命令行加入“finalize”自变量,则会获得下述结果:
Creating Characteristic is alive LivingCreature() Creating Characteristic has heart Animal() Creating Characteristic can live in water Amphibian() Frog() bye! Frog finalize Amphibian finalize Animal finalize LivingCreature finalize finalizing Characteristic is alive finalizing Characteristic has heart finalizing Characteristic can live in water
尽管成员对象按照与它们创建时相同的顺序进行收尾,但从技术角度说,并没有指定对象收尾的顺序。但对于基础类,我们可对收尾的顺序进行控制。采用的最佳顺序正是在这里采用的顺序,它与初始化顺序正好相反。按照与C++中用于“破坏器”相同的形式,我们应该首先执行衍生类的收尾,再是基础类的收尾。这是由于衍生类的收尾可能调用基础类中相同的方法,要求基础类组件仍然处于活动状态。因此,必须提前将它们清除(破坏)。
7.7.3 构建器内部的多形性方法的行为
构建器调用的分级结构(顺序)为我们带来了一个有趣的问题,或者说让我们进入了一种进退两难的局面。若当前位于一个构建器的内部,同时调用准备构建的那个对象的一个动态绑定方法,那么会出现什么情况呢?在原始的方法内部,我们完全可以想象会发生什么——动态绑定的调用会在运行期间进行解析,因为对象不知道它到底从属于方法所在的那个类,还是从属于从它衍生出来的某些类。为保持一致性,大家也许会认为这应该在构建器内部发生。
但实际情况并非完全如此。若调用构建器内部一个动态绑定的方法,会使用那个方法被覆盖的定义。然而,产生的效果可能并不如我们所愿,而且可能造成一些难于发现的程序错误。
从概念上讲,构建器的职责是让对象实际进入存在状态。在任何构建器内部,整个对象可能只是得到部分组织——我们只知道基础类对象已得到初始化,但却不知道哪些类已经继承。然而,一个动态绑定的方法调用却会在分级结构里“向前”或者“向外”前进。它调用位于衍生类里的一个方法。如果在构建器内部做这件事情,那么对于调用的方法,它要操纵的成员可能尚未得到正确的初始化——这显然不是我们所希望的。
通过观察下面这个例子,这个问题便会昭然若揭:
//: PolyConstructors.java // Constructors and polymorphism // don't produce what you might expect. abstract class Glyph { abstract void draw(); Glyph() { System.out.println("Glyph() before draw()"); draw(); System.out.println("Glyph() after draw()"); } } class RoundGlyph extends Glyph { int radius = 1; RoundGlyph(int r) { radius = r; System.out.println( "RoundGlyph.RoundGlyph(), radius = " + radius); } void draw() { System.out.println( "RoundGlyph.draw(), radius = " + radius); } } public class PolyConstructors { public static void main(String[] args) { new RoundGlyph(5); } } ///:~
在Glyph中,draw()方法是“抽象的”(abstract),所以它可以被其他方法覆盖。事实上,我们在RoundGlyph中不得不对其进行覆盖。但Glyph构建器会调用这个方法,而且调用会在RoundGlyph.draw()中止,这看起来似乎是有意的。但请看看输出结果:
Glyph() before draw() RoundGlyph.draw(), radius = 0 Glyph() after draw() RoundGlyph.RoundGlyph(), radius = 5
当Glyph的构建器调用draw()时,radius的值甚至不是默认的初始值1,而是0。这可能是由于一个点号或者屏幕上根本什么都没有画而造成的。这样就不得不开始查找程序中的错误,试着找出程序不能工作的原因。
前一节讲述的初始化顺序并不十分完整,而那是解决问题的关键所在。初始化的实际过程是这样的:
(1) 在采取其他任何操作之前,为对象分配的存储空间初始化成二进制零。
(2) 就象前面叙述的那样,调用基础类构建器。此时,被覆盖的draw()方法会得到调用(的确是在RoundGlyph构建器调用之前),此时会发现radius的值为0,这是由于步骤(1)造成的。
(3) 按照原先声明的顺序调用成员初始化代码。
(4) 调用衍生类构建器的主体。
采取这些操作要求有一个前提,那就是所有东西都至少要初始化成零(或者某些特殊数据类型与“零”等价的值),而不是仅仅留作垃圾。其中包括通过“合成”技术嵌入一个类内部的对象句柄。如果假若忘记初始化那个句柄,就会在运行期间出现违例事件。其他所有东西都会变成零,这在观看结果时通常是一个严重的警告信号。
在另一方面,应对这个程序的结果提高警惕。从逻辑的角度说,我们似乎已进行了无懈可击的设计,所以它的错误行为令人非常不可思议。而且没有从编译器那里收到任何报错信息(C++在这种情况下会表现出更合理的行为)。象这样的错误会很轻易地被人忽略,而且要花很长的时间才能找出。
因此,设计构建器时一个特别有效的规则是:用尽可能简单的方法使对象进入就绪状态;如果可能,避免调用任何方法。在构建器内唯一能够安全调用的是在基础类中具有final属性的那些方法(也适用于private方法,它们自动具有final属性)。这些方法不能被覆盖,所以不会出现上述潜在的问题。
7.8 通过继承进行设计
学习了多形性的知识后,由于多形性是如此“聪明”的一种工具,所以看起来似乎所有东西都应该继承。但假如过度使用继承技术,也会使自己的设计变得不必要地复杂起来。事实上,当我们以一个现成类为基础建立一个新类时,如首先选择继承,会使情况变得异常复杂。
一个更好的思路是首先选择“合成”——如果不能十分确定自己应使用哪一个。合成不会强迫我们的程序设计进入继承的分级结构中。同时,合成显得更加灵活,因为可以动态选择一种类型(以及行为),而继承要求在编译期间准确地知道一种类型。下面这个例子对此进行了阐释:
//: Transmogrify.java // Dynamically changing the behavior of // an object via composition. interface Actor { void act(); } class HappyActor implements Actor { public void act() { System.out.println("HappyActor"); } } class SadActor implements Actor { public void act() { System.out.println("SadActor"); } } class Stage { Actor a = new HappyActor(); void change() { a = new SadActor(); } void go() { a.act(); } } public class Transmogrify { public static void main(String[] args) { Stage s = new Stage(); s.go(); // Prints "HappyActor" s.change(); s.go(); // Prints "SadActor" } } ///:~
在这里,一个Stage对象包含了指向一个Actor的句柄,后者被初始化成一个HappyActor对象。这意味着go()会产生特定的行为。但由于句柄在运行期间可以重新与一个不同的对象绑定或结合起来,所以SadActor对象的句柄可在a中得到替换,然后由go()产生的行为发生改变。这样一来,我们在运行期间就获得了很大的灵活性。与此相反,我们不能在运行期间换用不同的形式来进行继承;它要求在编译期间完全决定下来。
一条常规的设计准则是:用继承表达行为间的差异,并用成员变量表达状态的变化。在上述例子中,两者都得到了应用:继承了两个不同的类,用于表达act()方法的差异;而Stage通过合成技术允许它自己的状态发生变化。在这种情况下,那种状态的改变同时也产生了行为的变化。
7.8.1 纯继承与扩展
学习继承时,为了创建继承分级结构,看来最明显的方法是采取一种“纯粹”的手段。也就是说,只有在基础类或“接口”中已建立的方法才可在衍生类中被覆盖,如下面这张图所示:
可将其描述成一种纯粹的“属于”关系,因为一个类的接口已规定了它到底“是什么”或者“属于什么”。通过继承,可保证所有衍生类都只拥有基础类的接口。如果按上述示意图操作,衍生出来的类除了基础类的接口之外,也不会再拥有其他什么。
可将其想象成一种“纯替换”,因为衍生类对象可为基础类完美地替换掉。使用它们的时候,我们根本没必要知道与子类有关的任何额外信息。如下所示:
也就是说,基础类可接收我们发给衍生类的任何消息,因为两者拥有完全一致的接口。我们要做的全部事情就是从衍生上溯造型,而且永远不需要回过头来检查对象的准确类型是什么。所有细节都已通过多形性获得了完美的控制。
若按这种思路考虑问题,那么一个纯粹的“属于”关系似乎是唯一明智的设计方法,其他任何设计方法都会导致混乱不清的思路,而且在定义上存在很大的困难。但这种想法又属于另一个极端。经过细致的研究,我们发现扩展接口对于一些特定问题来说是特别有效的方案。可将其称为“类似于”关系,因为扩展后的衍生类“类似于”基础类——它们有相同的基础接口——但它增加了一些特性,要求用额外的方法加以实现。如下所示:
尽管这是一种有用和明智的做法(由具体的环境决定),但它也有一个缺点:衍生类中对接口扩展的那一部分不可在基础类中使用。所以一旦上溯造型,就不可再调用新方法:
若在此时不进行上溯造型,则不会出现此类问题。但在许多情况下,都需要重新核实对象的准确类型,使自己能访问那个类型的扩展方法。在后面的小节里,我们具体讲述了这是如何实现的。
7.8.2 下溯造型与运行期类型标识
由于我们在上溯造型(在继承结构中向上移动)期间丢失了具体的类型信息,所以为了获取具体的类型信息——亦即在分级结构中向下移动——我们必须使用 “下溯造型”技术。然而,我们知道一个上溯造型肯定是安全的;基础类不可能再拥有一个比衍生类更大的接口。因此,我们通过基础类接口发送的每一条消息都肯定能够接收到。但在进行下溯造型的时候,我们(举个例子来说)并不真的知道一个几何形状实际是一个圆,它完全可能是一个三角形、方形或者其他形状。
为解决这个问题,必须有一种办法能够保证下溯造型正确进行。只有这样,我们才不会冒然造型成一种错误的类型,然后发出一条对象不可能收到的消息。这样做是非常不安全的。
在某些语言中(如C++),为了进行保证“类型安全”的下溯造型,必须采取特殊的操作。但在Java中,所有造型都会自动得到检查和核实!所以即使我们只是进行一次普通的括弧造型,进入运行期以后,仍然会毫无留情地对这个造型进行检查,保证它的确是我们希望的那种类型。如果不是,就会得到一个ClassCastException(类造型违例)。在运行期间对类型进行检查的行为叫作“运行期类型标识”(RTTI)。下面这个例子向大家演示了RTTI的行为:
//: RTTI.java // Downcasting & Run-Time Type // Identification (RTTI) import java.util.*; class Useful { public void f() {} public void g() {} } class MoreUseful extends Useful { public void f() {} public void g() {} public void u() {} public void v() {} public void w() {} } public class RTTI { public static void main(String[] args) { Useful[] x = { new Useful(), new MoreUseful() }; x[0].f(); x[1].g(); // Compile-time: method not found in Useful: //! x[1].u(); ((MoreUseful)x[1]).u(); // Downcast/RTTI ((MoreUseful)x[0]).u(); // Exception thrown } } ///:~
和在示意图中一样,MoreUseful(更有用的)对Useful(有用的)的接口进行了扩展。但由于它是继承来的,所以也能上溯造型到一个Useful。我们可看到这会在对数组x(位于main()中)进行初始化的时候发生。由于数组中的两个对象都属于Useful类,所以可将f()和g()方法同时发给它们两个。而且假如试图调用u()(它只存在于MoreUseful),就会收到一条编译期出错提示。
若想访问一个MoreUseful对象的扩展接口,可试着进行下溯造型。如果它是正确的类型,这一行动就会成功。否则,就会得到一个ClassCastException。我们不必为这个违例编写任何特殊的代码,因为它指出的是一个可能在程序中任何地方发生的一个编程错误。
RTTI的意义远不仅仅反映在造型处理上。例如,在试图下溯造型之前,可通过一种方法了解自己处理的是什么类型。整个第11章都在讲述Java运行期类型标识的方方面面。
7.9 总结
“多形性”意味着“不同的形式”。在面向对象的程序设计中,我们有相同的外观(基础类的通用接口)以及使用那个外观的不同形式:动态绑定或组织的、不同版本的方法。
通过这一章的学习,大家已知道假如不利用数据抽象以及继承技术,就不可能理解、甚至去创建多形性的一个例子。多形性是一种不可独立应用的特性(就象一个switch语句),只可与其他元素协同使用。我们应将其作为类总体关系的一部分来看待。人们经常混淆Java其他的、非面向对象的特性,比如方法过载等,这些特性有时也具有面向对象的某些特征。但不要被愚弄:如果以后没有绑定,就不成其为多形性。
为使用多形性乃至面向对象的技术,特别是在自己的程序中,必须将自己的编程视野扩展到不仅包括单独一个类的成员和消息,也要包括类与类之间的一致性以及它们的关系。尽管这要求学习时付出更多的精力,但却是非常值得的,因为只有这样才可真正有效地加快自己的编程速度、更好地组织代码、更容易做出包容面广的程序以及更易对自己的代码进行维护与扩展。
7.10 练习
(1) 创建Rodent(啮齿动物):Mouse(老鼠),Gerbil(鼹鼠),Hamster(大颊鼠)等的一个继承分级结构。在基础类中,提供适用于所有Rodent的方法,并在衍生类中覆盖它们,从而根据不同类型的Rodent采取不同的行动。创建一个Rodent数组,在其中填充不同类型的Rodent,然后调用自己的基础类方法,看看会有什么情况发生。
(2) 修改练习1,使Rodent成为一个接口。
(3) 改正WindError.java中的问题。
(4) 在GreenhouseControls.java中,添加Event内部类,使其能打开和关闭风扇。