在面向对象的程序设计语言中,多态是继数据抽象和继承之后的第三种基本特征。
多态通过分离做什么和怎么做,从另一个角度将接口和实现分离开来,多态不但能够改善代码的组织结构和
可读性,还能够创建可扩展的程序—即无论在项目最初创建时还是在需要添加新功能时都可以生长的程序。
“封装”通过合并特征和行为来创建新的数据类型。“实现隐藏”则通过将细节“私有化”把接口和实现
分离开来。这种类型的组织机制对那些拥有过程化程序设计背景的人来说,更容易理解,而多态的作用则似乎
消除类型之间的耦合关系,集成允许将对象视为它自己本身的类型或其基类型来加以处理,这种能力极为重要,
因为它允许将多种类型(从同一基类导出的)视为同一类型来处理,而同一份代码也就可以毫无差别地运行在
这些不同类型之上了。多态方法调用允许一种类型表现出与其他相似类型之间的区别,只要它们都是从同一
基类导出而来的。这种区别是根据方法行为的不同而表示出来的,虽然这些方法都可以通过同一个基类来
调用。
8.1再论向上转型
对象既可以作为它自己本身的类型使用,也可以作为它的基类型使用(向上转型–因为在继承树的画法中,
基类是放置在上方的 )
但是,这么做也有一个问题,看一下这个有关乐器的例子,
首先,既然几个例子都要演奏乐符(Note),我们就应该在包中单独创建一个Note类。
public enum Note{
MiDOLE_C,C_SHARP,B_FLAT;
}
这是一个枚举(enumeration)类,包含固定数目的可供选择的不变对象。不能再产生另外的对象,因为其构造器是私有的。
在下面的例子中,Wind是一种Instrument,因此可以继承Instrument类。
class Instrument {
public void play(Note n) {
System.out.println("Instrument.play()");
}
}
public class Wind extends Instrument {
// Redefine interface method:
public void play(Note n) {
System.out.println("Wind.play() " + note);
}
}
public class Music {
public static void tune(Instrument i) { //tune 曲调
// ...
i.play(Note.MIDDLE_C);
}
public static void main(String[] args) {
Wind flute = new Wind();
tune(flute); // Upcasting
}
}
结果: Wind.play()MIDDLE_C
Music.tune( )方法接受一个Instrument引用参数,同时也接受任何导出自Instrument的类。在Main()
方法中,当一个Wind引用传递到tune()方法时,就会出现这种情况,而不需要任何类型转换。这样做是允许的
――因为Wind从Instrument继承而来,所以Instrument的接口必定存在于Wind中。从Wind向上转型到
Instrument可能会“缩小”接口,但无论如何也不会比Instrument的全部接口更窄。
8.1.1 忘记对象类型
Music.java这个程序看起来似乎有些奇怪。为什么所有人都应该故意忘记一个对象的类型呢?在进行向上转型时,就会产生这种情况;并且如果让tune()方法直接接受一个Wind引用作为自己的参数,似乎会更为直观。但这样会引发的一个重要问题是:如果你那样做,就需要为系统内Instrument的每种类型都编写一个新的tune()方法。假设按照这种推理,现在再加入Stringed(弦乐)和Brass(管乐)这两种Instrument(乐器):
class Instrument {
public void play(Note n) {
System.out.println("Instrument.play()");
}
}
public class Wind extends Instrument {
// Redefine interface method:
public void play(Note note) {
System.out.println("Wind.play() " + note);
}
}
// Overloading instead of upcasting
class Stringed extends Instrument {
public void play(Note note) {
System.out.println("Stringed.play() " + note);
}
}
class Brass extends Instrument {
public void play(Note note) {
System.out.println("Brass.play() " + note);
}
}
public class Music2 {
private static Test monitor = new Test();
public static void tune(Wind i) {
i.play(Note.MIDDLE_C);
}
public static void tune(Stringed i) {
i.play(Note.MIDDLE_C);
}
public static void tune(Brass i) {
i.play(Note.MIDDLE_C);
}
public static void main(String[] args) {
Wind flute = new Wind();
Stringed violin = new Stringed();
Brass frenchHorn = new Brass();
tune(flute); // No upcasting
tune(violin);
tune(frenchHorn);
}
}
输出:
Wind.play() Middle C
Stringed.play() Middle C
Brass.play() Middle C
这样做行得通,但有一个主要缺点:必须为添加的每一个新Instrument类编写特定类型的方法。这意味着在开始时就需要更多的编程,这也意味着如果以后想添加类似Tune()的新方法,或者添加自Instrument导出的新类,仍需要做大量的工作。此外,如果我们忘记重载某个方法,编译器不会返回任何错误信息,这样关于类型的整个处理过程就变得难以操纵。
如果我们只写这样一个简单方法,它仅接收基类作为参数,而不是那些特殊的导出类。这样做情况会变得更好吗?也就是说,如果我们不管导出类的存在,编写的代码只是与基类打交道,会不会好呢?
这正是多态所允许的。然而,大多数程序员具有面向过程程序设计的背景,对多态的运作方式可能会感到有一点迷惑。(我也如此,嘿嘿~~)
8.2 转机
运行Music.java这个程序后,我们便会发现难点所在。Wind.play( )方法将产生输出结果。这无疑是我们所期望的输出结果,但它看起来似乎又没有什么意义。请观察一下tune()方法:
public static void tune(Instrument i) { // ...
i.play(Note.MIDDLE_C);
}
它接受一个Instrument引用。那么在这种情况下,编译器怎样才可能知道这个Instrument引用指向的是Wind对象,而不是Brass对象或Stringed对象呢?实际上,编译器无法得知。为了深入理解这个问题,有必要研究一下“绑定(binding)”这个话题。
8.2.1 方法调用绑定
将一个方法调用同一个方法主体关联起来被称作“绑定(binding)”。若在程序执行前进行绑定(如果有的话,由编译器和链接程序实现),叫做“前期绑定(early binding)”。可能以前从来没有听说过这个术语,因为它是面向过程的语言中不需要选择就默认的绑定方式。C编译器只有一种方法调用,那就是前期绑定。
上述程序之所以令人迷惑,主要是因为提前绑定。因为,当编译器只有一个Instrument引用时,它无法知道究竟调用哪个方法才对。
解决的办法叫做“后期绑定(late binding)”,它的含义就是在运行时,根据对象的类型进行绑定。后期绑定也叫做“动态绑定(dynamic binding)”或“运行时绑定(run-time binding)”。如果一种语言想实现后期绑定,就必须具有某些机制,以便在运行时能判断对象的类型,以调用恰当的方法。也就是说,编译器仍不知道对象的类型,但是方法调用机制能找到正确的方法体,并加以调用。后期绑定机制随编程语言的不同而有所不同,但是我们只要想象一下就会得知,不管怎样都必须在对象中安置某种“类型信息”。
Java中除了static和final方法(private方法属于final)之外,其他所有的方法都是后期绑定。这意味着通常情况下,我们不必判定是否应该进行后期绑定—它会自动发生。
为什么要将某个方法声明为final呢?正如前一章提到的那样,它可以防止其他人重载该方法。但更重要的一点或许是:这样做可以有效地“关闭”动态绑定,或者是想告诉编译器不需要对其进行动态绑定。这样,编译器就可以为final方法调用生成更有效的代码。 然而,大多数情况下,这样做对我们程序的整体性能不会产生什么改观。所以,最好根据设计来决定是否使用final ,而不是出于试图提高性能。
8.2.2 产生正确的行为
一旦知道Java中所有方法都是通过动态绑定实现多态这个事实之后,我们就可以编写只与基类打交道的程序代码了,并且这些代码对所有的导出类都可以正确运行。或者换种说法,发送消息给某个对象,让该对象去断定应该做什么事。
面向对象程序设计中,有一个最经典的“几何形状(shape)”例子。因为它直观,所以经常用到;但不幸的是,它可能使初学者认为面向对象程序设计仅适用于图形化程序设计,实际当然不是这种情形了。
在“几何形状”这个例子中,有一个Shape基类,以及多个导出类,如:Circle, Square, Triangle等。这个例子之所以好用,是因为我们可以说“圆是一种几何形状”,这种说法也很容易被理解。下面的继承图展示了它们之间的关系:
向上转型可以像下面这条语句这么简单:
Shape s = new Circle();
这里,创建了一个Circle对象,并把得到的引用立即赋值给Shape,这样做看似错误(将一种类型赋值给另一类型);但实际上是没问题的,因为通过继承, Circle就是一种Shape。因此,编译器认可这条语句,也就不会产生错误信息。 假设我们调用某个基类方法(已被导出类所重载):
s.draw();
同样地,我们可能会认为调用的是shape的draw(),因为这毕竟是一个shape引用,那么编译器是怎样知道去做其他的事情呢?由于后期绑定(多态),还是正确调用了Circle.draw( )方法。
下面的例子稍微有所不同:
class Shape{
public void draw() {}
public void erase() {}
}
class Circle extends Shape{
public void draw(){
System.out.println("Circle.draw()");
}
public void erase() {
System.out.println("Circle.erase()");
}
}
class Square extends Shape {
public void draw() {
System.out.println("Square.draw()");
}
public void erase() {
System.out.println("Square.erase()");
}
}
class Triangle extends Shape {
public void draw() {
System.out.println("Triangle.draw()");
}
public void erase() {
System.out.println("Triangle.erase()");
}
}
// A "factory" that randomly creates shapes:
class RandomShapeGenerator {
private Random rand = new Random();
public Shape next() {
switch(rand.nextInt(3)) {
default:
case 0: return new Circle();
case 1: return new Square();
case 2: return new Triangle();
}
}
}
public class Shapes{
private static RandomShapeGenerator gen = new RandomShapeGenerator();
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] = gen.next();
// Make polymorphic method calls:
for(int i = 0; i < s.length; i++)
s[i].draw();
}
}
Shape基类为自它那里继承而来的所有导出类,建立了一个通用接口——也就是说,所有形状都可以描绘和擦除。导出类重载了这些定义,以便为每种特殊类型的几何形状提供独特的行为。
RandomShapeGenerator是一种“工厂(factory)”,在我们每次调用next()方法时,它可以为随机选择的shape对象产生一个引用。请注意向上转型是在return语句里发生的。每个return语句取得一个指向某个Circle、Square或者Triangle的引用,并将其以Shape类型从next()方法中发送出去。所以无论我们在什么时候调用next()方法时,是绝对没有可能知道它所获的具体类型到底是什么,因为我们总是只能获得一个通用的Shape引用。
main()包含了Shape引用的一个数组,通过调用RandomShapeGenerator.next( )来填入数据。此时,我们只知道自己拥有一些Shape,不会知道除此之外的更具体情况(编译器一样不知)。然而,当我们遍历这个数组,并为每个数组元素调用draw()方法时,与各类型有关的专属行为竟会神奇般地正确发生,我们可以从运行该程序时,产生的输出结果中发现这一点。
随机选择几何形状是为了让大家理解:在编译期间,编译器不需要获得任何特殊的信息,就能进行正确的调用。对draw()方法的所有调用都是通过动态绑定进行的。
8.2.3扩展性
现在,让我们返回到乐器(Instrument)示例。由于有多态机制,我们可根据自己的需求对系统添加任意多的新类型,而不需更修改true()方法。在一个设计良好的OOP程序中,我们的大多数或者所有方法都会遵循tune()的模型,而且只与基类接口通信。这样的程序是“可扩展的”,因为可以从通用的基类继承出新的数据类型,从而新添一些功能。那些操纵基类接口
的方法不需要任何改动就可以应用于新类。
考虑一下:对于乐器例子,如果我们向基类中添加更多的方法,并加入一些新类,
将会出现什么情况呢?请看下图:
事实上,不需要改动tune()方法,所有的新类都能与原有类一起正确运行。即使tune()方法是存放在某个单独文件中,并且在Instrument接口中还添加了其他的新方法,tune()也不需再编译就仍能正确运行。下面是上述示意图的具体实现:
class Instrument{
void play(Note n){
System.out.println("Instrument.play() "+n);
}
String what (){return"Instrument";}
void adjust(){
System.out.println("Adjusting Instrument");
}
}
class Wind extends Instrument {
void play(Note n){
System.out.println("Wind.play() "+n);
}
String what(){return "Wind";}
void adjust(){
System.out.println("Adjusting Wind");
}
}
class Percussion extends Instrument {
void play(Note n) {
System.out.println("Percussion.play() " + n);
}
String what() { return "Percussion"; }
void adjust() {
System.out.println("Adjusting Percussion");
}
}
class Stringed extends Instrument {
void play(Note n) {
System.out.println("Stringed.play() " + n);
}
String what() { return "Stringed"; }
void adjust() {
System.out.println("Adjusting Stringed");
}
}
class Brass extends Wind {
void play(Note n) {
System.out.println("Brass.play() " + n);
}
void adjust() {
System.out.println("Adjusting Brass");
}
}
class Woodwind extends Wind {
void play(Note n) {
System.out.println("Woodwind.play() " + n);
}
String what() { return "Woodwind"; }
}
public class Music3 {
// Doesn't care about type, so new types
// added to the system still work right:
public static void tune(Instrument i) {
i.play(Note.MiDOLE_C);
}
public static void tuneAll(Instrument[] e) {
for(int i = 0; i < e.length; i++)
tune(e[i]);
}
public static void main(String[] args) {
// Upcasting during addition to the array:
Instrument[] orchestra = {
new Wind(),
new Percussion(),
new Stringed(),
new Brass(),
new Woodwind()
};
tuneAll(orchestra);
}
}
新添加的方法what()返回一个String引用及类的描述说明;另一个新添加的方法adjust()则提供每种乐器的调音方法。
在main()中,当我们将某种引用置入orchestra数组中,就会自动向上转型到Instrument。可以看到,tune()方法完全可以忽略它周围代码所发生的全部变化,依旧正常运行。这正是期望多态所具有的特性。
我们所作的代码修改,不会对程序中其他不应受到影响的部分产生破坏。从另一方面说就是,多态是一项让程序员“将改变的事物与未变的事物分离开来”的重要技术。
8.2.4 缺陷:“重载”私有方法
我们试图这样做也是无可厚非的:
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()");
}
}
我们所期望的输出是“public f( )”,但是由于private方法被自动认为就是final方法,而且对导出类是屏蔽的。 因此,在这种情况下,Derived类中的f( ) 方法就是一个全新的方法;既然基类中f( ) 方法在子类Derived中不可见,因此也就没有被重载。 结论就是:只有非private方法才可以被重载;但是我们还需要密切注意重载private方法的现象,虽然编译不会出错,但是不会按照我们所期望的来执行。明白地说,在导出类中,对于基类中的private方法,我们最好用一个不同的名字。
8.2.5 缺陷:域和静态方法
一旦你了解了多态机制,可能就会开始认为所有事务都可以多态地发生。然而,只有普通的方法调用可以是多态的。
例如,如果你直接访问某个域,这个访问就将在编译器进行解析,就像下面的示例:
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());
}
}
当Sub对象转型为Super引用时,任何域访问操作都将由编译器解析,因此不是多态的。在本例中,为Super.field和Sub.field分配了不同的存储空间。这样,Sub实际上包含两个称为field的域;它自己的和它从Super处得到的。然而,在引用Sub中的field时所产生的默认域并非Super版本的field域。因此,为了得到Super.field,必须显式地指明super.field.
尽管这看起来好像会称为一个容易令人混淆的问题,但是在实践中,它实际上从来不会发生。首先,你通常会将所有的域都设置成private,因此不能直接访问它们,其副作用是只能调用方法来访问。另外,你可能不会对基类中的域和导出类中的域赋予相同的名字,因为这种做法容易令人混淆。
如果某个方法是静态的,它的行为就不具有多态性:
class StaticSuper {
public static String staticGet() {
return "Base staticGet()";
}
public String dynamicGet() {
return "Base dynamicGet()";
}
}
class StaticSub extends StaticSuper {
public static String staticGet() {
return "Derived staticGet()";
}
public String dynamicGet() {
return "Derived dynamicGet()";
}
}
public class StaticPolymorphism {
public static void main(String[] args) {
StaticSuper sup = new StaticSub(); // Upcast
System.out.println(sup.staticGet());
System.out.println(sup.dynamicGet());
}
}
静态方法是与类,而并非与单个的对象相关联的。
总之:多态特性(动态绑定)只是针对方法的。域和静态方法不具有这种特性。
例如:父类和子类都有一个域 public String a; 在Super s = new Sub(); s.a 取出的是Super里的而不是Sub里的。 不过一般情况不会存在这种把域设置为public并且想用子类覆盖它的情况。
静态方法也不会有多态性。
8.3 构造器和多态
通常,构造器不同与其他种类的方法。即使涉及到多态,也仍是如此。尽管构造器并不具有多态性(它们实际上是Static方法,只不过该Static声明是隐式的),但还是非常有必要理解构造器怎样通过多态在复杂的层次结构中运作。这一理解将有助于大家避免一些令人不快的困扰。
8.3.1 构造器的调用顺序
构造器的调用顺序已在第5章进行了简要说明,并在第7章再次提到,但那些都是在多态引入之前介绍的。
基类的构造器总是在导出类的构造过程中被调用,而且按照继承层次逐渐向上链接,以使每个基类的构造器都能得到调用。这样做是有意义的,因为构造器具有一项特殊任务:检查对象是否被正确地构造。导出类只能访问它自己的成员,不能访问基类中的成员(基类成员通常是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 PortableLunch extends Lunch {
PortableLunch() { System.out.println("PortableLunch()");
}
}
public class Sandwich extends PortableLunch {
private Bread b = new Bread();
private Cheese c = new Cheese();
private Lettuce l = new Lettuce();
public Sandwich() {
System.out.println("Sandwich()");
}
public static void main(String[] args) {
new Sandwich();
}
}
在这个例子中,用其他类创建了一个复杂的类,而且每个类都有一个它声明自己的构造器。其中最重要的类是Sandwich, 它反映出了三层级别的继承(若将从Object的隐含继承也算在内,就是四级)以及三个成员对象。当在main()里创建一个Sandwich对象后,我们就可以看到输出结果。这也表明了这一复杂对象调用构造器要遵照下面的顺序:
1.调用基类构造器。这个步骤会不断地反复递归下去,首先是构造这种层次结构的根,
然后是下一层导出类,等等。直到最低层的导出类。
2.按声明顺序调用成员的初始状态设置模块。
3.调用导出类构造器的主体。
构造器的调用顺序是很重要的。当进行继承时,我们已经知道基类的一切,并且可以访问基类中任何声明为public和protected的成员。这意味着在导出类中,必须假定基类的所有成员都是有效的。一种标准方法是,构造动作一经发生,那么对象所有部分的全体成员都会得到构建。然而,在构造器内部,我们必须确保所要使用的成员都已经构建完毕。为确保这一目的,唯一的办法就是首先调用基类构造器。那么在进入导出类构造器时,在基类中可供我们访问的成员都已得到初始化。此外,在构造器中的所有成员必须有效也是因为当成员对象在类内进行定义的时候(比如上例中的b,c和l),我们应尽可能地对它们进行初始化(也就是,通过组合方法将对象置于类内)。若遵循这一规则,那么我们就能确定所有基类成员以及当前对象的成员对象都已初始化。但遗憾的是,这种做法并不适用于所有情况,我们会在下一节看到。
8.3.2 继承与清理
通过组合和继承方法来创建新类时,永远不必担心对象的清理问题,子对象(subobject)通常都会留给垃圾回收器进行处理。如果确实遇到清理的问题,那么必须用心为新类创建dispose( )方法(在这里我选用此名称;你可以提出更好的)。并且由于继承的缘故,
如果我们有其他作为垃圾回收一部分的特殊清理动作,就必须在导出类中覆盖dispose( )方法。当覆盖被继承类的dispose( )方法时,务必记住调用基类版本dispose( )方法。否则,基类的清除动作就不会发生。下例将予以证明:
class Characteristic { //特征
private String s;
Characteristic(String s) {
this.s = s;
System.out.println("Creating Characteristic " + s);
}
protected void dispose() {
System.out.println("finalizing Characteristic " + s);
}
}
class Description { //描述
private String s;
Description(String s) {
this.s = s;
System.out.println("Creating Description " + s);
}
protected void dispose() {
System.out.println("finalizing Description " + s);
}
}
class LivingCreature {
private Characteristic p = new Characteristic("is alive");
private Description t = new Description("Basic Living Creature");
LivingCreature() {
System.out.println("LivingCreature()");
}
protected void dispose() {
System.out.println("LivingCreature dispose");
t.dispose();
p.dispose();
}
}
class Animal extends LivingCreature { // 动物继承生物
private Characteristic p= new Characteristic("has heart");
private Description t = new Description("Animal not Vegetable");
Animal() {
System.out.println("Animal()");
}
protected void dispose() {
System.out.println("Animal dispose");
t.dispose();
p.dispose();
super.dispose();
}
}
class Amphibian extends Animal { // 两栖动物 继承 动物
private Characteristic p = new Characteristic("can live in water");
private Description t = new Description("Both water and land");
Amphibian() {
System.out.println("Amphibian()");
}
protected void dispose() {
System.out.println("Amphibian dispose");
t.dispose();
p.dispose();
super.dispose();
}
}
public class Frog extends Amphibian { //青蛙 继承 两栖动物
private Characteristic p = new Characteristic("Croaks");
private Description t = new Description("Eats Bugs");
public Frog() {
System.out.println("Frog()");
}
protected void dispose() {
System.out.println("Frog dispose");
t.dispose();
p.dispose();
super.dispose();
}
public static void main(String[] args) {
Frog frog = new Frog();
System.out.println("Bye!");
frog.dispose();
}
}
层次结构中的每个类都包含Characteristic和Description这两种类型的成员对象,并且也必须对它们进行处理。所以万一某个子对象要依赖于其他对象,销毁的顺序应该和初始化顺序相反。对于字段属性,则意味着与声明的顺序相反(因为字段属性的初始化是按照声明的顺序进行)。对于基类(遵循C++中析构函数的形式),我们应该首先对其导出类进行清理,然后才是基类。这是因为导出类的清理可能会调用基类中的某些方法,所以需要使基类中的构件仍起作用而不应过早地销毁她。从输出结果我们可以看到,Frog对象的所有部分都是按照创建的逆序进行销毁。
在这个例子中可以看到,尽管我们通常不必执行清除处理工作,但是一旦你选择要执行,就必须谨慎和小心。
在上面的示例中还应该注意到,Frog对象拥有其自己的成员对象。Frog对象创建了它自己的成员对象,并且知道它们存活多久(只要Frog存活着),因此Frog对象知道何时调用dispose()去释放其成员对象。然而,如果这些成员对象中存在于其他一个或者多个对象共享的情况,问题就变得更加复杂了,你就不能简单地假设你可以调用dispose()了。在这种情况下,也就必需使用引用计数来跟踪仍旧访问着共享对象的对象数量了。
下面是相关的代码:
class Shared {
private int refcount = 0;
private static long counter = 0;
private final long id = counter++;
public Shared() {
System.out.println("Creating " + this);
}
public void addRef() {
refcount++;
}
protected void dispose() {
if (--refcount == 0) {
System.out.println("Disposing " + this);
}
}
@Override
public String toString() {
return "Shared " + id;
}
}
class Composing {
private Shared shared;
private static long counter = 0;
private final long id = counter++;
public Composing(Shared shared) {
System.out.println("creating " + this);
this.shared = shared;
this.shared.addRef();
}
protected void dispose() {
System.out.println("disposing " + this);
shared.dispose();
}
@Override
public String toString() {
return "Composing " + id;
}
}
public class ReferenceCount {
public static void main(String[] args) {
Shared shared = new Shared();
Composing[] composing = {
new Composing(shared),
new Composing(shared),
new Composing(shared),
new Composing(shared),
new Composing(shared) };
for (Composing c : composing) {
c.dispose();
}
}
}
static long counter跟踪所创建的Shared的示例的数量,还可以为id提供数值。counter的类型是long而不是int,这样可以防止溢出。id是final的,因为我们不希望它的值在对象生命周期中被改变。 在将一个共享对象附着到类上时,必须记住调用addRef(),但是dispose()方法将跟踪引用数,并决定何时执行清理。使用这种技巧需要加倍地细心,但是如果你正在共享需要清理的对象,那么你就没有太多的选择余地了。
8.3.3构造器内部的多态方法的行为
构造器调用的层次结构带来了一个有趣的两难问题。如果在一个构造器的内部,同时调用正在构造的那个对象的某个动态绑定方法,
那会发生什么情况呢?
在一般的方法内部,动态绑定的调用是在运行期才决定的,因为对象无法知道它是属于
方法所在的那个类,还是属于那个类的导出类。为保持一致性,大家也许会认为这应该发生在构造器内部。
但事情并非完全如此。如果要调用构造器内部的一个动态绑定方法,就要用到那个方法的被覆盖后的定义。然而,
产生的效果可能
相当难于预料,并且可能造成一些难于发现的隐藏错误。
从概念上讲,构造器的工作实际上是创建对象(这并非是一件平常的工作)。
在任何构造器内部,整个对象可能只是部分形成——我们只知道基类对象已经进行初始化,如果构造器只是在构建对象
过程中的一个步骤,并且该对象所属的类是从这个构造器所属的类导出的,那么导出部分在当前构造器正在被调用
的时刻仍旧是没有被初始化的,然而,一个动态绑定的方法调用却会向外深入到继承层次结构内部。
它可以调用导出类里的方法。如果我们是在构造器内部这样做,
那么我们可能会调用某个方法,而这个方法所操纵的成员可能还未进行初始化——这肯定会招惹灾难。
通过下面这个例子,我们会看到问题所在:
class Glyph {
void draw(){
System.out.println("Glyph.draw()");
};
Glyph() {
System.out.println("Glyph() before draw()");
draw();
System.out.println("Glyph() after draw()");
}
}
class RoundGlyph extends Glyph {
private 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()方法设计为将要被覆盖,这种覆盖是在RoundGlyph中发生的。但是Glyph构造器会调用这个方法,结果导致了对RoundGlyph.draw()的调用,这看起来似乎是我们的目的。但是如果看到输出结果,我们会发现当Glyph的构造器调用draw()方法时,radius不是默认初始值1,而是0.这可能导致在屏幕上只画了一个点,或者根本什么东西都没有,我们只能干瞪眼,并试图找出程序无法运转的原因所在。
初始化的实际过程是:
1、在其他任何事务发生之前,将分配给对象的存储空间初始化成二进制的零。
2、如前所述那样调用基类构造器,此时,调用被覆盖后的draw()方法(要在调用RoundGlyph的构造器之前
调用),由于步骤1的缘故,我们此时会发现radius的值为0。
3、按照声明的顺序调用成员的初始化方法。
4、调用导出类的构造器主体。
这样做有一个优点,那就是所有东西都至少初始化成零(或者是某些特殊数据类型中与“零”等价的值),而不是仅仅留作垃圾。其中包括通过“组合”而嵌入一个类内部的对象引用。其值是null。所以如果忘记为该引用进行初始化,就会在运行期间抛出异常。查看输出结果时,我们会发现其他所有东西的值都会是零,这通常也正是发现问题的证据。
另一方面,我们应该对这个程序的结果相当震惊。在逻辑方面,我们做的已经十分完美,而它的行为却不可思议地错了,并且编译器也没有报错。(在这种情况下,C++语言会出现更合理的行为)。诸如此类的错误会很容易地被人忽略,而且要花很长的时间才能发现。
因此,编写构造器时有一条有益的规则:“用尽可能简单的方法使对象进入正常状态;如果可以的话,避免调用其他方法”。在构造器内唯一能够安全调用的那些方法是基类中的final方法(也适用于private方法,它们自动属于final方法)。这些方法不能被覆盖,因此也就不会出现上述令人惊讶的问题。
8.4 协变返回类型
Java SE5中添加了协变返回类型,它表示在导出类中的被覆盖方法可以返回基类方法的返回类型的某种导出类型:
class Grain{
public String toString(){
return "Grain";
}
}
class Wheat extends Grain{
public String toString(){
return "Wheat";
}
}
class Mill{
Grain process(){
return new Grain();
}
}
class WheatMill extends Mill{
Wheat process(){
return new Wheat();
}
}
public class CovariantReturn {
public static void main(String[] args) {
Mill m=new Mill();
Grain grain = m.process();
System.out.println(grain);
m=new WheatMill();
grain=m.process();
System.out.println(grain);
}
}
Java SE5与Java较早版本之间的主要差异就是较早的版本将强制process()的覆盖版本必须返回Grain,而不能返回Whear,尽管Wheat是从Grain导出的,因而也应该是一种合法的返回类型。协变返回类型允许返回更具体的Wheat类型。
8.5 用继承进行设计
学习了多态之后,看起来似乎所有的东西都可以被继承,因为多态是一种如此巧妙的工具。事实上,当我们使用现成的类来建立新类时,如果首先考虑使用继承技术,反倒会加重我们的设计负担,使事情变得不必要地复杂起来。
更好的方式是首先选择“组合”,尤其是不能十分确定应该使用哪一种方式时。组合不会强制我们的程序设计进入继承的层次结构中。而且,组合更加灵活,因为它可以动态选择类型(因此也就选择了行为);相反,继承在编译时就需要知道确切的类型。下面举例说明这一点:
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();
}
}
在这里,一个Stage对象包含了一个指向Actor的引用,且初始化为HappyActor对象。这意味着performPlay( )会产生某种特定的行为。既然引用在运行时可以与另一个不同的对象重新绑定起来,所以SadActor对象的引用可以在actor中被替代,然后由performPlay( )产生的行为也随之改变。这样一来,我们在运行期间获得了动态灵活性。(这也称作(状态模式)State Pattern)与此相反,我们不能在运行期间决定继承不同的对象,因为它要求在编译期间完全确定下来。
一条通用的准则是:“用继承表达行为间的差异,并用字段表达状态上的变化”。在上述例子中,两者都用到了: 通过继承得到了两个不同的类,用于表达act()方法的差异;而Stage通过运用组合使它自己的状态发生变化。在这种情况下,这种状态的改变也就产生了行为的改变。
8.5.1纯继承与扩展
采取“纯粹”的方式来创建继承层次结构似乎是最好的方式。也就是说,只有在基类或接口中已经建立的方法才可以在导出类中被覆盖,如下图所示:
这被称作是纯粹的“is-a”(是一种)关系,因为一个类的接口已经确定了它应该是什么。继承可以确保所有的导出类具有基类的所有接口,且绝对不会少。按上图那么做,导出类也将具有和基类一样的接口。
也可以认为这是一种纯替代,因为导出类可以完全地代替基类,而在使用它们时,完全不需要知道关于子类的任何额外信息:
也就是说,基类可以接受发送给导出类的任何消息,因为二者有着完全相同的接口。我们只需从导出类向上转型,永远不需知道正在处理的对象的确切类型。所有这一切,都是通过多态进行处理的。(如下图所示)
按照这种方式考虑,似乎只有纯碎的is-a关系才是唯一明智的做法,而所有其他的设计都只会导致混乱和注定会失败,这其实也是一个陷阱,因为只要开始考虑,就会转向,并发现扩展接口(遗憾的是,extends关键字似乎在怂恿我们这样做)才是解决特定问题的完美方案。这可以称为“is-like-a”(像一个)关系,因为导出类就像是一个基类——它有着相同的基本接口——但是它还具有由额外方法实现的其他特性。
虽然这是一种有用且明智的方法(视情况而定),但是它也有缺点。导出类中扩展的接口不能被基类访问,因此,一旦我们向上转型,就不能调用那些新增方法:
在这种情况下,如果我们不进行向上转型,这样的问题也就不会出现。但是通常情况下,我们需要重新查明对象的确切类型,
以便能够访问该类型所扩充的方法。下一节将说明如何做到这一点。
8.5.2向下转型与运行期类型标识
由于向上转型(在继承层次中向上移动)过程中会丢失具体的类型信息,所以我们就想,通过向下转型——也就是在继承层次中向下移动——应该能够获取类型信息。然而,我们知道向上转型是安全的;因为基类不会具有大于导出类的接口。
因此,我们通过基类接口发送的消息都能够确保被接收到。但是对于向下转型,例如,我们无法知道一个几何形状它确实就是一个圆。它可以是一个三角形、正方形或其他一些类型。
要解决这个问题,必须有某种方法来确保向下转型的正确性,使我们不致于贸然转型到一种错误类型,进而发出该对象无法接受的消息。这样做是极其不安全的。
在某些程序设计语言(如C++)中,我们必须执行一个特殊的操作以便获得安全的向下转型。但是在Java语言中,所有转型都会得到检查!所以即使我们只是进行一次普通的加括弧形式的强制转换,在进入运行期时,仍然会对其进行检查,以便保证它的确是我们希望的那种类型。如果不是,就会返回一个ClassCastException(转型异常)。这种在运行期间对类型进行检查的行为称作“运行期类型识别”(RTTI)。下面这个例子说明RTTI的行为:
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类型。我们在main()方法中对数组x进行初始化时可以看到这种情况的发生。既然数组中的两个对象都属于Useful类,所以我们可以调用f()和g()这两个方法。如果我们试图调用u()方法(它只存在于MoreUseful),就会返回一条编译期出错信息。
如果我们想访问一个MoreUseful对象的扩展接口,就可以尝试进行向下转型。如果所转类型是正确的,那么转型成功。否则,就会返回一个ClassCastException异常。我们不必为这个异常编写任何特殊的代码,因为它指出的是程序员在程序中任何地方都可能会犯的错误。(Throws-Exception)注释标签告知本书的构建系统;在运行该程序时,预期抛出一个异常。
RTTI的内容不仅仅包括转型处理。例如它还提供了一种方法,使你可以在试图向下转型之前,查看你要所要处理的类型。第14张专门讨论Java运行时类型标识的所有不同方面。
8.6 总结
“多态”意味着“不同的形式”。在面向对象的程序设计中,我们持有从基类继承而来的相同接口,以及使用该接口的不同形式:不同版本的动态绑定方法。
在本章中我们已经知道,如果不运用数据抽象和继承,就不可能去理解或者甚至不可能创建一个多态例子。
多态是一种不能单独来看待的特性(例如,像switch语句是可以的),相反它只能作为类关系“全景”中的一部分,与其它特性协同工作。如果不是后期绑定,就不是多态。
为了在自己的程序中有效地运用多态乃至面向对象的技术,必须扩展自己的编程视野,使其不仅包括个别类的成员和消息,而且还要包括类与类之间的共同特性以及它们之间的关系。尽管这需要极大的努力,但是这样做是非常值得的,因为它可以带来很多成效:更快的程序开发过程、更好的代码组织、更好的代码扩展以及更容易的代码维护等。