Thinking-in-Java--Java-编程思想--二-

Thinking in Java (Java 编程思想)(二)

3.2 执行控制

(3)2 执行控制

Java使用了C的全部控制语句,所以假期您以前用C或C++编程,其中大多数都应是非常熟悉的。大多数程序化的编程语言都提供了某种形式的控制语句,这在语言间通常是共通的。在Java里,涉及的关键字包括if-elsewhiledo-whilefor以及一个名为switch的选择语句。然而,Java并不支持非常有害的goto(它仍是解决某些特殊问题的权宜之计)。仍然可以进行象goto那样的跳转,但比典型的goto要局限多了。

3.2.1 真和假

所有条件语句都利用条件表达式的真或假来决定执行流程。条件表达式的一个例子是A==B。它用条件运算符==来判断A值是否等于B值。该表达式返回truefalse。本章早些时候接触到的所有关系运算符都可拿来构造一个条件语句。注意Java不允许我们将一个数字作为布尔值使用,即使它在C和C++里是允许的(真是非零,而假是零)。若想在一次布尔测试中使用一个非布尔值——比如在if(a)里,那么首先必须用一个条件表达式将其转换成一个布尔值,例如if(a!=0)

3.2.2 if-else

if-else语句或许是控制程序流程最基本的形式。其中的else是可选的,所以可按下述两种形式来使用if

if(布尔表达式)
语句

或者

if(布尔表达式)
语句
else
语句

条件必须产生一个布尔结果。“语句”要么是用分号结尾的一个简单语句,要么是一个复合语句——封闭在括号内的一组简单语句。在本书任何地方,只要提及“语句”这个词,就有可能包括简单或复合语句。

作为if-else的一个例子,下面这个test()方法可告诉我们猜测的一个数字位于目标数字之上、之下还是相等:

static int test(int testval) {
  int result = 0;
  if(testval > target)
    result = -1;
  else if(testval < target)
    result = +1;
  else
    result = 0; // match
  return result;
}

最好将流程控制语句缩进排列,使读者能方便地看出起点与终点。

(1) return

return关键字有两方面的用途:指定一个方法返回什么值(假设它没有void返回值),并立即返回那个值。可据此改写上面的test()方法,使其利用这些特点:

static int test2(int testval) {
  if(testval > target)
    return -1;
  if(testval < target)
    return +1;
  return 0; // match
}

不必加上else,因为方法在遇到return后便不再继续。

3.2.3 迭代

whiledo-whilefor控制着循环,有时将其划分为“迭代语句”。除非用于控制迭代的布尔表达式得到“假”的结果,否则语句会重复执行下去。while循环的格式如下:

while(布尔表达式)
语句

在循环刚开始时,会计算一次“布尔表达式”的值。而对于后来每一次额外的循环,都会在开始前重新计算一次。
下面这个简单的例子可产生随机数,直到符合特定的条件为止:

//: WhileTest.java
// Demonstrates the while loop

public class WhileTest {
  public static void main(String[] args) {
    double r = 0;
    while(r < 0.99d) {
      r = Math.random();
      System.out.println(r);
    }
  }
} ///:~

它用到了Math库里的static(静态)方法random()。该方法的作用是产生0和1之间(包括0,但不包括1)的一个double值。while的条件表达式意思是说:“一直循环下去,直到数字等于或大于0.99”。由于它的随机性,每运行一次这个程序,都会获得大小不同的数字列表。

3.2.4 do-while

do-while的格式如下:

do
语句
while(布尔表达式)

whiledo-while唯一的区别就是do-while肯定会至少执行一次;也就是说,至少会将其中的语句“过一遍”——即便表达式第一次便计算为false。而在while循环结构中,若条件第一次就为false,那么其中的语句根本不会执行。在实际应用中,whiledo-while更常用一些。

3.2.5 for

for循环在第一次迭代之前要进行初始化。随后,它会进行条件测试,而且在每一次迭代的时候,进行某种形式的“步进”(Stepping)。for循环的形式如下:

for(初始表达式; 布尔表达式; 步进)
语句

无论初始表达式,布尔表达式,还是步进,都可以置空。每次迭代前,都要测试一下布尔表达式。若获得的结果是false,就会继续执行紧跟在for语句后面的那行代码。在每次循环的末尾,会计算一次步进。

for循环通常用于执行“计数”任务:

//: ListCharacters.java
// Demonstrates "for" loop by listing
// all the ASCII characters.

public class ListCharacters {
  public static void main(String[] args) {
  for( char c = 0; c < 128; c++)
    if (c != 26 )  // ANSI Clear screen
      System.out.println(
        "value: " + (int)c +
        " character: " + c);
  }
} ///:~

注意变量c是在需要用到它的时候定义的——在for循环的控制表达式内部,而非在由起始花括号标记的代码块的最开头。c的作用域是由for控制的表达式。

以于象C这样传统的程序化语言,要求所有变量都在一个块的开头定义。所以在编译器创建一个块的时候,它可以为那些变量分配空间。而在Java和C++中,则可在整个块的范围内分散变量声明,在真正需要的地方才加以定义。这样便可形成更自然的编码风格,也更易理解。

可在for语句里定义多个变量,但它们必须具有同样的类型:

for(int i = 0, j = 1;
    i < 10 && j != 11;
    i++, j++)
 /* body of for loop */;

其中,for语句内的int定义同时覆盖了ij。只有for循环才具备在控制表达式里定义变量的能力。对于其他任何条件或循环语句,都不可采用这种方法。

(1) 逗号运算符

早在第1章,我们已提到了逗号运算符——注意不是逗号分隔符;后者用于分隔函数的不同参数。Java里唯一用到逗号运算符的地方就是for循环的控制表达式。在控制表达式的初始化和步进控制部分,我们可使用一系列由逗号分隔的语句。而且那些语句均会独立执行。前面的例子已运用了这种能力,下面则是另一个例子:

//: CommaOperator.java

public class CommaOperator {
  public static void main(String[] args) {
    for(int i = 1, j = i + 10; i < 5;
        i++, j = i * 2) {
      System.out.println("i= " + i + " j= " + j);
    }
  }
} ///:~

输出如下:

i= 1 j= 11
i= 2 j= 4
i= 3 j= 6
i= 4 j= 8

大家可以看到,无论在初始化还是在步进部分,语句都是顺序执行的。此外,尽管初始化部分可设置任意数量的定义,但都属于同一类型。

3.2.6 中断和继续

在任何循环语句的主体部分,亦可用breakcontinue控制循环的流程。其中,break用于强行退出循环,不执行循环中剩余的语句。而continue则停止执行当前的迭代,然后退回循环起始和,开始新的迭代。

下面这个程序向大家展示了breakcontinueforwhile循环中的例子:

//: BreakAndContinue.java
// Demonstrates break and continue keywords

public class BreakAndContinue {
  public static void main(String[] args) {
    for(int i = 0; i < 100; i++) {
      if(i == 74) break; // Out of for loop
      if(i % 9 != 0) continue; // Next iteration
      System.out.println(i);
    }
    int i = 0;
    // An "infinite loop":
    while(true) {
      i++;
      int j = i * 27;
      if(j == 1269) break; // Out of loop
      if(i % 10 != 0) continue; // Top of loop
      System.out.println(i);
    }
  }
} ///:~

在这个for循环中,i的值永远不会到达100。因为一旦i到达74,break语句就会中断循环。通常,只有在不知道中断条件何时满足时,才需象这样使用break。只要i不能被9整除,continue语句会使程序流程返回循环的最开头执行(所以使i值递增)。如果能够整除,则将值显示出来。

第二部分向大家揭示了一个“无限循环”的情况。然而,循环内部有一个break语句,可中止循环。除此以外,大家还会看到continue移回循环顶部,同时不完成剩余的内容(所以只有在i值能被9整除时才打印出值)。输出结果如下:

0
9
18
27
36
45
54
63
72
10
20
30
40

之所以显示0,是由于0%9等于0。

无限循环的第二种形式是for(;;)。编译器将while(true)for(;;)看作同一回事。所以具体选用哪个取决于自己的编程习惯。

(1) 臭名昭著的goto

goto关键字很早就在程序设计语言中出现。事实上,goto是汇编语言的程序控制结构的始祖:“若条件A,则跳到这里;否则跳到那里”。若阅读由几乎所有编译器生成的汇编代码,就会发现程序控制里包含了许多跳转。然而,goto是在源码的级别跳转的,所以招致了不好的声誉。若程序总是从一个地方跳到另一个地方,还有什么办法能识别代码的流程呢?随着Edsger Dijkstra著名的“Goto有害”论的问世,goto便从此失宠。

事实上,真正的问题并不在于使用goto,而在于goto的滥用。而且在一些少见的情况下,goto是组织控制流程的最佳手段。

尽管goto仍是Java的一个保留字,但并未在语言中得到正式使用;Java没有goto。然而,在breakcontinue这两个关键字的身上,我们仍然能看出一些goto的影子。它并不属于一次跳转,而是中断循环语句的一种方法。之所以把它们纳入goto问题中一起讨论,是由于它们使用了相同的机制:标签。

“标签”是后面跟一个冒号的标识符,就象下面这样:

label1:

对Java来说,唯一用到标签的地方是在循环语句之前。进一步说,它实际需要紧靠在循环语句的前方——在标签和循环之间置入任何语句都是不明智的。而在循环之前设置标签的唯一理由是:我们希望在其中嵌套另一个循环或者一个开关。这是由于breakcontinue关键字通常只中断当前循环,但若随同标签使用,它们就会中断到存在标签的地方。如下所示:

label1:
外部循环{
内部循环{
//...
break; //1
//...
continue; //2
//...
continue label1; //3
//...
break label1; //4
}
}

在条件1中,break中断内部循环,并在外部循环结束。在条件2中,continue移回内部循环的起始处。但在条件3中,continue label1却同时中断内部循环以及外部循环,并移至label1处。随后,它实际是继续循环,但却从外部循环开始。在条件4中,break label1也会中断所有循环,并回到label1处,但并不重新进入循环。也就是说,它实际是完全中止了两个循环。

下面是for循环的一个例子:

//: LabeledFor.java
// Java’s "labeled for loop"

public class LabeledFor {
  public static void main(String[] args) {
    int i = 0;
    outer: // Can't have statements here
    for(; true ;) { // infinite loop
      inner: // Can't have statements here
      for(; i < 10; i++) {
        prt("i = " + i);
        if(i == 2) {
          prt("continue");
          continue;
        }
        if(i == 3) {
          prt("break");
          i++; // Otherwise i never
               // gets incremented.
          break;
        }
        if(i == 7) {
          prt("continue outer");
          i++; // Otherwise i never
               // gets incremented.
          continue outer;
        }
        if(i == 8) {
          prt("break outer");
          break outer;
        }
        for(int k = 0; k < 5; k++) {
          if(k == 3) {
            prt("continue inner");
            continue inner;
          }
        }
      }
    }
    // Can't break or continue
    // to labels here
  }
  static void prt(String s) {
    System.out.println(s);
  }
} ///:~

这里用到了在其他例子中已经定义的prt()方法。

注意break会中断for循环,而且在抵达for循环的末尾之前,递增表达式不会执行。由于break跳过了递增表达式,所以递增会在i==3的情况下直接执行。在i==7的情况下,continue outer语句也会到达循环顶部,而且也会跳过递增,所以它也是直接递增的。

下面是输出结果:

i = 0
continue inner
i = 1
continue inner
i = 2
continue
i = 3
break
i = 4
continue inner
i = 5
continue inner
i = 6
continue inner
i = 7
continue outer
i = 8
break outer

如果没有break outer语句,就没有办法在一个内部循环里找到出外部循环的路径。这是由于break本身只能中断最内层的循环(对于continue同样如此)。

当然,若想在中断循环的同时退出方法,简单地用一个return即可。

下面这个例子向大家展示了带标签的break以及continue语句在while循环中的用法:

//: LabeledWhile.java
// Java's "labeled while" loop

public class LabeledWhile {
  public static void main(String[] args) {
    int i = 0;
    outer:
    while(true) {
      prt("Outer while loop");
      while(true) {
        i++;
        prt("i = " + i);
        if(i == 1) {
          prt("continue");
          continue;
        }
        if(i == 3) {
          prt("continue outer");
          continue outer;
        }
        if(i == 5) {
          prt("break");
          break;
        }
        if(i == 7) {
          prt("break outer");
          break outer;
        }
      }
    }
  }
  static void prt(String s) {
    System.out.println(s);
  }
} ///:~

同样的规则亦适用于while

(1) 简单的一个continue会退回最内层循环的开头(顶部),并继续执行。

(2) 带有标签的continue会到达标签的位置,并重新进入紧接在那个标签后面的循环。

(3) break会中断当前循环,并移离当前标签的末尾。

(4) 带标签的break会中断当前循环,并移离由那个标签指示的循环的末尾。

这个方法的输出结果是一目了然的:

Outer while loop
i = 1
continue
i = 2
i = 3
continue outer
Outer while loop
i = 4
i = 5
break
Outer while loop
i = 6
i = 7
break outer

大家要记住的重点是:在Java里唯一需要用到标签的地方就是拥有嵌套循环,而且想中断或继续多个嵌套级别的时候。

在Dijkstra的“Goto有害”论中,他最反对的就是标签,而非goto。随着标签在一个程序里数量的增多,他发现产生错误的机会也越来越多。标签和goto使我们难于对程序作静态分析。这是由于它们在程序的执行流程中引入了许多“怪圈”。但幸运的是,Java标签不会造成这方面的问题,因为它们的活动场所已被限死,不可通过特别的方式到处传递程序的控制权。由此也引出了一个有趣的问题:通过限制语句的能力,反而能使一项语言特性更加有用。

3.2.7 开关

“开关”(Switch)有时也被划分为一种“选择语句”。根据一个整数表达式的值,switch语句可从一系列代码选出一段执行。它的格式如下:

switch(整数选择因子) {
case 整数值1 : 语句; break;
case 整数值2 : 语句; break;
case 整数值3 : 语句; break;
case 整数值4 : 语句; break;
case 整数值5 : 语句; break;
//..
default:语句;
}

其中,“整数选择因子”是一个特殊的表达式,能产生整数值。switch能将整数选择因子的结果与每个整数值比较。若发现相符的,就执行对应的语句(简单或复合语句)。若没有发现相符的,就执行default语句。

在上面的定义中,大家会注意到每个case均以一个break结尾。这样可使执行流程跳转至switch主体的末尾。这是构建switch语句的一种传统方式,但break是可选的。若省略break,会继续执行后面的case语句的代码,直到遇到一个break为止。尽管通常不想出现这种情况,但对有经验的程序员来说,也许能够善加利用。注意最后的default语句没有break,因为执行流程已到了break的跳转目的地。当然,如果考虑到编程风格方面的原因,完全可以在default语句的末尾放置一个break,尽管它并没有任何实际的用处。

switch语句是实现多路选择的一种易行方式(比如从一系列执行路径中挑选一个)。但它要求使用一个选择因子,并且必须是intchar那样的整数值。例如,假若将一个字符串或者浮点数作为选择因子使用,那么它们在switch语句里是不会工作的。对于非整数类型,则必须使用一系列if语句。

下面这个例子可随机生成字母,并判断它们是元音还是辅音字母:

//: VowelsAndConsonants.java
// Demonstrates the switch statement

public class VowelsAndConsonants {
  public static void main(String[] args) {
    for(int i = 0; i < 100; i++) {
      char c = (char)(Math.random() * 26 + 'a');
      System.out.print(c + ": ");
      switch(c) {
      case 'a':
      case 'e':
      case 'i':
      case 'o':
      case 'u':
                System.out.println("vowel");
                break;
      case 'y':
      case 'w':
                System.out.println(
                  "Sometimes a vowel");
                break;
      default:
                System.out.println("consonant");
      }
    }
  }
} ///:~

由于Math.random()会产生0到1之间的一个值,所以只需将其乘以想获得的最大随机数(对于英语字母,这个数字是26),再加上一个偏移量,得到最小的随机数。

尽管我们在这儿表面上要处理的是字符,但switch语句实际使用的字符的整数值。在case语句中,用单引号封闭起来的字符也会产生整数值,以便我们进行比较。

请注意case语句相互间是如何聚合在一起的,它们依次排列,为一部分特定的代码提供了多种匹配模式。也应注意将break语句置于一个特定case的末尾,否则控制流程会简单地下移,并继续判断下一个条件是否相符。

(1) 具体的计算

应特别留意下面这个语句:

char c = (char)(Math.random() * 26 + 'a');

Math.random()会产生一个double值,所以26会转换成double类型,以便执行乘法运算。这个运算也会产生一个double值。这意味着为了执行加法,必须无将'a'转换成一个double。利用一个“转换”,double结果会转换回char

我们的第一个问题是,转换会对char作什么样的处理呢?换言之,假设一个值是29.7,我们把它转换成一个char,那么结果值到底是30还是29呢?答案可从下面这个例子中得到:

//: CastingNumbers.java
// What happens when you cast a float or double
// to an integral value?

public class CastingNumbers {
  public static void main(String[] args) {
    double
      above = 0.7,
      below = 0.4;
    System.out.println("above: " + above);
    System.out.println("below: " + below);
    System.out.println(
      "(int)above: " + (int)above);
    System.out.println(
      "(int)below: " + (int)below);
    System.out.println(
      "(char)('a' + above): " +
      (char)('a' + above));
    System.out.println(
      "(char)('a' + below): " +
      (char)('a' + below));
  }
} ///:~

输出结果如下:

above: 0.7
below: 0.4
(int)above: 0
(int)below: 0
(char)('a' + above): a
(char)('a' + below): a

所以答案就是:将一个floatdouble值转换成整数值后,总是将小数部分“砍掉”,不作任何进位处理。

第二个问题与Math.random()有关。它会产生0和1之间的值,但是否包括值1呢?用正统的数学语言表达,它到底是(0,1)[0,1](0,1],还是[0,1)呢(方括号表示“包括”,圆括号表示“不包括”)?同样地,一个示范程序向我们揭示了答案:

//: RandomBounds.java
// Does Math.random() produce 0.0 and 1.0?

public class RandomBounds {
  static void usage() {
    System.err.println("Usage: \n\t" +
      "RandomBounds lower\n\t" +
      "RandomBounds upper");
    System.exit(1);
  }
  public static void main(String[] args) {
    if(args.length != 1) usage();
    if(args[0].equals("lower")) {
      while(Math.random() != 0.0)
        ; // Keep trying
      System.out.println("Produced 0.0!");
    }
    else if(args[0].equals("upper")) {
      while(Math.random() != 1.0)
        ; // Keep trying
      System.out.println("Produced 1.0!");
    }
    else
      usage();
  }
} ///:~

为运行这个程序,只需在命令行键入下述命令即可:

java RandomBounds lower

java RandomBounds upper

在这两种情况下,我们都必须人工中断程序,所以会发现Math.random()“似乎”永远都不会产生0.0或1.0。但这只是一项实验而已。若想到0和1之间有2的128次方不同的双精度小数,所以如果全部产生这些数字,花费的时间会远远超过一个人的生命。当然,最后的结果是在Math.random()的输出中包括了0.0。或者用数字语言表达,输出值范围是[0,1)

3.3 总结

本章总结了大多数程序设计语言都具有的基本特性:计算、运算符优先顺序、类型转换以及选择和循环等等。现在,我们作好了相应的准备,可继续向面向对象的程序设计领域迈进。在下一章里,我们将讨论对象的初始化与清除问题,再后面则讲述隐藏的基本实现方法。

3.4 练习

(1) 写一个程序,打印出1到100间的整数。

(2) 修改练习(1),在值为47时用一个break退出程序。亦可换成return试试。

(3) 创建一个switch语句,为每一种case都显示一条消息。并将switch置入一个for循环里,令其尝试每一种case。在每个case后面都放置一个break,并对其进行测试。然后,删除break,看看会有什么情况出现。

第3章 控制程序流程

“就象任何有感知的生物一样,程序必须能操纵自己的世界,在执行过程中作出判断与选择。”

在Java里,我们利用运算符操纵对象和数据,并用执行控制语句作出选择。Java是建立在C++基础上的,所以对C和C++程序员来说,对Java这方面的大多数语句和运算符都应是非常熟悉的。当然,Java也进行了自己的一些改进与简化工作。

4.1 用构造器自动初始化

对于方法的创建,可将其想象成为自己写的每个类都调用一次initialize()。这个名字提醒我们在使用对象之前,应首先进行这样的调用。但不幸的是,这也意味着用户必须记住调用方法。在Java中,由于提供了名为“构造器”的一种特殊方法,所以类的设计者可担保每个对象都会得到正确的初始化。若某个类有一个构造器,那么在创建对象时,Java会自动调用那个构造器——甚至在用户毫不知觉的情况下。所以说这是可以担保的!

接着的一个问题是如何命名这个方法。存在两方面的问题。第一个是我们使用的任何名字都可能与打算为某个类成员使用的名字冲突。第二是由于编译器的责任是调用构造器,所以它必须知道要调用是哪个方法。C++采取的方案看来是最简单的,且更有逻辑性,所以也在Java里得到了应用:构造器的名字与类名相同。这样一来,可保证象这样的一个方法会在初始化期间自动调用。

下面是带有构造器的一个简单的类(若执行这个程序有问题,请参考第3章的“赋值”小节)。

//: SimpleConstructor.java
// Demonstration of a simple constructor
package c04;

class Rock {
  Rock() { // This is the constructor
    System.out.println("Creating Rock");
  }
}

public class SimpleConstructor {
  public static void main(String[] args) {
    for(int i = 0; i < 10; i++)
      new Rock();
  }
} ///:~

现在,一旦创建一个对象:

new Rock();

就会分配相应的存储空间,并调用构造器。这样可保证在我们经手之前,对象得到正确的初始化。
请注意所有方法首字母小写的编码规则并不适用于构造器。这是由于构造器的名字必须与类名完全相同!

和其他任何方法一样,构造器也能使用参数,以便我们指定对象的具体创建方式。可非常方便地改动上述例子,以便构造器使用自己的参数。如下所示:

class Rock {
  Rock(int i) {
    System.out.println(
      "Creating Rock number " + i);
  }
}

public class SimpleConstructor {
  public static void main(String[] args) {
    for(int i = 0; i < 10; i++)
      new Rock(i);
  }
}

利用构造器的参数,我们可为一个对象的初始化设定相应的参数。举个例子来说,假设类Tree有一个构造器,它用一个整数参数标记树的高度,那么就可以象下面这样创建一个Tree对象:

tree t = new Tree(12); // 12英尺高的树

Tree(int)是我们唯一的构造器,那么编译器不会允许我们以其他任何方式创建一个Tree对象。

构造器有助于消除大量涉及类的问题,并使代码更易阅读。例如在前述的代码段中,我们并未看到对initialize()方法的明确调用——那些方法在概念上独立于定义内容。在Java中,定义和初始化属于统一的概念——两者缺一不可。

构造器属于一种较特殊的方法类型,因为它没有返回值。这与void返回值存在着明显的区别。对于void返回值,尽管方法本身不会自动返回什么,但仍然可以让它返回另一些东西。构造器则不同,它不仅什么也不会自动返回,而且根本不能有任何选择。若存在一个返回值,而且假设我们可以自行选择返回内容,那么编译器多少要知道如何对那个返回值作什么样的处理。

4.2 方法重载

在任何程序设计语言中,一项重要的特性就是名字的运用。我们创建一个对象时,会分配到一个保存区域的名字。方法名代表的是一种具体的行动。通过用名字描述自己的系统,可使自己的程序更易人们理解和修改。它非常象写散文——目的是与读者沟通。

我们用名字引用或描述所有对象与方法。若名字选得好,可使自己及其他人更易理解自己的代码。

将人类语言中存在细致差别的概念“映射”到一种程序设计语言中时,会出现一些特殊的问题。在日常生活中,我们用相同的词表达多种不同的含义——即词的“重载”。我们说“洗衬衫”、“洗车”以及“洗狗”。但若强制象下面这样说,就显得很愚蠢:“衬衫洗 衬衫”、“车洗 车”以及“狗洗 狗”。这是由于听众根本不需要对执行的行动作任何明确的区分。人类的大多数语言都具有很强的“冗余”性,所以即使漏掉了几个词,仍然可以推断出含义。我们不需要独一无二的标识符——可从具体的语境中推论出含义。

大多数程序设计语言(特别是C)要求我们为每个函数都设定一个独一无二的标识符。所以绝对不能用一个名为print()的函数来显示整数,再用另一个print()显示浮点数——每个函数都要求具备唯一的名字。

在Java里,另一项因素强迫方法名出现重载情况:构造器。由于构造器的名字由类名决定,所以只能有一个构造器名称。但假若我们想用多种方式创建一个对象呢?例如,假设我们想创建一个类,令其用标准方式进行初始化,另外从文件里读取信息来初始化。此时,我们需要两个构造器,一个没有参数(默认构造器),另一个将字符串作为参数——用于初始化对象的那个文件的名字。由于都是构造器,所以它们必须有相同的名字,亦即类名。所以为了让相同的方法名伴随不同的参数类型使用,“方法重载”是非常关键的一项措施。同时,尽管方法重载是构造器必需的,但它亦可应用于其他任何方法,且用法非常方便。

在下面这个例子里,我们向大家同时展示了重载构造器和重载的原始方法:

//: Overloading.java
// Demonstration of both constructor
// and ordinary method overloading.
import java.util.*;

class Tree {
  int height;
  Tree() {
    prt("Planting a seedling");
    height = 0;
  }
  Tree(int i) {
    prt("Creating new Tree that is "
        + i + " feet tall");
    height = i;
  }
  void info() {
    prt("Tree is " + height
        + " feet tall");
  }
  void info(String s) {
    prt(s + ": Tree is "
        + height + " feet tall");
  }
  static void prt(String s) {
    System.out.println(s);
  }
}

public class Overloading {
  public static void main(String[] args) {
    for(int i = 0; i < 5; i++) {
      Tree t = new Tree(i);
      t.info();
      t.info("overloaded method");
    }
    // Overloaded constructor:
    new Tree();
  }
} ///:~

Tree既可创建成一颗种子,不含任何参数;亦可创建成生长在苗圃中的植物。为支持这种创建,共使用了两个构造器,一个没有参数(我们把没有参数的构造器称作“默认构造器”,注释①),另一个采用现成的高度。

①:在Sun公司出版的一些Java资料中,用简陋但很说明问题的词语称呼这类构造器——“无参数构造器”(no-arg constructors)。但“默认构造器”这个称呼已使用了许多年,所以我选择了它。

我们也有可能希望通过多种途径调用info()方法。例如,假设我们有一条额外的消息想显示出来,就使用String参数;而假设没有其他话可说,就不使用。由于为显然相同的概念赋予了两个独立的名字,所以看起来可能有些古怪。幸运的是,方法重载允许我们为两者使用相同的名字。

4.2.1 区分重载方法

若方法有同样的名字,Java怎样知道我们指的哪一个方法呢?这里有一个简单的规则:每个重载的方法都必须采取独一无二的参数类型列表。

若稍微思考几秒钟,就会想到这样一个问题:除根据参数的类型,程序员如何区分两个同名方法的差异呢?

即使参数的顺序也足够我们区分两个方法(尽管我们通常不愿意采用这种方法,因为它会产生难以维护的代码):

//: OverloadingOrder.java
// Overloading based on the order of
// the arguments.

public class OverloadingOrder {
  static void print(String s, int i) {
    System.out.println(
      "String: " + s +
      ", int: " + i);
  }
  static void print(int i, String s) {
    System.out.println(
      "int: " + i +
      ", String: " + s);
  }
  public static void main(String[] args) {
    print("String first", 11);
    print(99, "Int first");
  }
} ///:~

两个print()方法有完全一致的参数,但顺序不同,可据此区分它们。

4.2.2 基本类型的重载

主(数据)类型能从一个“较小”的类型自动转变成一个“较大”的类型。涉及重载问题时,这会稍微造成一些混乱。下面这个例子揭示了将基本类型传递给重载的方法时发生的情况:

//: PrimitiveOverloading.java
// Promotion of primitives and overloading

public class PrimitiveOverloading {
  // boolean can't be automatically converted
  static void prt(String s) {
    System.out.println(s);
  }

  void f1(char x) { prt("f1(char)"); }
  void f1(byte x) { prt("f1(byte)"); }
  void f1(short x) { prt("f1(short)"); }
  void f1(int x) { prt("f1(int)"); }
  void f1(long x) { prt("f1(long)"); }
  void f1(float x) { prt("f1(float)"); }
  void f1(double x) { prt("f1(double)"); }

  void f2(byte x) { prt("f2(byte)"); }
  void f2(short x) { prt("f2(short)"); }
  void f2(int x) { prt("f2(int)"); }
  void f2(long x) { prt("f2(long)"); }
  void f2(float x) { prt("f2(float)"); }
  void f2(double x) { prt("f2(double)"); }

  void f3(short x) { prt("f3(short)"); }
  void f3(int x) { prt("f3(int)"); }
  void f3(long x) { prt("f3(long)"); }
  void f3(float x) { prt("f3(float)"); }
  void f3(double x) { prt("f3(double)"); }

  void f4(int x) { prt("f4(int)"); }
  void f4(long x) { prt("f4(long)"); }
  void f4(float x) { prt("f4(float)"); }
  void f4(double x) { prt("f4(double)"); }

  void f5(long x) { prt("f5(long)"); }
  void f5(float x) { prt("f5(float)"); }
  void f5(double x) { prt("f5(double)"); }

  void f6(float x) { prt("f6(float)"); }
  void f6(double x) { prt("f6(double)"); }

  void f7(double x) { prt("f7(double)"); }

  void testConstVal() {
    prt("Testing with 5");
    f1(5);f2(5);f3(5);f4(5);f5(5);f6(5);f7(5);
  }
  void testChar() {
    char x = 'x';
    prt("char argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testByte() {
    byte x = 0;
    prt("byte argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testShort() {
    short x = 0;
    prt("short argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testInt() {
    int x = 0;
    prt("int argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testLong() {
    long x = 0;
    prt("long argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testFloat() {
    float x = 0;
    prt("float argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  void testDouble() {
    double x = 0;
    prt("double argument:");
    f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x);
  }
  public static void main(String[] args) {
    PrimitiveOverloading p =
      new PrimitiveOverloading();
    p.testConstVal();
    p.testChar();
    p.testByte();
    p.testShort();
    p.testInt();
    p.testLong();
    p.testFloat();
    p.testDouble();
  }
} ///:~

若观察这个程序的输出,就会发现常数值5被当作一个int值处理。所以假若可以使用一个重载的方法,就能获取它使用的int值。在其他所有情况下,若我们的数据类型“小于”方法中使用的参数,就会对那种数据类型进行“转型”处理。char获得的效果稍有些不同,这是由于假期它没有发现一个准确的char匹配,就会转型为int

若我们的参数“大于”重载方法期望的参数,这时又会出现什么情况呢?对前述程序的一个修改揭示出了答案:

//: Demotion.java
// Demotion of primitives and overloading

public class Demotion {
  static void prt(String s) {
    System.out.println(s);
  }

  void f1(char x) { prt("f1(char)"); }
  void f1(byte x) { prt("f1(byte)"); }
  void f1(short x) { prt("f1(short)"); }
  void f1(int x) { prt("f1(int)"); }
  void f1(long x) { prt("f1(long)"); }
  void f1(float x) { prt("f1(float)"); }
  void f1(double x) { prt("f1(double)"); }

  void f2(char x) { prt("f2(char)"); }
  void f2(byte x) { prt("f2(byte)"); }
  void f2(short x) { prt("f2(short)"); }
  void f2(int x) { prt("f2(int)"); }
  void f2(long x) { prt("f2(long)"); }
  void f2(float x) { prt("f2(float)"); }

  void f3(char x) { prt("f3(char)"); }
  void f3(byte x) { prt("f3(byte)"); }
  void f3(short x) { prt("f3(short)"); }
  void f3(int x) { prt("f3(int)"); }
  void f3(long x) { prt("f3(long)"); }

  void f4(char x) { prt("f4(char)"); }
  void f4(byte x) { prt("f4(byte)"); }
  void f4(short x) { prt("f4(short)"); }
  void f4(int x) { prt("f4(int)"); }

  void f5(char x) { prt("f5(char)"); }
  void f5(byte x) { prt("f5(byte)"); }
  void f5(short x) { prt("f5(short)"); }

  void f6(char x) { prt("f6(char)"); }
  void f6(byte x) { prt("f6(byte)"); }

  void f7(char x) { prt("f7(char)"); }

  void testDouble() {
    double x = 0;
    prt("double argument:");
    f1(x);f2((float)x);f3((long)x);f4((int)x);
    f5((short)x);f6((byte)x);f7((char)x);
  }
  public static void main(String[] args) {
    Demotion p = new Demotion();
    p.testDouble();
  }
} ///:~

在这里,方法采用了容量更小、范围更窄的基本类型值。若我们的参数范围比它宽,就必须用括号中的类型名将其转为适当的类型。如果不这样做,编译器会报告出错。

大家可注意到这是一种“缩小转换”。也就是说,在转换或转型过程中可能丢失一些信息。这正是编译器强迫我们明确定义的原因——我们需明确表达想要转型的愿望。

4.2.3 返回值重载

我们很易对下面这些问题感到迷惑:为什么只有类名和方法参数列出?为什么不根据返回值对方法加以区分?比如对下面这两个方法来说,虽然它们有同样的名字和参数,但其实是很容易区分的:

void f() {}
int f() {}

若编译器可根据上下文(语境)明确判断出含义,比如在int x=f()中,那么这样做完全没有问题。然而,我们也可能调用一个方法,同时忽略返回值;我们通常把这称为“为它的副作用去调用一个方法”,因为我们关心的不是返回值,而是方法调用的其他效果。所以假如我们象下面这样调用方法:

f();

Java怎样判断f()的具体调用方式呢?而且别人如何识别并理解代码呢?由于存在这一类的问题,所以不能根据返回值类型来区分重载的方法。

4.2.4 默认构造器

正如早先指出的那样,默认构造器是没有参数的。它们的作用是创建一个“空对象”。若创建一个没有构造器的类,则编译程序会帮我们自动创建一个默认构造器。例如:

//: DefaultConstructor.java

class Bird {
  int i;
}

public class DefaultConstructor {
  public static void main(String[] args) {
    Bird nc = new Bird(); // default!
  }
} ///:~

对于下面这一行:

new Bird();

它的作用是新建一个对象,并调用默认构造器——即使尚未明确定义一个象这样的构造器。若没有它,就没有方法可以调用,无法构建我们的对象。然而,如果已经定义了一个构造器(无论是否有参数),编译程序都不会帮我们自动生成一个:

class Bush {
Bush(int i) {}
Bush(double d) {}
}

现在,假若使用下述代码:

new Bush();

编译程序就会报告自己找不到一个相符的构造器。就好象我们没有设置任何构造器,编译程序会说:“你看来似乎需要一个构造器,所以让我们给你制造一个吧。”但假如我们写了一个构造器,编译程序就会说:“啊,你已写了一个构造器,所以我知道你想干什么;如果你不放置一个默认的,是由于你打算省略它。”

4.2.5 this关键字

如果有两个同类型的对象,分别叫作ab,那么您也许不知道如何为这两个对象同时调用一个f()方法:

class Banana { void f(int i) { /* ... */ } }
Banana a = new Banana(), b = new Banana();
a.f(1);
b.f(2);

若只有一个名叫f()的方法,它怎样才能知道自己是为a还是为b调用的呢?

为了能用简便的、面向对象的语法来书写代码——亦即“将消息发给对象”,编译器为我们完成了一些幕后工作。其中的秘密就是第一个参数传递给方法f(),而且那个参数是准备操作的那个对象的引用。所以前述的两个方法调用就变成了下面这样的形式:

Banana.f(a,1);
Banana.f(b,2);

这是内部的表达形式,我们并不能这样书写表达式,并试图让编译器接受它。但是,通过它可理解幕后到底发生了什么事情。

假定我们在一个方法的内部,并希望获得当前对象的引用。由于那个引用是由编译器“秘密”传递的,所以没有标识符可用。然而,针对这一目的有个专用的关键字:thisthis关键字(注意只能在方法内部使用)可为已调用了其方法的那个对象生成相应的引用。可象对待其他任何对象引用一样对待这个引用。但要注意,假若准备从自己某个类的另一个方法内部调用一个类方法,就不必使用this。只需简单地调用那个方法即可。当前的this引用会自动应用于其他方法。所以我们能使用下面这样的代码:

class Apricot {
void pick() { /* ... */ }
void pit() { pick(); /* ... */ }
}

pit()内部,我们可以说this.pick(),但事实上无此必要。编译器能帮我们自动完成。this关键字只能用于那些特殊的类——需明确使用当前对象的引用。例如,假若您希望将引用返回给当前对象,那么它经常在return语句中使用。

//: Leaf.java
// Simple use of the "this" keyword

public class Leaf {
  private int i = 0;
  Leaf increment() {
    i++;
    return this;
  }
  void print() {
    System.out.println("i = " + i);
  }
  public static void main(String[] args) {
    Leaf x = new Leaf();
    x.increment().increment().increment().print();
  }
} ///:~

由于increment()通过this关键字返回当前对象的引用,所以可以方便地对同一个对象执行多项操作。

(1) 在构造器里调用构造器

若为一个类写了多个构造器,那么经常都需要在一个构造器里调用另一个构造器,以避免写重复的代码。可用this关键字做到这一点。

通常,当我们说this的时候,都是指“这个对象”或者“当前对象”。而且它本身会产生当前对象的一个引用。在一个构造器中,若为其赋予一个参数列表,那么this关键字会具有不同的含义:它会对与那个参数列表相符的构造器进行明确的调用。这样一来,我们就可通过一条直接的途径来调用其他构造器。如下所示:

//: Flower.java
// Calling constructors with "this"

public class Flower {
  private int petalCount = 0;
  private String s = new String("null");
  Flower(int petals) {
    petalCount = petals;
    System.out.println(
      "Constructor w/ int arg only, petalCount= "
      + petalCount);
  }
  Flower(String ss) {
    System.out.println(
      "Constructor w/ String arg only, s=" + ss);
    s = ss;
  }
  Flower(String s, int petals) {
    this(petals);
//!    this(s); // Can't call two!
    this.s = s; // Another use of "this"
    System.out.println("String & int args");
  }
  Flower() {
    this("hi", 47);
    System.out.println(
      "default constructor (no args)");
  }
  void print() {
//!    this(11); // Not inside non-constructor!
    System.out.println(
      "petalCount = " + petalCount + " s = "+ s);
  }
  public static void main(String[] args) {
    Flower x = new Flower();
    x.print();
  }
} ///:~

其中,构造器Flower(String s,int petals)向我们揭示出这样一个问题:尽管可用this调用一个构造器,但不可调用两个。除此以外,构造器调用必须是我们做的第一件事情,否则会收到编译程序的报错信息。

这个例子也向大家展示了this的另一项用途。由于参数s的名字以及成员数据s的名字是相同的,所以会出现混淆。为解决这个问题,可用this.s来引用成员数据。经常都会在Java代码里看到这种形式的应用,本书的大量地方也采用了这种做法。

print()中,我们发现编译器不让我们从除了一个构造器之外的其他任何方法内部调用一个构造器。

(2) static的含义

理解了this关键字后,我们可更完整地理解static(静态)方法的含义。它意味着一个特定的方法没有this。我们不可从一个static方法内部发出对非static方法的调用(注释②),尽管反过来说是可以的。而且在没有任何对象的前提下,我们可针对类本身发出对一个static方法的调用。事实上,那正是static方法最基本的意义。它就好象我们创建一个全局函数的等价物(在C语言中)。除了全局函数不允许在Java中使用以外,若将一个static方法置入一个类的内部,它就可以访问其他static方法以及static字段。

②:有可能发出这类调用的一种情况是我们将一个对象引用传到static方法内部。随后,通过引用(此时实际是this),我们可调用非static方法,并访问非static字段。但一般地,如果真的想要这样做,只要制作一个普通的、非static方法即可。

有些人抱怨static方法并不是“面向对象”的,因为它们具有全局函数的某些特点;利用static方法,我们不必向对象发送一条消息,因为不存在this。这可能是一个清楚的参数,若您发现自己使用了大量静态方法,就应重新思考自己的策略。然而,static的概念是非常实用的,许多时候都需要用到它。所以至于它们是否真的“面向对象”,应该留给理论家去讨论。事实上,即使Smalltalk在自己的“类方法”里也有类似于static的东西。

4.3 清除:收尾和垃圾收集

程序员都知道“初始化”的重要性,但通常忘记清除的重要性。毕竟,谁需要来清除一个int呢?但是对于库来说,用完后简单地“释放”一个对象并非总是安全的。当然,Java可用垃圾收集器回收由不再使用的对象占据的内存。现在考虑一种非常特殊且不多见的情况。假定我们的对象分配了一个“特殊”内存区域,没有使用new。垃圾收集器只知道释放那些由new分配的内存,所以不知道如何释放对象的“特殊”内存。为解决这个问题,Java提供了一个名为finalize()的方法,可为我们的类定义它。在理想情况下,它的工作原理应该是这样的:一旦垃圾收集器准备好释放对象占用的存储空间,它首先调用finalize(),而且只有在下一次垃圾收集过程中,才会真正回收对象的内存。所以如果使用finalize(),就可以在垃圾收集期间进行一些重要的清除或清扫工作。

但也是一个潜在的编程陷阱,因为有些程序员(特别是在C++开发背景的)刚开始可能会错误认为它就是在C++中为“析构器”(Destructor)使用的finalize()——析构(清除)一个对象的时候,肯定会调用这个函数。但在这里有必要区分一下C++和Java的区别,因为C++的对象肯定会被清除(排开编程错误的因素),而Java对象并非肯定能作为垃圾被“收集”去。或者换句话说:

垃圾收集并不等于“析构”!

若能时刻牢记这一点,踩到陷阱的可能性就会大大减少。它意味着在我们不再需要一个对象之前,有些行动是必须采取的,而且必须由自己来采取这些行动。Java并未提供“析构器”或者类似的概念,所以必须创建一个原始的方法,用它来进行这种清除。例如,假设在对象创建过程中,它会将自己描绘到屏幕上。如果不从屏幕明确删除它的图像,那么它可能永远都不会被清除。若在finalize()里置入某种删除机制,那么假设对象被当作垃圾收掉了,图像首先会将自身从屏幕上移去。但若未被收掉,图像就会保留下来。所以要记住的第二个重点是:

我们的对象可能不会当作垃圾被收掉!

有时可能发现一个对象的存储空间永远都不会释放,因为自己的程序永远都接近于用光空间的临界点。若程序执行结束,而且垃圾收集器一直都没有释放我们创建的任何对象的存储空间,则随着程序的退出,那些资源会返回给操作系统。这是一件好事情,因为垃圾收集本身也要消耗一些开销。如永远都不用它,那么永远也不用支出这部分开销。

4.3.1 finalize()用途何在

此时,大家可能已相信了自己应该将finalize()作为一种常规用途的清除方法使用。它有什么好处呢?

要记住的第三个重点是:

垃圾收集只跟内存有关!

也就是说,垃圾收集器存在的唯一原因是为了回收程序不再使用的内存。所以对于与垃圾收集有关的任何活动来说,其中最值得注意的是finalize()方法,它们也必须同内存以及它的回收有关。

但这是否意味着假如对象包含了其他对象,finalize()就应该明确释放那些对象呢?答案是否定的——垃圾收集器会负责释放所有对象占据的内存,无论这些对象是如何创建的。它将对finalize()的需求限制到特殊的情况。在这种情况下,我们的对象可采用与创建对象时不同的方法分配一些存储空间。但大家或许会注意到,Java中的所有东西都是对象,所以这到底是怎么一回事呢?

之所以要使用finalize(),看起来似乎是由于有时需要采取与Java的普通方法不同的一种方法,通过分配内存来做一些具有C风格的事情。这主要可以通过“固有方法”来进行,它是从Java里调用非Java方法的一种方式(固有方法的问题在附录A讨论)。C和C++是目前唯一获得固有方法支持的语言。但由于它们能调用通过其他语言编写的子程序,所以能够有效地调用任何东西。在非Java代码内部,也许能调用C的malloc()系列函数,用它分配存储空间。而且除非调用了free(),否则存储空间不会得到释放,从而造成内存“漏洞”的出现。当然,free()是一个C和C++函数,所以我们需要在finalize()内部的一个固有方法中调用它。

读完上述文字后,大家或许已弄清楚了自己不必过多地使用finalize()。这个思想是正确的;它并不是进行普通清除工作的理想场所。那么,普通的清除工作应在何处进行呢?

4.3.2 必须执行清除

为清除一个对象,那个对象的用户必须在希望进行清除的地点调用一个清除方法。这听起来似乎很容易做到,但却与C++“析构器”的概念稍有抵触。在C++中,所有对象都会析构(清除)。或者换句话说,所有对象都“应该”析构。若将C++对象创建成一个本地对象,比如在栈中创建(在Java中是不可能的),那么清除或析构工作就会在“结束花括号”所代表的、创建这个对象的作用域的末尾进行。若对象是用new创建的(类似于Java),那么当程序员调用C++的delete命令时(Java没有这个命令),就会调用相应的析构器。若程序员忘记了,那么永远不会调用析构器,我们最终得到的将是一个内存“漏洞”,另外还包括对象的其他部分永远不会得到清除。

相反,Java不允许我们创建本地(局部)对象——无论如何都要使用new。但在Java中,没有delete命令来释放对象,因为垃圾收集器会帮助我们自动释放存储空间。所以如果站在比较简化的立场,我们可以说正是由于存在垃圾收集机制,所以Java没有析构器。然而,随着以后学习的深入,就会知道垃圾收集器的存在并不能完全消除对析构器的需要,或者说不能消除对析构器代表的那种机制的需要(而且绝对不能直接调用finalize(),所以应尽量避免用它)。若希望执行除释放存储空间之外的其他某种形式的清除工作,仍然必须调用Java中的一个方法。它等价于C++的析构器,只是没后者方便。

finalize()最有用处的地方之一是观察垃圾收集的过程。下面这个例子向大家展示了垃圾收集所经历的过程,并对前面的陈述进行了总结。

//: Garbage.java
// Demonstration of the garbage
// collector and finalization

class Chair {
  static boolean gcrun = false;
  static boolean f = false;
  static int created = 0;
  static int finalized = 0;
  int i;
  Chair() {
    i = ++created;
    if(created == 47)
      System.out.println("Created 47");
  }
  protected void finalize() {
    if(!gcrun) {
      gcrun = true;
      System.out.println(
        "Beginning to finalize after " +
        created + " Chairs have been created");
    }
    if(i == 47) {
      System.out.println(
        "Finalizing Chair #47, " +
        "Setting flag to stop Chair creation");
      f = true;
    }
    finalized++;
    if(finalized >= created)
      System.out.println(
        "All " + finalized + " finalized");
  }
}

public class Garbage {
  public static void main(String[] args) {
    if(args.length == 0) {
      System.err.println("Usage: \n" +
        "java Garbage before\n  or:\n" +
        "java Garbage after");
      return;
    }
    while(!Chair.f) {
      new Chair();
      new String("To take up space");
    }
    System.out.println(
      "After all Chairs have been created:\n" +
      "total created = " + Chair.created +
      ", total finalized = " + Chair.finalized);
    if(args[0].equals("before")) {
      System.out.println("gc():");
      System.gc();
      System.out.println("runFinalization():");
      System.runFinalization();
    }
    System.out.println("bye!");
    if(args[0].equals("after"))
      System.runFinalizersOnExit(true);
  }
} ///:~

上面这个程序创建了许多Chair对象,而且在垃圾收集器开始运行后的某些时候,程序会停止创建Chair。由于垃圾收集器可能在任何时间运行,所以我们不能准确知道它在何时启动。因此,程序用一个名为gcrun的标记来指出垃圾收集器是否已经开始运行。利用第二个标记fChair可告诉main()它应停止对象的生成。这两个标记都是在finalize()内部设置的,它调用于垃圾收集期间。

另两个static变量——created以及finalized——分别用于跟踪已创建的对象数量以及垃圾收集器已进行完收尾工作的对象数量。最后,每个Chair都有它自己的(非staticint i,所以能跟踪了解它具体的编号是多少。编号为47的Chair进行完收尾工作后,标记会设为true,最终结束Chair对象的创建过程。

所有这些都在main()的内部进行——在下面这个循环里:

while(!Chair.f) {
new Chair();
new String("To take up space");
}

大家可能会疑惑这个循环什么时候会停下来,因为内部没有任何改变Chair.f值的语句。然而,finalize()进程会改变这个值,直至最终对编号47的对象进行收尾处理。

每次循环过程中创建的String对象只是属于额外的垃圾,用于吸引垃圾收集器——一旦垃圾收集器对可用内存的容量感到“紧张不安”,就会开始关注它。

运行这个程序的时候,提供了一个命令行参数before或者after。其中,before参数会调用System.gc()方法(强制执行垃圾收集器),同时还会调用System.runFinalization()方法,以便进行收尾工作。这些方法都可在Java 1.0中使用,但通过使用after参数而调用的runFinalizersOnExit()方法却只有Java 1.1及后续版本提供了对它的支持(注释③)。注意可在程序执行的任何时候调用这个方法,而且收尾程序的执行与垃圾收集器是否运行是无关的。

③:不幸的是,Java 1.0采用的垃圾收集器方案永远不能正确地调用finalize()。因此,finalize()方法(特别是那些用于关闭文件的)事实上经常都不会得到调用。现在有些文章声称所有收尾模块都会在程序退出的时候得到调用——即使到程序中止的时候,垃圾收集器仍未针对那些对象采取行动。这并不是真实的情况,所以我们根本不能指望finalize()能为所有对象而调用。特别地,finalize()在Java 1.0里几乎毫无用处。

前面的程序向我们揭示出:在Java 1.1中,收尾模块肯定会运行这一许诺已成为现实——但前提是我们明确地强制它采取这一操作。若使用一个不是beforeafter的参数(如none),那么两个收尾工作都不会进行,而且我们会得到象下面这样的输出:

Created 47

Created 47
Beginning to finalize after 8694 Chairs have been created
Finalizing Chair #47, Setting flag to stop Chair creation
After all Chairs have been created:
total created = 9834, total finalized = 108
bye!

因此,到程序结束的时候,并非所有收尾模块都会得到调用(注释④)。为强制进行收尾工作,可先调用System.gc(),再调用System.runFinalization()。这样可清除到目前为止没有使用的所有对象。这样做一个稍显奇怪的地方是在调用runFinalization()之前调用gc(),这看起来似乎与Sun公司的文档说明有些抵触,它宣称首先运行收尾模块,再释放存储空间。然而,若在这里首先调用runFinalization(),再调用gc(),收尾模块根本不会执行。

④:到你读到本书时,有些Java虚拟机(JVM)可能已开始表现出不同的行为。

针对所有对象,Java 1.1有时之所以会默认为跳过收尾工作,是由于它认为这样做的开销太大。不管用哪种方法强制进行垃圾收集,都可能注意到比没有额外收尾工作时较长的时间延迟。

4.4 成员初始化

Java尽自己的全力保证所有变量都能在使用前得到正确的初始化。若被定义成相对于一个方法的“局部”变量,这一保证就通过编译期的出错提示表现出来。因此,如果使用下述代码:

void f() {
int i;
i++;
}

就会收到一条出错提示消息,告诉你i可能尚未初始化。当然,编译器也可为i赋予一个默认值,但它看起来更象一个程序员的失误,此时默认值反而会“帮倒忙”。若强迫程序员提供一个初始值,就往往能够帮他/她纠出程序里的“Bug”。

然而,若将基本类型设为一个类的数据成员,情况就会变得稍微有些不同。由于任何方法都可以初始化或使用那个数据,所以在正式使用数据前,若还是强迫程序员将其初始化成一个适当的值,就可能不是一种实际的做法。然而,若为其赋予一个垃圾值,同样是非常不安全的。因此,一个类的所有基本类型数据成员都会保证获得一个初始值。可用下面这段小程序看到这些值:

//: InitialValues.java
// Shows default initial values

class Measurement {
  boolean t;
  char c;
  byte b;
  short s;
  int i;
  long l;
  float f;
  double d;
  void print() {
    System.out.println(
      "Data type      Inital value\n" +
      "boolean        " + t + "\n" +
      "char           " + c + "\n" +
      "byte           " + b + "\n" +
      "short          " + s + "\n" +
      "int            " + i + "\n" +
      "long           " + l + "\n" +
      "float          " + f + "\n" +
      "double         " + d);
  }
}

public class InitialValues {
  public static void main(String[] args) {
    Measurement d = new Measurement();
    d.print();
    /* In this case you could also say:
    new Measurement().print();
    */
  }
} ///:~

输入结果如下:

Data type      Inital value
boolean        false
char
byte           0
short          0
int            0
long           0
float          0.0
double         0.0

其中,Char值为空(NULL),没有数据打印出来。

稍后大家就会看到:在一个类的内部定义一个对象引用时,如果不将其初始化成新对象,那个引用就会获得一个空值。

4.4.1 规定初始化

如果想自己为变量赋予一个初始值,又会发生什么情况呢?为达到这个目的,一个最直接的做法是在类内部定义变量的同时也为其赋值(注意在C++里不能这样做,尽管C++的新手们总“想”这样做)。在下面,Measurement类内部的字段定义已发生了变化,提供了初始值:

class Measurement {
  boolean b = true;
  char c = 'x';
  byte B = 47;
  short s = 0xff;
  int i = 999;
  long l = 1;
  float f = 3.14f;
  double d = 3.14159;
  //. . .

亦可用相同的方法初始化非基本(主)类型的对象。若Depth是一个类,那么可象下面这样插入一个变量并进行初始化:

class Measurement {
Depth o = new Depth();
boolean b = true;
// . . .

若尚未为o指定一个初始值,同时不顾一切地提前试用它,就会得到一条运行期错误提示,告诉你产生了名为“异常”(Exception)的一个错误(在第9章详述)。
甚至可通过调用一个方法来提供初始值:

class CInit {
int i = f();
//...
}

当然,这个方法亦可使用参数,但那些参数不可是尚未初始化的其他类成员。因此,下面这样做是合法的:

class CInit {
int i = f();
int j = g(i);
//...
}

但下面这样做是非法的:

class CInit {
int j = g(i);
int i = f();
//...
}

这正是编译器对“向前引用”感到不适应的一个地方,因为它与初始化的顺序有关,而不是与程序的编译方式有关。

这种初始化方法非常简单和直观。它的一个限制是类型Measurement的每个对象都会获得相同的初始化值。有时,这正是我们希望的结果,但有时却需要盼望更大的灵活性。

4.4.2 构造器初始化

可考虑用构造器执行初始化进程。这样便可在编程时获得更大的灵活程度,因为我们可以在运行期调用方法和采取行动,从而“现场”决定初始化值。但要注意这样一件事情:不可妨碍自动初始化的进行,它在构造器进入之前就会发生。因此,假如使用下述代码:

class Counter {
int i;
Counter() { i = 7; }
// . . .

那么i首先会初始化成零,然后变成7。对于所有基本类型以及对象引用,这种情况都是成立的,其中包括在定义时已进行了明确初始化的那些一些。考虑到这个原因,编译器不会试着强迫我们在构造器任何特定的场所对元素进行初始化,或者在它们使用之前——初始化早已得到了保证(注释⑤)。

⑤:相反,C++有自己的“构造器初始模块列表”,能在进入构造器主体之前进行初始化,而且它对于对象来说是强制进行的。参见《Thinking in C++》。

(1) 初始化顺序

在一个类里,初始化的顺序是由变量在类内的定义顺序决定的。即使变量定义大量遍布于方法定义的中间,那些变量仍会在调用任何方法之前得到初始化——甚至在构造器调用之前。例如:

//: OrderOfInitialization.java
// Demonstrates initialization order.

// When the constructor is called, to create a
// Tag object, you'll see a message:
class Tag {
  Tag(int marker) {
    System.out.println("Tag(" + marker + ")");
  }
}

class Card {
  Tag t1 = new Tag(1); // Before constructor
  Card() {
    // Indicate we're in the constructor:
    System.out.println("Card()");
    t3 = new Tag(33); // Re-initialize t3
  }
  Tag t2 = new Tag(2); // After constructor
  void f() {
    System.out.println("f()");
  }
  Tag t3 = new Tag(3); // At end
}

public class OrderOfInitialization {
  public static void main(String[] args) {
    Card t = new Card();
    t.f(); // Shows that construction is done
  }
} ///:~

Card中,Tag对象的定义故意到处散布,以证明它们全都会在构造器进入或者发生其他任何事情之前得到初始化。除此之外,t3在构造器内部得到了重新初始化。它的输入结果如下:

Tag(1)
Tag(2)
Tag(3)
Card()
Tag(33)
f()

因此,t3引用会被初始化两次,一次在构造器调用前,一次在调用期间(第一个对象会被丢弃,所以它后来可被当作垃圾收掉)。从表面看,这样做似乎效率低下,但它能保证正确的初始化——若定义了一个重载的构造器,它没有初始化t3;同时在t3的定义里并没有规定“默认”的初始化方式,那么会产生什么后果呢?

(2) 静态数据的初始化

若数据是静态的(static),那么同样的事情就会发生;如果它属于一个基本类型,而且未对其初始化,就会自动获得自己的标准基本类型初始值;如果它是指向一个对象的引用,那么除非新建一个对象,并将引用同它连接起来,否则就会得到一个空值(NULL)。

如果想在定义的同时进行初始化,采取的方法与非静态值表面看起来是相同的。但由于static值只有一个存储区域,所以无论创建多少个对象,都必然会遇到何时对那个存储区域进行初始化的问题。下面这个例子可将这个问题说更清楚一些:

//: StaticInitialization.java
// Specifying initial values in a
// class definition.

class Bowl {
  Bowl(int marker) {
    System.out.println("Bowl(" + marker + ")");
  }
  void f(int marker) {
    System.out.println("f(" + marker + ")");
  }
}

class Table {
  static Bowl b1 = new Bowl(1);
  Table() {
    System.out.println("Table()");
    b2.f(1);
  }
  void f2(int marker) {
    System.out.println("f2(" + marker + ")");
  }
  static Bowl b2 = new Bowl(2);
}

class Cupboard {
  Bowl b3 = new Bowl(3);
  static Bowl b4 = new Bowl(4);
  Cupboard() {
    System.out.println("Cupboard()");
    b4.f(2);
  }
  void f3(int marker) {
    System.out.println("f3(" + marker + ")");
  }
  static Bowl b5 = new Bowl(5);
}

public class StaticInitialization {
  public static void main(String[] args) {
    System.out.println(
      "Creating new Cupboard() in main");
    new Cupboard();
    System.out.println(
      "Creating new Cupboard() in main");
    new Cupboard();
    t2.f2(1);
    t3.f3(1);
  }
  static Table t2 = new Table();
  static Cupboard t3 = new Cupboard();
} ///:~

Bowl允许我们检查一个类的创建过程,而TableCupboard能创建散布于类定义中的Bowlstatic成员。注意在static定义之前,Cupboard先创建了一个非staticBowl b3。它的输出结果如下:

Bowl(1)
Bowl(2)
Table()
f(1)
Bowl(4)
Bowl(5)
Bowl(3)
Cupboard()
f(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f(2)
f2(1)
f3(1)

static初始化只有在必要的时候才会进行。如果不创建一个Table对象,而且永远都不引用Table.b1Table.b2,那么static Bowl b1b2永远都不会创建。然而,只有在创建了第一个Table对象之后(或者发生了第一次static访问),它们才会创建。在那以后,static对象不会重新初始化。

初始化的顺序是首先static(如果它们尚未由前一次对象创建过程初始化),接着是非static对象。大家可从输出结果中找到相应的证据。

在这里有必要总结一下对象的创建过程。请考虑一个名为Dog的类:

(1) 类型为Dog的一个对象首次创建时,或者Dog类的static方法/static字段首次访问时,Java解释器必须找到Dog.class(在事先设好的类路径里搜索)。

(2) 找到Dog.class后(它会创建一个Class对象,这将在后面学到),它的所有static初始化模块都会运行。因此,static初始化仅发生一次——在Class对象首次载入的时候。

(3) 创建一个new Dog()时,Dog对象的构建进程首先会在内存堆(Heap)里为一个Dog对象分配足够多的存储空间。

(4) 这种存储空间会清为零,将Dog中的所有基本类型设为它们的默认值(零用于数字,以及booleanchar的等价设定)。

(5) 进行字段定义时发生的所有初始化都会执行。

(6) 执行构造器。正如第6章将要讲到的那样,这实际可能要求进行相当多的操作,特别是在涉及继承的时候。

(3) 明确进行的静态初始化

Java允许我们将其他static初始化工作划分到类内一个特殊的“static构建从句”(有时也叫作“静态块”)里。它看起来象下面这个样子:

class Spoon {
  static int i;
  static {
    i = 47;
  }
  // . . .

尽管看起来象个方法,但它实际只是一个static关键字,后面跟随一个方法主体。与其他static初始化一样,这段代码仅执行一次——首次生成那个类的一个对象时,或者首次访问属于那个类的一个static成员时(即便从未生成过那个类的对象)。例如:

//: ExplicitStatic.java
// Explicit static initialization
// with the "static" clause.

class Cup {
  Cup(int marker) {
    System.out.println("Cup(" + marker + ")");
  }
  void f(int marker) {
    System.out.println("f(" + marker + ")");
  }
}

class Cups {
  static Cup c1;
  static Cup c2;
  static {
    c1 = new Cup(1);
    c2 = new Cup(2);
  }
  Cups() {
    System.out.println("Cups()");
  }
}

public class ExplicitStatic {
  public static void main(String[] args) {
    System.out.println("Inside main()");
    Cups.c1.f(99);  // (1)
  }
  static Cups x = new Cups();  // (2)
  static Cups y = new Cups();  // (2)
} ///:~

在标记为(1)的行内访问static对象c1的时候,或在行(1)标记为注释,同时(2)行不标记成注释的时候,用于Cupsstatic初始化模块就会运行。若(1)和(2)都被标记成注释,则用于Cupsstatic初始化进程永远不会发生。

(4) 非静态实例的初始化

针对每个对象的非静态变量的初始化,Java 1.1提供了一种类似的语法格式。下面是一个例子:

//: Mugs.java
// Java 1.1 "Instance Initialization"

class Mug {
  Mug(int marker) {
    System.out.println("Mug(" + marker + ")");
  }
  void f(int marker) {
    System.out.println("f(" + marker + ")");
  }
}

public class Mugs {
  Mug c1;
  Mug c2;
  {
    c1 = new Mug(1);
    c2 = new Mug(2);
    System.out.println("c1 & c2 initialized");
  }
  Mugs() {
    System.out.println("Mugs()");
  }
  public static void main(String[] args) {
    System.out.println("Inside main()");
    Mugs x = new Mugs();
  }
} ///:~

大家可看到实例初始化从句:

  {
    c1 = new Mug(1);
    c2 = new Mug(2);
    System.out.println("c1 & c2 initialized");
  }

它看起来与静态初始化从句极其相似,只是static关键字从里面消失了。为支持对“匿名内部类”的初始化(参见第7章),必须采用这一语法格式。

4.5 数组初始化

在C中初始化数组极易出错,而且相当麻烦。C++通过“集合初始化”使其更安全(注释⑥)。Java则没有象C++那样的“集合”概念,因为Java中的所有东西都是对象。但它确实有自己的数组,通过数组初始化来提供支持。

数组代表一系列对象或者基本数据类型,所有相同的类型都封装到一起——采用一个统一的标识符名称。数组的定义和使用是通过方括号索引运算符进行的([])。为定义一个数组,只需在类型名后简单地跟随一对空方括号即可:

int[] al;

也可以将方括号置于标识符后面,获得完全一致的结果:

int al[];

这种格式与C和C++程序员习惯的格式是一致的。然而,最“通顺”的也许还是前一种语法,因为它指出类型是“一个int数组”。本书将沿用那种格式。

编译器不允许我们告诉它一个数组有多大。这样便使我们回到了“引用”的问题上。此时,我们拥有的一切就是指向数组的一个引用,而且尚未给数组分配任何空间。为了给数组创建相应的存储空间,必须编写一个初始化表达式。对于数组,初始化工作可在代码的任何地方出现,但也可以使用一种特殊的初始化表达式,它必须在数组创建的地方出现。这种特殊的初始化是一系列由花括号封闭起来的值。存储空间的分配(等价于使用new)将由编译器在这种情况下进行。例如:

int[] a1 = { 1, 2, 3, 4, 5 };

那么为什么还要定义一个没有数组的数组引用呢?

int[] a2;

事实上在Java中,可将一个数组分配给另一个,所以能使用下述语句:

a2 = a1;

我们真正准备做的是复制一个引用,就象下面演示的那样:

//: Arrays.java
// Arrays of primitives.

public class Arrays {
  public static void main(String[] args) {
    int[] a1 = { 1, 2, 3, 4, 5 };
    int[] a2;
    a2 = a1;
    for(int i = 0; i < a2.length; i++)
      a2[i]++;
    for(int i = 0; i < a1.length; i++)
      prt("a1[" + i + "] = " + a1[i]);
  }
  static void prt(String s) {
    System.out.println(s);
  }
} ///:~

大家看到a1获得了一个初始值,而a2没有;a2将在以后赋值——这种情况下是赋给另一个数组。

这里也出现了一些新东西:所有数组都有一个本质成员(无论它们是对象数组还是基本类型数组),可对其进行查询——但不是改变,从而获知数组内包含了多少个元素。这个成员就是length。与C和C++类似,由于Java数组从元素0开始计数,所以能索引的最大元素编号是length-1。如超出边界,C和C++会“默默”地接受,并允许我们胡乱使用自己的内存,这正是许多程序错误的根源。然而,Java可保留我们这受这一问题的损害,方法是一旦超过边界,就生成一个运行期错误(即一个“异常”,这是第9章的主题)。当然,由于需要检查每个数组的访问,所以会消耗一定的时间和多余的代码量,而且没有办法把它关闭。这意味着数组访问可能成为程序效率低下的重要原因——如果它们在关键的场合进行。但考虑到因特网访问的安全,以及程序员的编程效率,Java设计人员还是应该把它看作是值得的。

程序编写期间,如果不知道在自己的数组里需要多少元素,那么又该怎么办呢?此时,只需简单地用new在数组里创建元素。在这里,即使准备创建的是一个基本数据类型的数组,new也能正常地工作(new不会创建非数组的基本类型):

//: ArrayNew.java
// Creating arrays with new.
import java.util.*;

public class ArrayNew {
  static Random rand = new Random();
  static int pRand(int mod) {
    return Math.abs(rand.nextInt()) % mod + 1;
  }
  public static void main(String[] args) {
    int[] a;
    a = new int[pRand(20)];
    prt("length of a = " + a.length);
    for(int i = 0; i < a.length; i++)
      prt("a[" + i + "] = " + a[i]);
  }
  static void prt(String s) {
    System.out.println(s);
  }
} ///:~

由于数组的大小是随机决定的(使用早先定义的pRand()方法),所以非常明显,数组的创建实际是在运行期间进行的。除此以外,从这个程序的输出中,大家可看到基本数据类型的数组元素会自动初始化成“空”值(对于数值,空值就是零;对于char,它是null;而对于boolean,它却是false)。

当然,数组可能已在相同的语句中定义和初始化了,如下所示:

int[] a = new int[pRand(20)];

若操作的是一个非基本类型对象的数组,那么无论如何都要使用new。在这里,我们会再一次遇到引用问题,因为我们创建的是一个引用数组。请大家观察包装器类型Integer,它是一个类,而非基本数据类型:

//: ArrayClassObj.java
// Creating an array of non-primitive objects.
import java.util.*;

public class ArrayClassObj {
  static Random rand = new Random();
  static int pRand(int mod) {
    return Math.abs(rand.nextInt()) % mod + 1;
  }
  public static void main(String[] args) {
    Integer[] a = new Integer[pRand(20)];
    prt("length of a = " + a.length);
    for(int i = 0; i < a.length; i++) {
      a[i] = new Integer(pRand(500));
      prt("a[" + i + "] = " + a[i]);
    }
  }
  static void prt(String s) {
    System.out.println(s);
  }
} ///:~

在这儿,甚至在new调用后才开始创建数组:

Integer[] a = new Integer[pRand(20)];

它只是一个引用数组,而且除非通过创建一个新的Integer对象,从而初始化了对象引用,否则初始化进程不会结束:

a[i] = new Integer(pRand(500));

但若忘记创建对象,就会在运行期试图读取空数组位置时获得一个“异常”错误。

下面让我们看看打印语句中String对象的构成情况。大家可看到指向Integer对象的引用会自动转换,从而产生一个String,它代表着位于对象内部的值。

亦可用花括号封闭列表来初始化对象数组。可采用两种形式,第一种是Java 1.0允许的唯一形式。第二种(等价)形式自Java 1.1才开始提供支持:

//: ArrayInit.java
// Array initialization

public class ArrayInit {
  public static void main(String[] args) {
    Integer[] a = {
      new Integer(1),
      new Integer(2),
      new Integer(3),
    };

    // Java 1.1 only:
    Integer[] b = new Integer[] {
      new Integer(1),
      new Integer(2),
      new Integer(3),
    };
  }
} ///:~

这种做法大多数时候都很有用,但限制也是最大的,因为数组的大小是在编译期间决定的。初始化列表的最后一个逗号是可选的(这一特性使长列表的维护变得更加容易)。

数组初始化的第二种形式(Java 1.1开始支持)提供了一种更简便的语法,可创建和调用方法,获得与C的“变量参数列表”(C通常把它简称为“变参表”)一致的效果。这些效果包括未知的参数数量以及未知的类型(如果这样选择的话)。由于所有类最终都是从通用的根类Object中继承的,所以能创建一个方法,令其获取一个Object数组,并象下面这样调用它:

//: VarArgs.java
// Using the Java 1.1 array syntax to create
// variable argument lists

class A { int i; }

public class VarArgs {
  static void f(Object[] x) {
    for(int i = 0; i < x.length; i++)
      System.out.println(x[i]);
  }
  public static void main(String[] args) {
    f(new Object[] {
        new Integer(47), new VarArgs(),
        new Float(3.14), new Double(11.11) });
    f(new Object[] {"one", "two", "three" });
    f(new Object[] {new A(), new A(), new A()});
  }
} ///:~

此时,我们对这些未知的对象并不能采取太多的操作,而且这个程序利用自动String转换对每个Object做一些有用的事情。在第11章(运行期类型识别或RTTI),大家还会学习如何调查这类对象的准确类型,使自己能对它们做一些有趣的事情。

4.5.1 多维数组

在Java里可以方便地创建多维数组:

//: MultiDimArray.java
// Creating multidimensional arrays.
import java.util.*;

public class MultiDimArray {
  static Random rand = new Random();
  static int pRand(int mod) {
    return Math.abs(rand.nextInt()) % mod + 1;
  }
  public static void main(String[] args) {
    int[][] a1 = {
      { 1, 2, 3, },
      { 4, 5, 6, },
    };
    for(int i = 0; i < a1.length; i++)
      for(int j = 0; j < a1[i].length; j++)
        prt("a1[" + i + "][" + j +
            "] = " + a1[i][j]);
    // 3-D array with fixed length:
    int[][][] a2 = new int[2][2][4];
    for(int i = 0; i < a2.length; i++)
      for(int j = 0; j < a2[i].length; j++)
        for(int k = 0; k < a2[i][j].length;
            k++)
          prt("a2[" + i + "][" +
              j + "][" + k +
              "] = " + a2[i][j][k]);
    // 3-D array with varied-length vectors:
    int[][][] a3 = new int[pRand(7)][][];
    for(int i = 0; i < a3.length; i++) {
      a3[i] = new int[pRand(5)][];
      for(int j = 0; j < a3[i].length; j++)
        a3[i][j] = new int[pRand(5)];
    }
    for(int i = 0; i < a3.length; i++)
      for(int j = 0; j < a3[i].length; j++)
        for(int k = 0; k < a3[i][j].length;
            k++)
          prt("a3[" + i + "][" +
              j + "][" + k +
              "] = " + a3[i][j][k]);
    // Array of non-primitive objects:
    Integer[][] a4 = {
      { new Integer(1), new Integer(2)},
      { new Integer(3), new Integer(4)},
      { new Integer(5), new Integer(6)},
    };
    for(int i = 0; i < a4.length; i++)
      for(int j = 0; j < a4[i].length; j++)
        prt("a4[" + i + "][" + j +
            "] = " + a4[i][j]);
    Integer[][] a5;
    a5 = new Integer[3][];
    for(int i = 0; i < a5.length; i++) {
      a5[i] = new Integer[3];
      for(int j = 0; j < a5[i].length; j++)
        a5[i][j] = new Integer(i*j);
    }
    for(int i = 0; i < a5.length; i++)
      for(int j = 0; j < a5[i].length; j++)
        prt("a5[" + i + "][" + j +
            "] = " + a5[i][j]);
  }
  static void prt(String s) {
    System.out.println(s);
  }
} ///:~

用于打印的代码里使用了length,所以它不必依赖固定的数组大小。
第一个例子展示了基本数据类型的一个多维数组。我们可用花括号定出数组内每个向量的边界:

int[][] a1 = {
{ 1, 2, 3, },
{ 4, 5, 6, },
};

每个方括号对都将我们移至数组的下一级。
第二个例子展示了用new分配的一个三维数组。在这里,整个数组都是立即分配的:

int[][][] a2 = new int[2][2][4];

但第三个例子却向大家揭示出构成矩阵的每个向量都可以有任意的长度:

    int[][][] a3 = new int[pRand(7)][][];
    for(int i = 0; i < a3.length; i++) {
      a3[i] = new int[pRand(5)][];
      for(int j = 0; j < a3[i].length; j++)
        a3[i][j] = new int[pRand(5)];
    }

对于第一个new创建的数组,它的第一个元素的长度是随机的,其他元素的长度则没有定义。for循环内的第二个new则会填写元素,但保持第三个索引的未定状态——直到碰到第三个new

根据输出结果,大家可以看到:假若没有明确指定初始化值,数组值就会自动初始化成零。
可用类似的表式处理非基本类型对象的数组。这从第四个例子可以看出,它向我们演示了用花括号收集多个new表达式的能力:

    Integer[][] a4 = {
      { new Integer(1), new Integer(2)},
      { new Integer(3), new Integer(4)},
      { new Integer(5), new Integer(6)},
    };

第五个例子展示了如何逐渐构建非基本类型的对象数组:

    Integer[][] a5;
    a5 = new Integer[3][];
    for(int i = 0; i < a5.length; i++) {
      a5[i] = new Integer[3];
      for(int j = 0; j < a5[i].length; j++)
        a5[i][j] = new Integer(i*j);
    }

i*j只是在Integer里置了一个有趣的值。

4.6 总结

作为初始化的一种具体操作形式,构造器应使大家明确感受到在语言中进行初始化的重要性。与C++的程序设计一样,判断一个程序效率如何,关键是看是否由于变量的初始化不正确而造成了严重的编程错误(Bug)。这些形式的错误很难发现,而且类似的问题也适用于不正确的清除或收尾工作。由于构造器使我们能保证正确的初始化和清除(若没有正确的构造器调用,编译器不允许对象创建),所以能获得完全的控制权和安全性。

在C++中,与“构建”相反的“析构”(Destruction)工作也是相当重要的,因为用new创建的对象必须明确地清除。在Java中,垃圾收集器会自动为所有对象释放内存,所以Java中等价的清除方法并不是经常都需要用到的。如果不需要类似于构造器的行为,Java的垃圾收集器可以极大简化编程工作,而且在内存的管理过程中增加更大的安全性。有些垃圾收集器甚至能清除其他资源,比如图形和文件引用等。然而,垃圾收集器确实也增加了运行期的开销。但这种开销到底造成了多大的影响却是很难看出的,因为到目前为止,Java解释器的总体运行速度仍然是比较慢的。随着这一情况的改观,我们应该能判断出垃圾收集器的开销是否使Java不适合做一些特定的工作(其中一个问题是垃圾收集器不可预测的性质)。

由于所有对象都肯定能获得正确的构建,所以同这儿讲述的情况相比,构造器实际做的事情还要多得多。特别地,当我们通过“创作”或“继承”生成新类的时候,对构建的保证仍然有效,而且需要一些附加的语法来提供对它的支持。大家将在以后的章节里详细了解创作、继承以及它们对构造器造成的影响。

4.7 练习

(1) 用默认构造器创建一个类(没有参数),用它打印一条消息。创建属于这个类的一个对象。

(2) 在练习1的基础上增加一个重载的构造器,令其采用一个String参数,并随同自己的消息打印出来。

(3) 以练习2创建的类为基础上,创建属于它的对象引用的一个数组,但不要实际创建对象并分配到数组里。运行程
序时,注意是否打印出来自构造器调用的初始化消息。

(4) 创建同引用数组联系起来的对象,最终完成练习3。

(5) 用参数beforeafternone运行程序,试验Garbage.java。重复这个操作,观察是否从输出中看出了一些固定的模式。改变代码,使System.runFinalization()System.gc()之前调用,再观察结果。

第4章 初始化和清除

“随着计算机的进步,‘不安全’的程序设计已成为造成编程代价高昂的罪魁祸首之一。”

“初始化”和“清除”是这些安全问题的其中两个。许多C程序的错误都是由于程序员忘记初始化一个变量造成的。对于现成的库,若用户不知道如何初始化库的一个组件,就往往会出现这一类的错误。清除是另一个特殊的问题,因为用完一个元素后,由于不再关心,所以很容易把它忘记。这样一来,那个元素占用的资源会一直保留下去,极易产生资源(主要是内存)用尽的后果。

C++为我们引入了“构造器”的概念。这是一种特殊的方法,在一个对象创建之后自动调用。Java也沿用了这个概念,但新增了自己的“垃圾收集器”,能在资源不再需要的时候自动释放它们。本章将讨论初始化和清除的问题,以及Java如何提供它们的支持。

5.1 包:库单元

我们用import关键字导入一个完整的库时,就会获得“包”(Package)。例如:

import java.util.*;

它的作用是导入完整的实用工具(Utility)库,该库属于标准Java开发工具包的一部分。由于Vector位于java.util里,所以现在要么指定完整名称java.util.Vector(可省略import语句),要么简单地指定一个Vector(因为import是默认的)。

若想导入单独一个类,可在import语句里指定那个类的名字:

import java.util.Vector;

现在,我们可以自由地使用Vector。然而,java.util中的其他任何类仍是不可使用的。

之所以要进行这样的导入,是为了提供一种特殊的机制,以便管理“命名空间”(Name Space)。我们所有类成员的名字相互间都会隔离起来。位于类A内的一个方法f()不会与位于类B内的、拥有相同“签名”(参数列表)的f()发生冲突。但类名会不会冲突呢?假设创建一个stack类,将它安装到已有一个stack类(由其他人编写)的机器上,这时会出现什么情况呢?对于因特网中的Java应用,这种情况会在用户毫不知晓的时候发生,因为类会在运行一个Java程序的时候自动下载。

正是由于存在名字潜在的冲突,所以特别有必要对Java中的命名空间进行完整的控制,而且需要创建一个完全独一无二的名字,无论因特网存在什么样的限制。

迄今为止,本书的大多数例子都仅存在于单个文件中,而且设计成局部(本地)使用,没有同包名发生冲突(在这种情况下,类名置于“默认包”内)。这是一种有效的做法,而且考虑到问题的简化,本书剩下的部分也将尽可能地采用它。然而,若计划创建一个“对因特网友好”或者说“适合在因特网使用”的程序,必须考虑如何防止类名的重复。

为Java创建一个源码文件的时候,它通常叫作一个“编辑单元”(有时也叫作“翻译单元”)。每个编译单元都必须有一个以.java结尾的名字。而且在编译单元的内部,可以有一个公共(public)类,它必须拥有与文件相同的名字(包括大小写形式,但排除.java文件扩展名)。如果不这样做,编译器就会报告出错。每个编译单元内都只能有一个public类(同样地,否则编译器会报告出错)。那个编译单元剩下的类(如果有的话)可在那个包外面的世界面前隐藏起来,因为它们并非“公共”的(非public),而且它们由用于主public类的“支撑”类组成。

编译一个.java文件时,我们会获得一个名字完全相同的输出文件;但对于.java文件中的每个类,它们都有一个.class扩展名。因此,我们最终从少量的.java文件里有可能获得数量众多的.class文件。如以前用一种汇编语言写过程序,那么可能已习惯编译器先分割出一种过渡形式(通常是一个.obj文件),再用一个链接器将其与其他东西封装到一起(生成一个可执行文件),或者与一个库封装到一起(生成一个库)。但那并不是Java的工作方式。一个有效的程序就是一系列.class文件,它们可以封装和压缩到一个JAR文件里(使用Java 1.1提供的jar工具)。Java解释器负责对这些文件的寻找、装载和解释(注释①)。

①:Java并没有强制一定要使用解释器。一些固有代码的Java编译器可生成单独的可执行文件。

“库”也由一系列类文件构成。每个文件都有一个public类(并没强迫使用一个public类,但这种情况最很典型的),所以每个文件都有一个组件。如果想将所有这些组件(它们在各自独立的.java.class文件里)都归纳到一起,那么package关键字就可以发挥作用)。

若在一个文件的开头使用下述代码:

package mypackage;

那么package语句必须作为文件的第一个非注释语句出现。该语句的作用是指出这个编译单元属于名为mypackage的一个库的一部分。或者换句话说,它表明这个编译单元内的public类名位于mypackage这个名字的下面。如果其他人想使用这个名字,要么指出完整的名字,要么与mypackage联合使用import关键字(使用前面给出的选项)。注意根据Java包(封装)的约定,名字内的所有字母都应小写,甚至那些中间单词亦要如此。

例如,假定文件名是MyClass.java。它意味着在那个文件有一个、而且只能有一个public类。而且那个类的名字必须是MyClass(包括大小写形式):

package mypackage;
public class MyClass {
// . . .

现在,如果有人想使用MyClass,或者想使用mypackage内的其他任何public类,他们必须用import关键字激活mypackage内的名字,使它们能够使用。另一个办法则是指定完整的名称:

mypackage.MyClass m = new mypackage.MyClass();

import关键字则可将其变得简洁得多:

import mypackage.*;
// . . .
MyClass m = new MyClass();

作为一名库设计者,一定要记住packageimport关键字允许我们做的事情就是分割单个全局命名空间,保证我们不会遇到名字的冲突——无论有多少人使用因特网,也无论多少人用Java编写自己的类。

5.1.1 创建独一无二的包名

大家或许已注意到这样一个事实:由于一个包永远不会真的“封装”到单独一个文件里面,它可由多个.class文件构成,所以局面可能稍微有些混乱。为避免这个问题,最合理的一种做法就是将某个特定包使用的所有.class文件都置入单个目录里。也就是说,我们要利用操作系统的分级文件结构避免出现混乱局面。这正是Java所采取的方法。

它同时也解决了另两个问题:创建独一无二的包名以及找出那些可能深藏于目录结构某处的类。正如我们在第2章讲述的那样,为达到这个目的,需要将.class文件的位置路径编码到package的名字里。但根据约定,编译器强迫package名的第一部分是类创建者的因特网域名。由于因特网域名肯定是独一无二的(由InterNIC保证——注释②,它控制着域名的分配),所以假如按这一约定行事,package的名称就肯定不会重复,所以永远不会遇到名称冲突的问题。换句话说,除非将自己的域名转让给其他人,而且对方也按照相同的路径名编写Java代码,否则名字的冲突是永远不会出现的。当然,如果你没有自己的域名,那么必须创造一个非常生僻的包名(例如自己的英文姓名),以便尽最大可能创建一个独一无二的包名。如决定发行自己的Java代码,那么强烈推荐去申请自己的域名,它所需的费用是非常低廉的。

②:ftp://ftp.internic.net

这个技巧的另一部分是将package名解析成自己机器上的一个目录。这样一来,Java程序运行并需要装载.class文件的时候(这是动态进行的,在程序需要创建属于那个类的一个对象,或者首次访问那个类的一个static成员时),它就可以找到.class文件驻留的那个目录。

Java解释器的工作程序如下:首先,它找到环境变量CLASSPATH(将Java或者具有Java解释能力的工具——如浏览器——安装到机器中时,通过操作系统进行设定)。CLASSPATH包含了一个或多个目录,它们作为一种特殊的“根”使用,从这里展开对.class文件的搜索。从那个根开始,解释器会寻找包名,并将每个点号(句点)替换成一个斜杠,从而生成从CLASSPATH根开始的一个路径名(所以package foo.bar.baz会变成foo\bar\baz或者foo/bar/baz;具体是正斜杠还是反斜杠由操作系统决定)。随后将它们连接到一起,成为CLASSPATH内的各个条目(入口)。以后搜索.class文件时,就可从这些地方开始查找与准备创建的类名对应的名字。此外,它也会搜索一些标准目录——这些目录与Java解释器驻留的地方有关。

为进一步理解这个问题,下面以我自己的域名为例,它是bruceeckel.com。将其反转过来后,com.bruceeckel就为我的类创建了独一无二的全局名称(comeduorgnet等扩展名以前在Java包中都是大写的,但自Java 1.2以来,这种情况已发生了变化。现在整个包名都是小写的)。由于决定创建一个名为util的库,我可以进一步地分割它,所以最后得到的包名如下:

package com.bruceeckel.util;

现在,可将这个包名作为下述两个文件的“命名空间”使用:

//: Vector.java
// Creating a package
package com.bruceeckel.util;

public class Vector {
  public Vector() {
    System.out.println(
      "com.bruceeckel.util.Vector");
  }
} ///:~

创建自己的包时,要求package语句必须是文件中的第一个“非注释”代码。第二个文件表面看起来是类似的:

//: List.java
// Creating a package
package com.bruceeckel.util;

public class List {
  public List() {
    System.out.println(
      "com.bruceeckel.util.List");
  }
} ///:~

这两个文件都置于我自己系统的一个子目录中:

C:\DOC\JavaT\com\bruceeckel\util

若通过它往回走,就会发现包名com.bruceeckel.util,但路径的第一部分又是什么呢?这是由CLASSPATH环境变量决定的。在我的机器上,它是:

CLASSPATH=.;D:\JAVA\LIB;C:\DOC\JavaT

可以看出,CLASSPATH里能包含大量备用的搜索路径。然而,使用JAR文件时要注意一个问题:必须将JAR文件的名字置于类路径里,而不仅仅是它所在的路径。所以对一个名为grape.jar的JAR文件来说,我们的类路径需要包括:

CLASSPATH=.;D:\JAVA\LIB;C:\flavors\grape.jar

正确设置好类路径后,可将下面这个文件置于任何目录里(若在执行该程序时遇到麻烦,请参见第3章的3.1.2小节“赋值”):

//: LibTest.java
// Uses the library
package c05;
import com.bruceeckel.util.*;

public class LibTest {
  public static void main(String[] args) {
    Vector v = new Vector();
    List l = new List();
  }
} ///:~

编译器遇到import语句后,它会搜索由CLASSPATH指定的目录,查找子目录com\bruceeckel\util,然后查找名称适当的已编译文件(对于VectorVector.class,对于List则是List.class)。注意VectorList内无论类还是需要的方法都必须设为public

(1) 自动编译

为导入的类首次创建一个对象时(或者访问一个类的static成员时),编译器会在适当的目录里寻找同名的.class文件(所以如果创建类X的一个对象,就应该是X.class)。若只发现X.class,它就是必须使用的那一个类。然而,如果它在相同的目录中还发现了一个X.java,编译器就会比较两个文件的日期标记。如果X.javaX.class新,就会自动编译X.java,生成一个最新的X.class
对于一个特定的类,或在与它同名的.java文件中没有找到它,就会对那个类采取上述的处理。

(2) 冲突

若通过 * 导入了两个库,而且它们包括相同的名字,这时会出现什么情况呢?例如,假定一个程序使用了下述导入语句:

import com.bruceeckel.util.*;
import java.util.*;

由于 java.util.* 也包含了一个Vector类,所以这会造成潜在的冲突。然而,只要冲突并不真的发生,那么就不会产生任何问题——这当然是最理想的情况,因为否则的话,就需要进行大量编程工作,防范那些可能可能永远也不会发生的冲突。

如现在试着生成一个Vector,就肯定会发生冲突。如下所示:

Vector v = new Vector();

它引用的到底是哪个Vector类呢?编译器对这个问题没有答案,读者也不可能知道。所以编译器会报告一个错误,强迫我们进行明确的说明。例如,假设我想使用标准的Java Vector,那么必须象下面这样编程:

java.util.Vector v = new java.util.Vector();

由于它(与CLASSPATH一起)完整指定了那个Vector的位置,所以不再需要 import java.util.* 语句,除非还想使用来自java.util的其他东西。

5.1.2 自定义工具库

掌握前述的知识后,接下来就可以开始创建自己的工具库,以便减少或者完全消除重复的代码。例如,可为System.out.println()创建一个别名,减少重复键入的代码量。它可以是名为tools的一个包(package)的一部分:

//: P.java
// The P.rint & P.rintln shorthand
package com.bruceeckel.tools;

public class P {
  public static void rint(Object obj) {
    System.out.print(obj);
  }
  public static void rint(String s) {
    System.out.print(s);
  }
  public static void rint(char[] s) {
    System.out.print(s);
  }
  public static void rint(char c) {
    System.out.print(c);
  }
  public static void rint(int i) {
    System.out.print(i);
  }
  public static void rint(long l) {
    System.out.print(l);
  }
  public static void rint(float f) {
    System.out.print(f);
  }
  public static void rint(double d) {
    System.out.print(d);
  }
  public static void rint(boolean b) {
    System.out.print(b);
  }
  public static void rintln() {
    System.out.println();
  }
  public static void rintln(Object obj) {
    System.out.println(obj);
  }
  public static void rintln(String s) {
    System.out.println(s);
  }
  public static void rintln(char[] s) {
    System.out.println(s);
  }
  public static void rintln(char c) {
    System.out.println(c);
  }
  public static void rintln(int i) {
    System.out.println(i);
  }
  public static void rintln(long l) {
    System.out.println(l);
  }
  public static void rintln(float f) {
    System.out.println(f);
  }
  public static void rintln(double d) {
    System.out.println(d);
  }
  public static void rintln(boolean b) {
    System.out.println(b);
  }
} ///:~

所有不同的数据类型现在都可以在一个新行输出(P.rintln()),或者不在一个新行输出(P.rint())。

大家可能会猜想这个文件所在的目录必须从某个CLASSPATH位置开始,然后继续com/bruceeckel/tools。编译完毕后,利用一个import语句,即可在自己系统的任何地方使用P.class文件。如下所示:

ToolTest.java

所以从现在开始,无论什么时候只要做出了一个有用的新工具,就可将其加入tools目录(或者自己的个人utiltools目录)。

(1) CLASSPATH的陷阱

P.java文件存在一个非常有趣的陷阱。特别是对于早期的Java实现方案来说,类路径的正确设定通常都是很困难的一项工作。编写这本书的时候,我引入了P.java文件,它最初看起来似乎工作很正常。但在某些情况下,却开始出现中断。在很长的时间里,我都确信这是Java或其他什么在实现时一个错误。但最后,我终于发现在一个地方引入了一个程序(即第17章要说明的CodePackager.java),它使用了一个不同的类P。由于它作为一个工具使用,所以有时候会进入类路径里;另一些时候则不会这样。但只要它进入类路径,那么假若执行的程序需要寻找com.bruceeckel.tools中的类,Java首先发现的就是CodePackager.java中的P。此时,编译器会报告一个特定的方法没有找到。这当然是非常令人头疼的,因为我们在前面的类P里明明看到了这个方法,而且根本没有更多的诊断报告可为我们提供一条线索,让我们知道找到的是一个完全不同的类(那甚至不是public的)。

乍一看来,这似乎是编译器的一个错误,但假若考察import语句,就会发现它只是说:“在这里可能发现了P”。然而,我们假定的是编译器搜索自己类路径的任何地方,所以一旦它发现一个P,就会使用它;若在搜索过程中发现了“错误的”一个,它就会停止搜索。这与我们在前面表述的稍微有些区别,因为存在一些讨厌的类,它们都位于包内。而这里有一个不在包内的P,但仍可在常规的类路径搜索过程中找到。

如果您遇到象这样的情况,请务必保证对于类路径的每个地方,每个名字都仅存在一个类。

5.1.3 利用导入改变行为

Java已取消的一种特性是C的“条件编译”,它允许我们改变参数,获得不同的行为,同时不改变其他任何代码。Java之所以抛弃了这一特性,可能是由于该特性经常在C里用于解决跨平台问题:代码的不同部分根据具体的平台进行编译,否则不能在特定的平台上运行。由于Java的设计思想是成为一种自动跨平台的语言,所以这种特性是没有必要的。

然而,条件编译还有另一些非常有价值的用途。一种很常见的用途就是调试代码。调试特性可在开发过程中使用,但在发行的产品中却无此功能。Alen Holub(www.holub.com)提出了利用包(package)来模仿条件编译的概念。根据这一概念,它创建了C“断定机制”一个非常有用的Java版本。之所以叫作“断定机制”,是由于我们可以说“它应该为真”或者“它应该为假”。如果语句不同意你的断定,就可以发现相关的情况。这种工具在调试过程中是特别有用的。

可用下面这个类进行程序调试:

//: Assert.java
// Assertion tool for debugging
package com.bruceeckel.tools.debug;

public class Assert {
  private static void perr(String msg) {
    System.err.println(msg);
  }
  public final static void is_true(boolean exp) {
    if(!exp) perr("Assertion failed");
  }
  public final static void is_false(boolean exp){
    if(exp) perr("Assertion failed");
  }
  public final static void
  is_true(boolean exp, String msg) {
    if(!exp) perr("Assertion failed: " + msg);
  }
  public final static void
  is_false(boolean exp, String msg) {
    if(exp) perr("Assertion failed: " + msg);
  }
} ///:~

这个类只是简单地封装了布尔测试。如果失败,就显示出出错消息。在第9章,大家还会学习一个更高级的错误控制工具,名为“异常控制”。但在目前这种情况下,perr()方法已经可以很好地工作。

如果想使用这个类,可在自己的程序中加入下面这一行:

import com.bruceeckel.tools.debug.*;

如欲清除断定机制,以便自己能发行最终的代码,我们创建了第二个Assert类,但却是在一个不同的包里:

//: Assert.java
// Turning off the assertion output
// so you can ship the program.
package com.bruceeckel.tools;

public class Assert {
  public final static void is_true(boolean exp){}
  public final static void is_false(boolean exp){}
  public final static void
  is_true(boolean exp, String msg) {}
  public final static void
  is_false(boolean exp, String msg) {}
} ///:~

现在,假如将前一个import语句变成下面这个样子:

import com.bruceeckel.tools.*;

程序便不再显示出断言。下面是个例子:

//: TestAssert.java
// Demonstrating the assertion tool
package c05;
// Comment the following, and uncomment the
// subsequent line to change assertion behavior:
import com.bruceeckel.tools.debug.*;
// import com.bruceeckel.tools.*;

public class TestAssert {
  public static void main(String[] args) {
    Assert.is_true((2 + 2) == 5);
    Assert.is_false((1 + 1) == 2);
    Assert.is_true((2 + 2) == 5, "2 + 2 == 5");
    Assert.is_false((1 + 1) == 2, "1 +1 != 2");
  }
} ///:~

通过改变导入的package,我们可将自己的代码从调试版本变成最终的发行版本。这种技术可应用于任何种类的条件代码。

5.1.4 包的停用

大家应注意这样一个问题:每次创建一个包后,都在为包取名时间接地指定了一个目录结构。这个包必须存在(驻留)于由它的名字规定的目录内。而且这个目录必须能从CLASSPATH开始搜索并发现。最开始的时候,package关键字的运用可能会令人迷惑,因为除非坚持遵守根据目录路径指定包名的规则,否则就会在运行期获得大量莫名其妙的消息,指出找不到一个特定的类——即使那个类明明就在相同的目录中。若得到象这样的一条消息,请试着将package语句作为注释标记出去。如果这样做行得通,就可知道问题到底出在哪儿。

5.2 Java访问指示符

针对类内每个成员的每个定义,Java访问指示符publicprotected以及private都置于它们的最前面——无论它们是一个数据成员,还是一个方法。每个访问指示符都只控制着对那个特定定义的访问。这与C++存在着显著不同。在C++中,访问指示符控制着它后面的所有定义,直到又一个访问指示符加入为止。

通过千丝万缕的联系,程序为所有东西都指定了某种形式的访问。在后面的小节里,大家要学习与各类访问有关的所有知识。首次从默认访问开始。

5.2.1 “友好的”

如果根本不指定访问指示符,就象本章之前的所有例子那样,这时会出现什么情况呢?默认的访问没有关键字,但它通常称为“友好”(Friendly)访问。这意味着当前包内的其他所有类都能访问“友好的”成员,但对包外的所有类来说,这些成员却是“私有”(Private)的,外界不得访问。由于一个编译单元(一个文件)只能从属于单个包,所以单个编译单元内的所有类相互间都是自动“友好”的。因此,我们也说友好元素拥有“包访问”权限。

友好访问允许我们将相关的类都组合到一个包里,使它们相互间方便地进行沟通。将类组合到一个包内以后(这样便允许友好成员的相互访问,亦即让它们“交朋友”),我们便“拥有”了那个包内的代码。只有我们已经拥有的代码才能友好地访问自己拥有的其他代码。我们可认为友好访问使类在一个包内的组合显得有意义,或者说前者是后者的原因。在许多语言中,我们在文件内组织定义的方式往往显得有些牵强。但在Java中,却强制用一种颇有意义的形式进行组织。除此以外,我们有时可能想排除一些类,不想让它们访问当前包内定义的类。

对于任何关系,一个非常重要的问题是“谁能访问我们的‘私有’或private代码”。类控制着哪些代码能够访问自己的成员。没有任何秘诀可以“闯入”。另一个包内推荐可以声明一个新类,然后说:“嗨,我是Bob的朋友!”,并指望看到Bob的protected(受到保护的)、友好的以及private(私有)的成员。为获得对一个访问权限,唯一的方法就是:

(1) 使成员成为public(公共的)。这样所有人从任何地方都可以访问它。

(2) 变成一个“友好”成员,方法是舍弃所有访问指示符,并将其类置于相同的包内。这样一来,其他类就可以访问成员。

(3) 正如以后引入“继承”概念后大家会知道的那样,一个继承的类既可以访问一个protected成员,也可以访问一个public成员(但不可访问private成员)。只有在两个类位于相同的包内时,它才可以访问友好成员。但现在不必关心这方面的问题。

(4) 提供“访问器/变化器”方法(亦称为“获取/设置”方法),以便读取和修改值。这是OOP环境中最正规的一种方法,也是Java Beans的基础——具体情况会在第13章介绍。

5.2.2 public:接口访问

使用public关键字时,它意味着紧随在public后面的成员声明适用于所有人,特别是适用于使用库的客户程序员。假定我们定义了一个名为dessert的包,其中包含下述单元(若执行该程序时遇到困难,请参考第3章3.1.2小节“赋值”):

//: Cookie.java
// Creates a library
package c05.dessert;

public class Cookie {
  public Cookie() {
   System.out.println("Cookie constructor");
  }
  void foo() { System.out.println("foo"); }
} ///:~

请记住,Cookie.java必须驻留在名为dessert的一个子目录内,而这个子目录又必须位于由CLASSPATH指定的C05目录下面(C05代表本书的第5章)。不要错误地以为Java无论如何都会将当前目录作为搜索的起点看待。如果不将一个.作为CLASSPATH的一部分使用,Java就不会考虑当前目录。

现在,假若创建使用了Cookie的一个程序,如下所示:

//: Dinner.java
// Uses the library
import c05.dessert.*;

public class Dinner {
  public Dinner() {
   System.out.println("Dinner constructor");
  }
  public static void main(String[] args) {
    Cookie x = new Cookie();
    //! x.foo(); // Can't access
  }
} ///:~

就可以创建一个Cookie对象,因为它的构造器是public的,而且类也是public的(公共类的概念稍后还会进行更详细的讲述)。然而,foo()成员不可在Dinner.java内访问,因为foo()只有在dessert包内才是“友好”的。

(1) 默认包

大家可能会惊讶地发现下面这些代码得以顺利编译——尽管它看起来似乎已违背了规则:

//: Cake.java
// Accesses a class in a separate
// compilation unit.

class Cake {
  public static void main(String[] args) {
    Pie x = new Pie();
    x.f();
  }
} ///:~

在位于相同目录的第二个文件里:

//: Pie.java
// The other class

class Pie {
  void f() { System.out.println("Pie.f()"); }
} ///:~

最初可能会把它们看作完全不相干的文件,然而Cake能创建一个Pie对象,并能调用它的f()方法!通常的想法会认为Pief()是“友好的”,所以不适用于Cake。它们确实是友好的——这部分结论非常正确。但它们之所以仍能在Cake.java中使用,是由于它们位于相同的目录中,而且没有明确的包名。Java把象这样的文件看作那个目录“默认包”的一部分,所以它们对于目录内的其他文件来说是“友好”的。

5.2.3 private:不能接触!

private关键字意味着除非那个特定的类,而且从那个类的方法里,否则没有人能访问那个成员。同一个包内的其他成员不能访问private成员,这使其显得似乎将类与我们自己都隔离起来。另一方面,也不能由几个合作的人创建一个包。所以private允许我们自由地改变那个成员,同时毋需关心它是否会影响同一个包内的另一个类。默认的“友好”包访问通常已经是一种适当的隐藏方法;请记住,对于包的用户来说,是不能访问一个“友好”成员的。这种效果往往能令人满意,因为默认访问是我们通常采用的方法。对于希望变成public(公共)的成员,我们通常明确地指出,令其可由客户程序员自由调用。而且作为一个结果,最开始的时候通常会认为自己不必频繁使用private关键字,因为完全可以在不用它的前提下发布自己的代码(这与C++是个鲜明的对比)。然而,随着学习的深入,大家就会发现private仍然有非常重要的用途,特别是在涉及多线程处理的时候(详情见第14章)。

下面是应用了private的一个例子:

//: IceCream.java
// Demonstrates "private" keyword

class Sundae {
  private Sundae() {}
  static Sundae makeASundae() {
    return new Sundae();
  }
}

public class IceCream {
  public static void main(String[] args) {
    //! Sundae x = new Sundae();
    Sundae x = Sundae.makeASundae();
  }
} ///:~

这个例子向我们证明了使用private的方便:有时可能想控制对象的创建方式,并防止有人直接访问一个特定的构造器(或者所有构造器)。在上面的例子中,我们不可通过它的构造器创建一个Sundae对象;相反,必须调用makeASundae()方法来实现(注释③)。

③:此时还会产生另一个影响:由于默认构造器是唯一获得定义的,而且它的属性是private,所以可防止对这个类的继承(这是第6章要重点讲述的主题)。

若确定一个类只有一个“助手”方法,那么对于任何方法来说,都可以把它们设为private,从而保证自己不会误在包内其他地方使用它,防止自己更改或删除方法。将一个方法的属性设为private后,可保证自己一直保持这一选项(然而,若一个引用被设为private,并不表明其他对象不能拥有指向同一个对象的public引用。有关“别名”的问题将在第12章详述)。

5.2.4 protected:“友好的一种”

protected(受到保护的)访问指示符要求大家提前有所认识。首先应注意这样一个事实:为继续学习本书一直到继承那一章之前的内容,并不一定需要先理解本小节的内容。但为了保持内容的完整,这儿仍然要对此进行简要说明,并提供相关的例子。

protected关键字为我们引入了一种名为“继承”的概念,它以现有的类为基础,并在其中加入新的成员,同时不会对现有的类产生影响——我们将这种现有的类称为“基类”或者“基本类”(Base Class)。亦可改变那个类现有成员的行为。对于从一个现有类的继承,我们说自己的新类“扩展”(extends)了那个现有的类。如下所示:

class Foo extends Bar {

类定义剩余的部分看起来是完全相同的。

若新建一个包,并从另一个包内的某个类里继承,则唯一能够访问的成员就是原来那个包的public成员。当然,如果在相同的包里进行继承,那么继承获得的包能够访问所有“友好”的成员。有些时候,基类的创建者喜欢提供一个特殊的成员,并允许访问派生类。这正是protected的工作。若往回引用5.2.2小节“public:接口访问”的那个Cookie.java文件,则下面这个类就不能访问“友好”的成员:

//: ChocolateChip.java
// Can't access friendly member
// in another class
import c05.dessert.*;

public class ChocolateChip extends Cookie {
  public ChocolateChip() {
   System.out.println(
     "ChocolateChip constructor");
  }
  public static void main(String[] args) {
    ChocolateChip x = new ChocolateChip();
    //! x.foo(); // Can't access foo
  }
} ///:~

对于继承,值得注意的一件有趣的事情是倘若方法foo()存在于类Cookie中,那么它也会存在于从Cookie继承的所有类中。但由于foo()在外部的包里是“友好”的,所以我们不能使用它。当然,亦可将其变成public。但这样一来,由于所有人都能自由访问它,所以可能并非我们所希望的局面。若象下面这样修改类Cookie

public class Cookie {
  public Cookie() {
    System.out.println("Cookie constructor");
  }
  protected void foo() {
    System.out.println("foo");
  }
}

那么仍然能在包dessert里“友好”地访问foo(),但从Cookie继承的其他东西亦可自由地访问它。然而,它并非公共的(public)。

5.3 接口与实现

我们通常认为访问控制是“隐藏实现细节”的一种方式。将数据和方法封装到类内后,可生成一种数据类型,它具有自己的特征与行为。但由于两方面重要的原因,访问为那个数据类型加上了自己的边界。第一个原因是规定客户程序员哪些能够使用,哪些不能。我们可在结构里构建自己的内部机制,不用担心客户程序员将其当作接口的一部分,从而自由地使用或者“滥用”。

这个原因直接导致了第二个原因:我们需要将接口同实现细节分离开。若结构在一系列程序中使用,但用户除了将消息发给public接口之外,不能做其他任何事情,我们就可以改变不属于public的所有东西(如“友好的”、protected以及private),同时不要求用户对他们的代码作任何修改。

我们现在是在一个面向对象的编程环境中,其中的一个类(class)实际是指“一类对象”,就象我们说“鱼类”或“鸟类”那样。从属于这个类的所有对象都共享这些特征与行为。“类”是对属于这一类的所有对象的外观及行为进行的一种描述。

在一些早期OOP语言中,如Simula-67,关键字class的作用是描述一种新的数据类型。同样的关键字在大多数面向对象的编程语言里都得到了应用。它其实是整个语言的焦点:需要新建数据类型的场合比那些用于容纳数据和方法的“容器”多得多。

在Java中,类是最基本的OOP概念。它是本书未采用粗体印刷的关键字之一——由于数量太多,所以会造成页面排版的严重混乱。

为清楚起见,可考虑用特殊的样式创建一个类:将public成员置于最开头,后面跟随protected、友好以及private成员。这样做的好处是类的使用者可从上向下依次阅读,并首先看到对自己来说最重要的内容(即public成员,因为它们可从文件的外部访问),并在遇到非公共成员后停止阅读,后者已经属于内部实现细节的一部分了。然而,利用由javadoc提供支持的注释文档(已在第2章介绍),代码的可读性问题已在很大程度上得到了解决。

public class X {
  public void pub1( ) { /* . . . */ }
  public void pub2( ) { /* . . . */ }
  public void pub3( ) { /* . . . */ }
  private void priv1( ) { /* . . . */ }
  private void priv2( ) { /* . . . */ }
  private void priv3( ) { /* . . . */ }
  private int i;
  // . . .
}

由于接口和实现细节仍然混合在一起,所以只是部分容易阅读。也就是说,仍然能够看到源码——实现的细节,因为它们需要保存在类里面。向一个类的消费者显示出接口实际是“类浏览器”的工作。这种工具能查找所有可用的类,总结出可对它们采取的全部操作(比如可以使用哪些成员等),并用一种清爽悦目的形式显示出来。到大家读到这本书的时候,所有优秀的Java开发工具都应推出了自己的浏览器。

5.4 类访问

在Java中,亦可用访问指示符判断出一个库内的哪些类可由那个库的用户使用。若想一个类能由客户程序员调用,可在类主体的起始花括号前面某处放置一个public关键字。它控制着客户程序员是否能够创建属于这个类的一个对象。

为控制一个类的访问,指示符必须在关键字class之前出现。所以我们能够使用:

public class Widget {

也就是说,假若我们的库名是mylib,那么所有客户程序员都能访问Widget——通过下述语句:

import mylib.Widget;

或者

import mylib.*;

然而,我们同时还要注意到一些额外的限制:

(1) 每个编译单元(文件)都只能有一个public类。每个编译单元有一个公共接口的概念是由那个公共类表达出来的。根据自己的需要,它可拥有任意多个提供支撑的“友好”类。但若在一个编译单元里使用了多个public类,编译器就会向我们提示一条出错消息。

(2) public类的名字必须与包含了编译单元的那个文件的名字完全相符,甚至包括它的大小写形式。所以对于Widget来说,文件的名字必须是Widget.java,而不应是widget.java或者WIDGET.java。同样地,如果出现不符,就会报告一个编译期错误。

(3) 可能(但并常见)有一个编译单元根本没有任何公共类。此时,可按自己的意愿任意指定文件名。

如果已经获得了mylib内部的一个类,准备用它完成由Widget或者mylib内部的其他某些public类执行的任务,此时又会出现什么情况呢?我们不希望花费力气为客户程序员编制文档,并感觉以后某个时候也许会进行大手笔的修改,并将自己的类一起删掉,换成另一个不同的类。为获得这种灵活处理的能力,需要保证没有客户程序员能够依赖自己隐藏于mylib内部的特定实现细节。为达到这个目的,只需将public关键字从类中剔除即可,这样便把类变成了“友好的”(类仅能在包内使用)。

注意不可将类设成private(那样会使除类之外的其他东西都不能访问它),也不能设成protected(注释④)。因此,我们现在对于类的访问只有两个选择:“友好的”或者public。若不愿其他任何人访问那个类,可将所有构造器设为private。这样一来,在类的一个static成员内部,除自己之外的其他所有人都无法创建属于那个类的一个对象(注释⑤)。如下例所示:

//: Lunch.java
// Demonstrates class access specifiers.
// Make a class effectively private
// with private constructors:

class Soup {
  private Soup() {}
  // (1) Allow creation via static method:
  public static Soup makeSoup() {
    return new Soup();
  }
  // (2) Create a static object and
  // return a reference upon request.
  // (The "Singleton" pattern):
  private static Soup ps1 = new Soup();
  public static Soup access() {
    return ps1;
  }
  public void f() {}
}

class Sandwich { // Uses Lunch
  void f() { new Lunch(); }
}

// Only one public class allowed per file:
public class Lunch {
  void test() {
    // Can't do this! Private constructor:
    //! Soup priv1 = new Soup();
    Soup priv2 = Soup.makeSoup();
    Sandwich f1 = new Sandwich();
    Soup.access().f();
  }
} ///:~

④:实际上,Java 1.1内部类既可以是“受到保护的”,也可以是“私有的”,但那属于特别情况。第7章会详细解释这个问题。

⑤:亦可通过从那个类继承来实现。

迄今为止,我们创建过的大多数方法都是要么返回void,要么返回一个基本数据类型。所以对下述定义来说:

public static Soup access() {
return psl;
}

它最开始多少会使人有些迷惑。位于方法名(access)前的单词指出方法到底返回什么。在这之前,我们看到的都是void,它意味着“什么也不返回”(void在英语里是“虚无”的意思。但亦可返回指向一个对象的引用,此时出现的就是这个情况。该方法返回一个引用,它指向类Soup的一个对象。

Soup类向我们展示出如何通过将所有构造器都设为private,从而防止直接创建一个类。请记住,假若不明确地至少创建一个构造器,就会自动创建默认构造器(没有参数)。若自己编写默认构造器,它就不会自动创建。把它变成private后,就没人能为那个类创建一个对象。但别人怎样使用这个类呢?上面的例子为我们揭示出了两个选择。第一个选择,我们可创建一个static方法,再通过它创建一个新的Soup,然后返回指向它的一个引用。如果想在返回之前对Soup进行一些额外的操作,或者想了解准备创建多少个Soup对象(可能是为了限制它们的个数),这种方案无疑是特别有用的。

第二个选择是采用“设计模式”(Design Pattern)技术,本书后面会对此进行详细介绍。通常方案叫作“单例”,因为它仅允许创建一个对象。类Soup的对象被创建成Soup的一个static private成员,所以有一个而且只能有一个。除非通过public方法access(),否则根本无法访问它。

正如早先指出的那样,如果不针对类的访问设置一个访问指示符,那么它会自动默认为“友好的”。这意味着那个类的对象可由包内的其他类创建,但不能由包外创建。请记住,对于相同目录内的所有文件,如果没有明确地进行package声明,那么它们都默认为那个目录的默认包的一部分。然而,假若那个类一个static成员的属性是public,那么客户程序员仍然能够访问那个static成员——即使它们不能创建属于那个类的一个对象。

5.5 总结

对于任何关系,最重要的一点都是规定好所有方面都必须遵守的界限或规则。创建一个库时,相当于建立了同那个库的用户(即“客户程序员”)的一种关系——那些用户属于另外的程序员,可能用我们的库自行构建一个应用程序,或者用我们的库构建一个更大的库。

如果不制订规则,客户程序员就可以随心所欲地操作一个类的所有成员,无论我们本来愿不愿意其中的一些成员被直接操作。所有东西都在别人面前都暴露无遗。

本章讲述了如何构建类,从而制作出理想的库。首先,我们讲述如何将一组类封装到一个库里。其次,我们讲述类如何控制对自己成员的访问。

一般情况下,一个C程序项目会在50K到100K行代码之间的某个地方开始中断。这是由于C仅有一个“命名空间”,所以名字会开始互相抵触,从而造成额外的管理开销。而在Java中,package关键字、包命名方案以及import关键字为我们提供对名字的完全控制,所以命名冲突的问题可以很轻易地得到避免。

有两方面的原因要求我们控制对成员的访问。第一个是防止用户接触那些他们不应碰的工具。对于数据类型的内部机制,那些工具是必需的。但它们并不属于用户接口的一部分,用户不必用它来解决自己的特定问题。所以将方法和字段变成“私有”(private)后,可极大方便用户。因为他们能轻易看出哪些对于自己来说是最重要的,以及哪些是自己需要忽略的。这样便简化了用户对一个类的理解。

进行访问控制的第二个、也是最重要的一个原因是:允许库设计者改变类的内部工作机制,同时不必担心它会对客户程序员产生什么影响。最开始的时候,可用一种方法构建一个类,后来发现需要重新构建代码,以便达到更快的速度。如接口和实现细节早已进行了明确的分隔与保护,就可以轻松地达到自己的目的,不要求用户改写他们的代码。

利用Java中的访问指示符,可有效控制类的创建者。那个类的用户可确切知道哪些是自己能够使用的,哪些则是可以忽略的。但更重要的一点是,它可确保没有任何用户能依赖一个类的基础实现机制的任何部分。作为一个类的创建者,我们可自由修改基础的实现细节,这一改变不会对客户程序员产生任何影响,因为他们不能访问类的那一部分。

有能力改变基础的实现细节后,除了能在以后改进自己的设置之外,也同时拥有了“犯错误”的自由。无论当初计划与设计时有多么仔细,仍然有可能出现一些失误。由于知道自己能相当安全地犯下这种错误,所以可以放心大胆地进行更多、更自由的试验。这对自己编程水平的提高是很有帮助的,使整个项目最终能更快、更好地完成。

一个类的公共接口是所有用户都能看见的,所以在进行分析与设计的时候,这是应尽量保证其准确性的最重要的一个部分。但也不必过于紧张,少许的误差仍然是允许的。若最初设计的接口存在少许问题,可考虑添加更多的方法,只要保证不删除客户程序员已在他们的代码里使用的东西。

5.6 练习

(1) 用publicprivateprotected以及“友好的”数据成员及方法成员创建一个类。创建属于这个类的一个对象,并观察在试图访问所有类成员时会获得哪种类型的编译器错误提示。注意同一个目录内的类属于“默认”包的一部分。

(2) 用protected数据创建一个类。在相同的文件里创建第二个类,用一个方法操纵第一个类里的protected数据。

(3) 新建一个目录,并编辑自己的CLASSPATH,以便包括那个新目录。将P.class文件复制到自己的新目录,然后改变文件名、P类以及方法名(亦可考虑添加额外的输出,观察它的运行过程)。在一个不同的目录里创建另一个程序,令其使用自己的新类。

(4) 在c05目录(假定在自己的CLASSPATH里)创建下述文件:

214页程序

然后在c05之外的另一个目录里创建下述文件:

214-215页程序

解释编译器为什么会产生一个错误。将Foreign(外部)类作为c05包的一部分改变了什么东西吗?

第5章 隐藏实现过程

“进行面向对象的设计时,一项基本的考虑是:如何将发生变化的东西与保持不变的东西分隔开。”

这一点对于库来说是特别重要的。那个库的用户(客户程序员)必须能依赖自己使用的那一部分,并知道一旦新版本的库出台,自己不需要改写代码。而与此相反,库的创建者必须能自由地进行修改与改进,同时保证客户程序员代码不会受到那些变动的影响。

为达到这个目的,需遵守一定的约定或规则。例如,库程序员在修改库内的一个类时,必须保证不删除已有的方法,因为那样做会造成客户程序员代码出现断点。然而,相反的情况却是令人痛苦的。对于一个数据成员,库的创建者怎样才能知道哪些数据成员已受到客户程序员的访问呢?若方法属于某个类唯一的一部分,而且并不一定由客户程序员直接使用,那么这种痛苦的情况同样是真实的。如果库的创建者想删除一种旧有的实现方案,并置入新代码,此时又该怎么办呢?对那些成员进行的任何改动都可能中断客户程序员的代码。所以库创建者处在一个尴尬的境地,似乎根本动弹不得。

为解决这个问题,Java推出了“访问指示符”的概念,允许库创建者声明哪些东西是客户程序员可以使用的,哪些是不可使用的。这种访问控制的级别在“最大访问”和“最小访问”的范围之间,分别包括:public,“友好的”(无关键字),protected以及private。根据前一段的描述,大家或许已总结出作为一名库设计者,应将所有东西都尽可能保持为private(私有),并只展示出那些想让客户程序员使用的方法。这种思路是完全正确的,尽管它有点儿违背那些用其他语言(特别是C)编程的人的直觉,那些人习惯于在没有任何限制的情况下访问所有东西。到这一章结束时,大家应该可以深刻体会到Java访问控制的价值。

然而,组件库以及控制谁能访问那个库的组件的概念现在仍不是完整的。仍存在这样一个问题:如何将组件绑定到单独一个统一的库单元里。这是通过Java的package(打包)关键字来实现的,而且访问指示符要受到类在相同的包还是在不同的包里的影响。所以在本章的开头,大家首先要学习库组件如何置入包里。这样才能理解访问指示符的完整含义。

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.10 总结

无论继承还是组合,我们都可以在现有类型的基础上创建一个新类型。但在典型情况下,我们通过组合来实现现有类型的“复用”或“重复使用”,将其作为新类型基础实现过程的一部分使用。但如果想实现接口的“复用”,就应使用继承。由于派生或派生出来的类拥有基类的接口,所以能够将其“向上转换”为基类。对于下一章要讲述的多态性问题,这一点是至关重要的。

尽管继承在面向对象的程序设计中得到了特别的强调,但在实际启动一个设计时,最好还是先考虑采用组合技术。只有在特别必要的时候,才应考虑采用继承技术(下一章还会讲到这个问题)。组合显得更加灵活。但是,通过对自己的成员类型应用一些继承技巧,可在运行期准确改变那些成员对象的类型,由此可改变它们的行为。

尽管对于快速项目开发来说,通过组合和继承实现的代码复用具有很大的帮助作用。但在允许其他程序员完全依赖它之前,一般都希望能重新设计自己的类结构。我们理想的类结构应该是每个类都有自己特定的用途。它们不能过大(如集成的功能太多,则很难实现它的复用),也不能过小(造成不能由自己使用,或者不能增添新功能)。最终实现的类应该能够方便地复用。

6.11 练习

(1) 用默认构造器(空参数列表)创建两个类:AB,令它们自己声明自己。从A继承一个名为C的新类,并在C内创建一个成员B。不要为C创建一个构造器。创建类C的一个对象,并观察结果。

(2) 修改练习1,使AB都有含有参数的构造器,则不是采用默认构造器。为C写一个构造器,并在C的构造器中执行所有初始化工作。

(3) 使用文件Cartoon.java,将Cartoon类的构造器代码变成注释内容标注出去。解释会发生什么事情。

(4) 使用文件Chess.java,将Chess类的构造器代码作为注释标注出去。同样解释会发生什么。

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,并将所有方法都设为publicprotected成员也允许派生出来的类访问它;以后还会深入探讨这一问题)。当然,在一些特殊的场合,我们仍然必须作出一些调整,但这并不是一个好的做法。

注意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(对象),因为它是从根类明确继承的。每个类都重新定义了Shapecleanup()方法,同时还要用super调用那个方法的基类版本。尽管对象存在期间调用的所有方法都可负责做一些要求清除的工作,但对于特定的Shape类——Circle(圆)、Triangle(三角形)以及Line(直线),它们都拥有自己的构造器,能完成“作图”(draw)任务。每个类都有它们自己的cleanup()方法,用于将非内存的东西恢复回对象存在之前的景象。

main()中,可看到两个新关键字:tryfinally。我们要到第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");
  }
} ///:~

由于i1I2都是具有final属性的基本数据类型,并含有编译期的值,所以它们除了能作为编译期的常数使用外,在任何导入方式中也不会出现任何不同。I3是我们体验此类常数定义时更典型的一种方式:public表示它们可在包外使用;Static强调它们只有一个;而final表明它是一个常数。注意对于含有固定初始化值(即编译期常数)的fianl static基本数据类型,它们的名字根据规则要全部采用大写。也要注意i5在编译期间是未知的,所以它没有大写。

不能由于某样东西的属性是final,就认定它的值能在编译时期知道。i4i5向大家证明了这一点。它们在运行期间使用随机生成的数字。例子的这一部分也向大家揭示出将final值设为static和非static之间的差异。只有当值在运行期间初始化的前提下,这种差异才会揭示出来。因为编译期间的值被编译器认为是相同的。这种差异可从输出结果中看出:

fd1: i4 = 15, i5 = 9
Creating new FinalData
fd1: i4 = 15, i5 = 9
fd2: i4 = 10, i5 = 9

注意对于fd1fd2来说,i4的值是唯一的,但i5的值不会由于创建了另一个FinalData对象而发生改变。那是因为它的属性是static,而且在载入时初始化,而非每创建一个对象时初始化。

v1v4的变量向我们揭示出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极短的方法名与Vector的方法名)。对类库的用户来说,这显然是不应该如此轻易就能看出的。一个产品的设计变得不一致后,会加大用户的工作量。这也从另一个侧面强调了代码设计与检查时需要很强的责任心。

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章 类复用

“Java引人注目的一项特性是代码的重复使用或者复用。但最具革命意义的是,除代码的复制和修改以外,我们还能做多得多的其他事情。”

在象C那样的程序化语言里,代码的重复使用早已可行,但效果不是特别显著。与Java的其他地方一样,这个方案解决的也是与类有关的问题。我们通过创建新类来重复使用代码,但却用不着重新创建,可以直接使用别人已建好并调试好的现成类。

但这样做必须保证不会干扰原有的代码。在这一章里,我们将介绍两个达到这一目标的方法。第一个最简单:在新类里简单地创建原有类的对象。我们把这种方法叫作“组合”,因为新类由现有类的对象合并而成。我们只是简单地重复利用代码的功能,而不是采用它的形式。

第二种方法则显得稍微有些技巧。它创建一个新类,将其作为现有类的一个“类型”。我们可以原样采取现有类的形式,并在其中加入新代码,同时不会对现有的类产生影响。这种魔术般的行为叫作“继承”(Inheritance),涉及的大多数工作都是由编译器完成的。对于面向对象的程序设计,“继承”是最重要的基础概念之一。它对我们下一章要讲述的内容会产生一些额外的影响。

对于组合与继承这两种方法,大多数语法和行为都是类似的(因为它们都要根据现有的类型生成新类型)。在本章,我们将深入学习这些代码复用或者重复使用的机制。

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里继承得到的。从WindInstrument的向上转换可能“缩小”那个接口,但不可能把它变得比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.10 练习

(1) 创建Rodent(啮齿动物):Mouse(老鼠),Gerbil(鼹鼠),Hamster(大颊鼠)等的一个继承分级结构。在基类中,提供适用于所有Rodent的方法,并在派生类中覆盖它们,从而根据不同类型的Rodent采取不同的行动。创建一个Rodent数组,在其中填充不同类型的Rodent,然后调用自己的基类方法,看看会有什么情况发生。

(2) 修改练习1,使Rodent成为一个接口。

(3) 改正WindError.java中的问题。

(4) 在GreenhouseControls.java中,添加Event内部类,使其能打开和关闭风扇。

7.2 深入理解

对于Music.java的困难性,可通过运行程序加以体会。输出是Wind.play()。这当然是我们希望的输出,但它看起来似乎并不愿按我们的希望行事。请观察一下tune()方法:

public static void tune(Instrument i) {
// ...
i.play(Note.middleC);
}

它接收Instrument引用。所以在这种情况下,编译器怎样才能知道Instrument引用指向的是一个Wind,而不是一个BrassStringed呢?编译器无从得知。为了深入了理解这个问题,我们有必要探讨一下“绑定”这个主题。

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();

同样地,大家也许认为会调用Shapedraw(),因为这毕竟是一个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语句里发生的。这个语句取得指向一个CircleSquare或者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(接口)关键字使抽象的概念更深入了一层。我们可将其想象为一个“纯”抽象类。它允许创建者规定一个类的基本形式:方法名、参数列表以及返回类型,但不规定方法主体。接口也包含了基本数据类型的数据成员,但它们都默认为staticfinal。接口只提供一种形式,并不提供实现的细节。

接口这样描述自己:“对于实现我的所有类,看起来都应该象我现在这个样子”。因此,采用了一个特定接口的所有代码都知道对于那个接口可能会调用什么方法。这便是接口的全部含义。所以我们常把接口用于建立类和类之间的一个“协议”。有些面向对象的程序设计语言采用了一个名为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同接口CanFightCanSwim以及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 常数分组

由于置入一个接口的所有字段都自动具有staticfinal属性,所以接口是对常数值进行分组的一个好工具,它具有与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对象,它们是在类本身内部创建的,包括:JANFEBMAR等等。这些对象也在month数组中使用,后者让我们能够按数字挑选月份,而不是按名字(注意数组中提供了一个多余的JAN,使偏移量增加了1,也使December确实成为12月)。在main()中,我们可注意到类型的安全性:m是一个Month2对象,所以只能将其分配给Month2。在前面的Months.java例子中,只提供了int值,所以本来想用来代表一个月份的int变量可能实际获得一个整数值,那样做可能不十分安全。

这儿介绍的方法也允许我们交换使用==或者equals(),就象main()尾部展示的那样。

7.5.4 初始化接口中的字段

接口中定义的字段会自动具有staticfinal属性。它们不能是“空白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();
  }
} ///:~

现在,ContentsDestination代表可由客户程序员使用的接口(记住接口会将自己的所有成员都变成public属性)。为方便起见,它们置于单独一个文件里,但原始的ContentsDestination在它们自己的文件中是相互public的。

Parcel3中,一些新东西已经加入:内部类PContents被设为private,所以除了Parcel3之外,其他任何东西都不能访问它。PDestination被设为protected,所以除了Parcel3Parcel3包内的类(因为protected也为包赋予了访问权;也就是说,protected也是“友好的”),以及Parcel3的继承者之外,其他任何东西都不能访问PDestination。这意味着客户程序员对这些成员的认识与访问将会受到限制。事实上,我们甚至不能向下转换到一个private内部类(或者一个protected内部类,除非自己本身便是一个继承者),因为我们不能访问名字,就象在classTest里看到的那样。所以,利用private内部类,类设计人员可完全禁止其他人依赖类型编码,并可将具体的实现细节完全隐藏起来。除此以外,从客户程序员的角度来看,一个接口的范围没有意义的,因为他们不能访问不属于公共接口类的任何额外方法。这样一来,Java编译器也有机会生成效率更高的代码。

普通(非内部)类不可设为privateprotected——只允许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(),它们每个方法都引用了oo是个不属于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成员,以便调用将引用返回ContentsDestination的方法。

通常,我们不在一个接口里设置任何代码,但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对象向上转换至Egg2y引用。所以当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) 调用派生构造器的主体。

构造器调用的顺序是非常重要的。进行继承时,我们知道关于基类的一切,并且能访问基类的任何publicprotected成员。这意味着当我们在派生类的时候,必须能假定基类的所有成员都是有效的。采用一种标准方法,构建行动已经进行,所以对象所有部分的成员均已得到构建。但在构造器内部,必须保证使用的所有成员都已构建。为达到这个要求,唯一的办法就是首先调用基类构造器。然后在进入派生类构造器以后,我们在基类能够访问的所有成员都已得到初始化。此外,所有成员对象(亦即通过组合方法置于类内的对象)在类内进行定义的时候(比如上例中的bcl),由于我们应尽可能地对它们进行初始化,所以也应保证构造器内部的所有成员均为有效。若坚持按这一规则行事,会有助于我们确定所有基类成员以及当前对象的成员对象均已获得正确的初始化。但不幸的是,这种做法并不适用于所有情况,这将在下一节具体说明。

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

从中可以看出确实没有为基类·调用收尾模块。但假如在命令行加入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章 多态性

“对于面向对象的程序设计语言,多型性是第三种最基本的特征(前两种是数据抽象和继承。”

“多态性”(Polymorphism)从另一个角度将接口从具体的实现细节中分离出来,亦即实现了“是什么”与“怎样做”两个模块的分离。利用多态性的概念,代码的组织以及可读性均能获得改善。此外,还能创建“易于扩展”的程序。无论在项目的创建过程中,还是在需要加入新特性的时候,它们都可以方便地“成长”。

通过合并各种特征与行为,封装技术可创建出新的数据类型。通过对具体实现细节的隐藏,可将接口与实现细节分离,使所有细节成为private(私有)。这种组织方式使那些有程序化编程背景人感觉颇为舒适。但多态性却涉及对“类型”的分解。通过上一章的学习,大家已知道通过继承可将一个对象当作它自己的类型或者它自己的基类型对待。这种能力是十分重要的,因为多个类型(从相同的基类型中派生出来)可被当作同一种类型对待。而且只需一段代码,即可对所有不同的类型进行同样的处理。利用具有多态性的方法调用,一种类型可将自己与另一种相似的类型区分开,只要它们都是从相同的基类型中派生出来的。这种区分是通过各种方法在行为上的差异实现的,可通过基类实现对那些方法的调用。

在这一章中,大家要由浅入深地学习有关多态性的问题(也叫作动态绑定、推迟绑定或者运行期绑定)。同时举一些简单的例子,其中所有无关的部分都已剥除,只保留与多态性有关的代码。

8.1 数组

对数组的大多数必要的介绍已在第4章的最后一节进行。通过那里的学习,大家已知道自己该如何定义及初始化一个数组。对象的容纳是本章的重点,而数组只是容纳对象的一种方式。但由于还有其他大量方法可容纳数组,所以是哪些地方使数组显得如此特别呢?

有两方面的问题将数组与其他集合类型区分开来:效率和类型。对于Java来说,为保存和访问一系列对象(实际是对象的引用)数组,最有效的方法莫过于数组。数组实际代表一个简单的线性序列,它使得元素的访问速度非常快,但我们却要为这种速度付出代价:创建一个数组对象时,它的大小是固定的,而且不可在那个数组对象的“存在时间”内发生改变。可创建特定大小的一个数组,然后假如用光了存储空间,就再创建一个新数组,将所有引用从旧数组移到新数组。这属于“向量”(Vector)类的行为,本章稍后还会详细讨论它。然而,由于为这种大小的灵活性要付出较大的代价,所以我们认为向量的效率并没有数组高。

C++的向量类知道自己容纳的是什么类型的对象,但同Java的数组相比,它却有一个明显的缺点:C++向量类的operator[]不能进行范围检查,所以很容易超出边界(然而,它可以查询vector有多大,而且at()方法确实能进行范围检查)。在Java中,无论使用的是数组还是集合,都会进行范围检查——若超过边界,就会获得一个RuntimeException(运行期异常)错误。正如大家在第9章会学到的那样,这类异常指出的是一个程序员错误,所以不需要在代码中检查它。在另一方面,由于C++的vector不进行范围检查,所以访问速度较快——在Java中,由于对数组和集合都要进行范围检查,所以对性能有一定的影响。

本章还要学习另外几种常见的集合类:Vector(向量)、Stack(栈)以及Hashtable(散列表)。这些类都涉及对对象的处理——好象它们没有特定的类型。换言之,它们将其当作Object类型处理(Object类型是Java中所有类的“根”类)。从某个角度看,这种处理方法是非常合理的:我们仅需构建一个集合,然后任何Java对象都可以进入那个集合(除基本数据类型外——可用Java的基本类型封装类将其作为常数置入集合,或者将其封装到自己的类内,作为可以变化的值使用)。这再一次反映了数组优于常规集合:创建一个数组时,可令其容纳一种特定的类型。这意味着可进行编译期类型检查,预防自己设置了错误的类型,或者错误指定了准备提取的类型。当然,在编译期或者运行期,Java会防止我们将不当的消息发给一个对象。所以我们不必考虑自己的哪种做法更加危险,只要编译器能及时地指出错误,同时在运行期间加快速度,目的也就达到了。此外,用户很少会对一次异常事件感到非常惊讶的。

考虑到执行效率和类型检查,应尽可能地采用数组。然而,当我们试图解决一个更常规的问题时,数组的局限也可能显得非常明显。在研究过数组以后,本章剩余的部分将把重点放到Java提供的集合类身上。

8.1.1 数组和第一类对象

无论使用的数组属于什么类型,数组标识符实际都是指向真实对象的一个引用。那些对象本身是在内存“堆”里创建的。堆对象既可“隐式”创建(即默认产生),亦可“显式”创建(即明确指定,用一个new表达式)。堆对象的一部分(实际是我们能访问的唯一字段或方法)是只读的length(长度)成员,它告诉我们那个数组对象里最多能容纳多少元素。对于数组对象,[]语法是我们能采用的唯一另类访问方法。

下面这个例子展示了对数组进行初始化的不同方式,以及如何将数组引用分配给不同的数组对象。它也揭示出对象数组和基本数据类型数组在使用方法上几乎是完全一致的。唯一的差别在于对象数组容纳的是引用,而基本数据类型数组容纳的是具体的数值(若在执行此程序时遇到困难,请参考第3章的“赋值”小节):

//: ArraySize.java
// Initialization & re-assignment of arrays
package c08;

class Weeble {} // A small mythical creature

public class ArraySize {
  public static void main(String[] args) {
    // Arrays of objects:
    Weeble[] a; // Null handle
    Weeble[] b = new Weeble[5]; // Null handles
    Weeble[] c = new Weeble[4];
    for(int i = 0; i < c.length; i++)
      c[i] = new Weeble();
    Weeble[] d = {
      new Weeble(), new Weeble(), new Weeble()
    };
    // Compile error: variable a not initialized:
    //!System.out.println("a.length=" + a.length);
    System.out.println("b.length = " + b.length);
    // The handles inside the array are
    // automatically initialized to null:
    for(int i = 0; i < b.length; i++)
      System.out.println("b[" + i + "]=" + b[i]);
    System.out.println("c.length = " + c.length);
    System.out.println("d.length = " + d.length);
    a = d;
    System.out.println("a.length = " + a.length);
    // Java 1.1 initialization syntax:
    a = new Weeble[] {
      new Weeble(), new Weeble()
    };
    System.out.println("a.length = " + a.length);

    // Arrays of primitives:
    int[] e; // Null handle
    int[] f = new int[5];
    int[] g = new int[4];
    for(int i = 0; i < g.length; i++)
      g[i] = i*i;
    int[] h = { 11, 47, 93 };
    // Compile error: variable e not initialized:
    //!System.out.println("e.length=" + e.length);
    System.out.println("f.length = " + f.length);
    // The primitives inside the array are
    // automatically initialized to zero:
    for(int i = 0; i < f.length; i++)
      System.out.println("f[" + i + "]=" + f[i]);
    System.out.println("g.length = " + g.length);
    System.out.println("h.length = " + h.length);
    e = h;
    System.out.println("e.length = " + e.length);
    // Java 1.1 initialization syntax:
    e = new int[] { 1, 2 };
    System.out.println("e.length = " + e.length);
  }
} ///:~
Here’s the output from the program:

b.length = 5
b[0]=null
b[1]=null
b[2]=null
b[3]=null
b[4]=null
c.length = 4
d.length = 3
a.length = 3
a.length = 2
f.length = 5
f[0]=0
f[1]=0
f[2]=0
f[3]=0
f[4]=0
g.length = 4
h.length = 3
e.length = 3
e.length = 2

其中,数组a只是初始化成一个null引用。此时,编译器会禁止我们对这个引用作任何实际操作,除非已正确地初始化了它。数组b被初始化成指向由Weeble引用构成的一个数组,但那个数组里实际并未放置任何Weeble对象。然而,我们仍然可以查询那个数组的大小,因为b指向的是一个合法对象。这也为我们带来了一个难题:不可知道那个数组里实际包含了多少个元素,因为length只告诉我们可将多少元素置入那个数组。换言之,我们只知道数组对象的大小或容量,不知其实际容纳了多少个元素。尽管如此,由于数组对象在创建之初会自动初始化成null,所以可检查它是否为null,判断一个特定的数组“空位”是否容纳一个对象。类似地,由基本数据类型构成的数组会自动初始化成零(针对数值类型)、null(字符类型)或者false(布尔类型)。

数组c显示出我们首先创建一个数组对象,再将Weeble对象赋给那个数组的所有“空位”。数组d揭示出“集合初始化”语法,从而创建数组对象(用new命令明确进行,类似于数组c),然后用Weeble对象进行初始化,全部工作在一条语句里完成。
下面这个表达式:

a = d;

向我们展示了如何取得同一个数组对象连接的引用,然后将其赋给另一个数组对象,就象我们针对对象引用的其他任何类型做的那样。现在,ad都指向内存堆内同样的数组对象。

Java 1.1加入了一种新的数组初始化语法,可将其想象成“动态集合初始化”。由d采用的Java 1.0集合初始化方法则必须在定义d的同时进行。但若采用Java 1.1的语法,却可以在任何地方创建和初始化一个数组对象。例如,假设hide()方法用于取得一个Weeble对象数组,那么调用它时传统的方法是:

hide(d);

但在Java 1.1中,亦可动态创建想作为参数传递的数组,如下所示:

hide(new Weeble[] {new Weeble(), new Weeble() });

这一新式语法使我们在某些场合下写代码更方便了。

上述例子的第二部分揭示出这样一个问题:对于由基本数据类型构成的数组,它们的运作方式与对象数组极为相似,只是前者直接包容了基本类型的数据值。

(1) 基本数据类型集合

集合类只能容纳对象引用。但对一个数组,却既可令其直接容纳基本类型的数据,亦可容纳指向对象的引用。利用象IntegerDouble之类的“包装器”类,可将基本数据类型的值置入一个集合里。但正如本章后面会在WordCount.java例子中讲到的那样,用于基本数据类型的包装器类只是在某些场合下才能发挥作用。无论将基本类型的数据置入数组,还是将其封装进入位于集合的一个类内,都涉及到执行效率的问题。显然,若能创建和访问一个基本数据类型数组,那么比起访问一个封装数据的集合,前者的效率会高出许多。

当然,假如准备一种基本数据类型,同时又想要集合的灵活性(在需要的时候可自动扩展,腾出更多的空间),就不宜使用数组,必须使用由封装的数据构成的一个集合。大家或许认为针对每种基本数据类型,都应有一种特殊类型的Vector。但Java并未提供这一特性。某些形式的建模机制或许会在某一天帮助Java更好地解决这个问题(注释①)。

①:这儿是C++比Java做得好的一个地方,因为C++通过template关键字提供了对“参数化类型”的支持。

8.1.2 数组的返回

假定我们现在想写一个方法,同时不希望它仅仅返回一样东西,而是想返回一系列东西。此时,象C和C++这样的语言会使问题复杂化,因为我们不能返回一个数组,只能返回指向数组的一个指针。这样就非常麻烦,因为很难控制数组的“存在时间”,它很容易造成内存“漏洞”的出现。

Java采用的是类似的方法,但我们能“返回一个数组”。当然,此时返回的实际仍是指向数组的指针。但在Java里,我们永远不必担心那个数组的是否可用——只要需要,它就会自动存在。而且垃圾收集器会在我们完成后自动将其清除。
作为一个例子,请思考如何返回一个字符串数组:

//: IceCream.java
// Returning arrays from methods

public class IceCream {
  static String[] flav = {
    "Chocolate", "Strawberry",
    "Vanilla Fudge Swirl", "Mint Chip",
    "Mocha Almond Fudge", "Rum Raisin",
    "Praline Cream", "Mud Pie"
  };
  static String[] flavorSet(int n) {
    // Force it to be positive & within bounds:
    n = Math.abs(n) % (flav.length + 1);
    String[] results = new String[n];
    int[] picks = new int[n];
    for(int i = 0; i < picks.length; i++)
      picks[i] = -1;
    for(int i = 0; i < picks.length; i++) {
      retry:
      while(true) {
        int t =
          (int)(Math.random() * flav.length);
        for(int j = 0; j < i; j++)
          if(picks[j] == t) continue retry;
        picks[i] = t;
        results[i] = flav[t];
        break;
      }
    }
    return results;
  }
  public static void main(String[] args) {
    for(int i = 0; i < 20; i++) {
      System.out.println(
        "flavorSet(" + i + ") = ");
      String[] fl = flavorSet(flav.length);
      for(int j = 0; j < fl.length; j++)
        System.out.println("\t" + fl[j]);
    }
  }
} ///:~

flavorSet()方法创建了一个名为resultsString数组。该数组的大小为n——具体数值取决于我们传递给方法的参数。随后,它从数组flav里随机挑选一些“香料”(Flavor),并将它们置入results里,并最终返回results。返回数组与返回其他任何对象没什么区别——最终返回的都是一个引用。至于数组到底是在flavorSet()里创建的,还是在其他什么地方创建的,这个问题并不重要,因为反正返回的仅是一个引用。一旦我们的操作完成,垃圾收集器会自动关照数组的清除工作。而且只要我们需要数组,它就会乖乖地听候调遣。

另一方面,注意当flavorSet()随机挑选香料的时候,它需要保证以前出现过的一次随机选择不会再次出现。为达到这个目的,它使用了一个无限while循环,不断地作出随机选择,直到发现未在picks数组里出现过的一个元素为止(当然,也可以进行字符串比较,检查随机选择是否在results数组里出现过,但字符串比较的效率比较低)。若成功,就添加这个元素,并中断循环(break),再查找下一个(i值会递增)。但假若t是一个已在picks里出现过的数组,就用标签式的continue往回跳两级,强制选择一个新t。用一个调试程序可以很清楚地看到这个过程。

main()能显示出20个完整的香料集合,所以我们看到flavorSet()每次都用一个随机顺序选择香料。为体会这一点,最简单的方法就是将输出重导向进入一个文件,然后直接观看这个文件的内容。

8.2 集合

现在总结一下我们前面学过的东西:为容纳一组对象,最适宜的选择应当是数组。而且假如容纳的是一系列基本数据类型,更是必须采用数组。在本章剩下的部分,大家将接触到一些更常规的情况。当我们编写程序时,通常并不能确切地知道最终需要多少个对象。有些时候甚至想用更复杂的方式来保存对象。为解决这个问题,Java提供了四种类型的“集合类”:Vector(向量)、BitSet(位集)、Stack(栈)以及Hashtable(散列表)。与拥有集合功能的其他语言相比,尽管这儿的数量显得相当少,但仍然能用它们解决数量惊人的实际问题。

这些集合类具有形形色色的特征。例如,Stack实现了一个LIFO(后入先出)序列,而Hashtable是一种“关联数组”,允许我们将任何对象关联起来。除此以外,所有Java集合类都能自动改变自身的大小。所以,我们在编程时可使用数量众多的对象,同时不必担心会将集合弄得有多大。

8.2.1 缺点:类型未知

使用Java集合的“缺点”是在将对象置入一个集合时丢失了类型信息。之所以会发生这种情况,是由于当初编写集合时,那个集合的程序员根本不知道用户到底想把什么类型置入集合。若指示某个集合只允许特定的类型,会妨碍它成为一个“常规用途”的工具,为用户带来麻烦。为解决这个问题,集合实际容纳的是类型为Object的一些对象的引用。这种类型当然代表Java中的所有对象,因为它是所有类的根。当然,也要注意这并不包括基本数据类型,因为它们并不是从“任何东西”继承来的。这是一个很好的方案,只是不适用下述场合:

(1) 将一个对象引用置入集合时,由于类型信息会被抛弃,所以任何类型的对象都可进入我们的集合——即便特别指示它只能容纳特定类型的对象。举个例子来说,虽然指示它只能容纳猫,但事实上任何人都可以把一条狗扔进来。

(2) 由于类型信息不复存在,所以集合能肯定的唯一事情就是自己容纳的是指向一个对象的引用。正式使用它之前,必须对其进行转换,使其具有正确的类型。

值得欣慰的是,Java不允许人们滥用置入集合的对象。假如将一条狗扔进一个猫的集合,那么仍会将集合内的所有东西都看作猫,所以在使用那条狗时会得到一个“异常”错误。在同样的意义上,假若试图将一条狗的引用“转换”到一只猫,那么运行期间仍会得到一个“异常”错误。

下面是个例子:

//: CatsAndDogs.java
// Simple collection example (Vector)
import java.util.*;

class Cat {
  private int catNumber;
  Cat(int i) {
    catNumber = i;
  }
  void print() {
    System.out.println("Cat #" + catNumber);
  }
}

class Dog {
  private int dogNumber;
  Dog(int i) {
    dogNumber = i;
  }
  void print() {
    System.out.println("Dog #" + dogNumber);
  }
}

public class CatsAndDogs {
  public static void main(String[] args) {
    Vector cats = new Vector();
    for(int i = 0; i < 7; i++)
      cats.addElement(new Cat(i));
    // Not a problem to add a dog to cats:
    cats.addElement(new Dog(7));
    for(int i = 0; i < cats.size(); i++)
      ((Cat)cats.elementAt(i)).print();
    // Dog is detected only at run-time
  }
} ///:~

可以看出,Vector的使用是非常简单的:先创建一个,再用addElement()置入对象,以后用elementAt()取得那些对象(注意Vector有一个size()方法,可使我们知道已添加了多少个元素,以便防止误超边界,造成异常错误)。

CatDog类都非常浅显——除了都是“对象”之外,它们并无特别之处(倘若不明确指出从什么类继承,就默认为从Object继承。所以我们不仅能用Vector方法将Cat对象置入这个集合,也能添加Dog对象,同时不会在编译期和运行期得到任何出错提示。用Vector方法elementAt()获取原本认为是Cat的对象时,实际获得的是指向一个Object的引用,必须将那个对象转换为Cat。随后,需要将整个表达式用括号封闭起来,在为Cat调用print()方法之前进行强制转换;否则就会出现一个语法错误。在运行期间,如果试图将Dog对象转换为Cat,就会得到一个异常。

这些处理的意义都非常深远。尽管显得有些麻烦,但却获得了安全上的保证。我们从此再难偶然造成一些隐藏得深的错误。若程序的一个部分(或几个部分)将对象插入一个集合,但我们只是通过一次异常在程序的某个部分发现一个错误的对象置入了集合,就必须找出插入错误的位置。当然,可通过检查代码达到这个目的,但这或许是最笨的调试工具。另一方面,我们可从一些标准化的集合类开始自己的编程。尽管它们在功能上存在一些不足,且显得有些笨拙,但却能保证没有隐藏的错误。

(1) 错误有时并不显露出来

在某些情况下,程序似乎正确地工作,不转换回我们原来的类型。第一种情况是相当特殊的:String类从编译器获得了额外的帮助,使其能够正常工作。只要编译器期待的是一个String对象,但它没有得到一个,就会自动调用在Object里定义、并且能够由任何Java类覆盖的toString()方法。这个方法能生成满足要求的String对象,然后在我们需要的时候使用。

因此,为了让自己类的对象能显示出来,要做的全部事情就是覆盖toString()方法,如下例所示:

//: WorksAnyway.java
// In special cases, things just seem
// to work correctly.
import java.util.*;

class Mouse {
  private int mouseNumber;
  Mouse(int i) {
    mouseNumber = i;
  }
  // Magic method:
  public String toString() {
    return "This is Mouse #" + mouseNumber;
  }
  void print(String msg) {
    if(msg != null) System.out.println(msg);
    System.out.println(
      "Mouse number " + mouseNumber);
  }
}

class MouseTrap {
  static void caughtYa(Object m) {
    Mouse mouse = (Mouse)m; // Cast from Object
    mouse.print("Caught one!");
  }
}

public class WorksAnyway {
  public static void main(String[] args) {
    Vector mice = new Vector();
    for(int i = 0; i < 3; i++)
      mice.addElement(new Mouse(i));
    for(int i = 0; i < mice.size(); i++) {
      // No cast necessary, automatic call
      // to Object.toString():
      System.out.println(
        "Free mouse: " + mice.elementAt(i));
      MouseTrap.caughtYa(mice.elementAt(i));
    }
  }
} ///:~

可在Mouse里看到对toString()的重定义代码。在main()的第二个for循环中,可发现下述语句:

System.out.println("Free mouse: " +
mice.elementAt(i));

+后,编译器预期看到的是一个String对象。elementAt()生成了一个Object,所以为获得希望的String,编译器会默认调用toString()。但不幸的是,只有针对String才能得到象这样的结果;其他任何类型都不会进行这样的转换。

隐藏转换的第二种方法已在Mousetrap里得到了应用。caughtYa()方法接收的不是一个Mouse,而是一个Object。随后再将其转换为一个Mouse。当然,这样做是非常冒失的,因为通过接收一个Object,任何东西都可以传递给方法。然而,假若转换不正确——如果我们传递了错误的类型——就会在运行期间得到一个异常错误。这当然没有在编译期进行检查好,但仍然能防止问题的发生。注意在使用这个方法时毋需进行转换:

MouseTrap.caughtYa(mice.elementAt(i));

(2) 生成能自动判别类型的Vector

大家或许不想放弃刚才那个问题。一个更“健壮”的方案是用Vector创建一个新类,使其只接收我们指定的类型,也只生成我们希望的类型。如下所示:

//: GopherVector.java
// A type-conscious Vector
import java.util.*;

class Gopher {
  private int gopherNumber;
  Gopher(int i) {
    gopherNumber = i;
  }
  void print(String msg) {
    if(msg != null) System.out.println(msg);
    System.out.println(
      "Gopher number " + gopherNumber);
  }
}

class GopherTrap {
  static void caughtYa(Gopher g) {
    g.print("Caught one!");
  }
}

class GopherVector {
  private Vector v = new Vector();
  public void addElement(Gopher m) {
    v.addElement(m);
  }
  public Gopher elementAt(int index) {
    return (Gopher)v.elementAt(index);
  }
  public int size() { return v.size(); }
  public static void main(String[] args) {
    GopherVector gophers = new GopherVector();
    for(int i = 0; i < 3; i++)
      gophers.addElement(new Gopher(i));
    for(int i = 0; i < gophers.size(); i++)
      GopherTrap.caughtYa(gophers.elementAt(i));
  }
} ///:~

这前一个例子类似,只是新的GopherVector类有一个类型为Vectorprivate成员(从Vector继承有些麻烦,理由稍后便知),而且方法也和Vector类似。然而,它不会接收和产生普通Object,只对Gopher对象感兴趣。

由于GopherVector只接收一个Gopher(地鼠),所以假如我们使用:

gophers.addElement(new Pigeon());

就会在编译期间获得一条出错消息。采用这种方式,尽管从编码的角度看显得更令人沉闷,但可以立即判断出是否使用了正确的类型。

注意在使用elementAt()时不必进行转换——它肯定是一个Gopher

(3) 参数化类型

这类问题并不是孤立的——我们许多时候都要在其他类型的基础上创建新类型。此时,在编译期间拥有特定的类型信息是非常有帮助的。这便是“参数化类型”的概念。在C++中,它由语言通过“模板”获得了直接支持。至少,Java保留了关键字generic,期望有一天能够支持参数化类型。但我们现在无法确定这一天何时会来临。

8.3 枚举器(迭代器)

在任何集合类中,必须通过某种方法在其中置入对象,再用另一种方法从中取得对象。毕竟,容纳各种各样的对象正是集合的首要任务。在Vector中,addElement()便是我们插入对象采用的方法,而elementAt()是提取对象的唯一方法。Vector非常灵活,我们可在任何时候选择任何东西,并可使用不同的索引选择多个元素。

若从更高的角度看这个问题,就会发现它的一个缺陷:需要事先知道集合的准确类型,否则无法使用。乍看来,这一点似乎没什么关系。但假若最开始决定使用Vector,后来在程序中又决定(考虑执行效率的原因)改变成一个List(属于Java1.2集合库的一部分),这时又该如何做呢?

可利用“迭代器”(Iterator)的概念达到这个目的。它可以是一个对象,作用是遍历一系列对象,并选择那个序列中的每个对象,同时不让客户程序员知道或关注那个序列的基础结构。此外,我们通常认为迭代器是一种“轻量级”对象;也就是说,创建它只需付出极少的代价。但也正是由于这个原因,我们常发现迭代器存在一些似乎很奇怪的限制。例如,有些迭代器只能朝一个方向移动。
Java的Enumeration(枚举,注释②)便是具有这些限制的一个迭代器的例子。除下面这些外,不可再用它做其他任何事情:

(1) 用一个名为elements()的方法要求集合为我们提供一个Enumeration。我们首次调用它的nextElement()时,这个Enumeration会返回序列中的第一个元素。

(2) 用nextElement()获得下一个对象。

(3) 用hasMoreElements()检查序列中是否还有更多的对象。

②:“迭代器”这个词在C++和OOP的其他地方是经常出现的,所以很难确定为什么Java的开发者采用了这样一个奇怪的名字。Java 1.2的集合库修正了这个问题以及其他许多问题。

只可用Enumeration做这些事情,不能再有更多。它属于迭代器一种简单的实现方式,但功能依然十分强大。为体会它的运作过程,让我们复习一下本章早些时候提到的CatsAndDogs.java程序。在原始版本中,elementAt()方法用于选择每一个元素,但在下述修订版中,可看到使用了一个“枚举”:

//: CatsAndDogs2.java
// Simple collection with Enumeration
import java.util.*;

class Cat2 {
  private int catNumber;
  Cat2(int i) {
    catNumber = i;
  }
  void print() {
    System.out.println("Cat number " +catNumber);
  }
}

class Dog2 {
  private int dogNumber;
  Dog2(int i) {
    dogNumber = i;
  }
  void print() {
    System.out.println("Dog number " +dogNumber);
  }
}

public class CatsAndDogs2 {
  public static void main(String[] args) {
    Vector cats = new Vector();
    for(int i = 0; i < 7; i++)
      cats.addElement(new Cat2(i));
    // Not a problem to add a dog to cats:
    cats.addElement(new Dog2(7));
    Enumeration e = cats.elements();
    while(e.hasMoreElements())
      ((Cat2)e.nextElement()).print();
    // Dog is detected only at run-time
  }
} ///:~

我们看到唯一的改变就是最后几行。不再是:

for(int i = 0; i < cats.size(); i++)
((Cat)cats.elementAt(i)).print();

而是用一个Enumeration遍历整个序列:

while(e.hasMoreElements())
((Cat2)e.nextElement()).print();

使用Enumeration,我们不必关心集合中的元素数量。所有工作均由hasMoreElements()nextElement()自动照管了。

下面再看看另一个例子,让我们创建一个常规用途的打印方法:

//: HamsterMaze.java
// Using an Enumeration
import java.util.*;

class Hamster {
  private int hamsterNumber;
  Hamster(int i) {
    hamsterNumber = i;
  }
  public String toString() {
    return "This is Hamster #" + hamsterNumber;
  }
}

class Printer {
  static void printAll(Enumeration e) {
    while(e.hasMoreElements())
      System.out.println(
        e.nextElement().toString());
  }
}

public class HamsterMaze {
  public static void main(String[] args) {
    Vector v = new Vector();
    for(int i = 0; i < 3; i++)
      v.addElement(new Hamster(i));
    Printer.printAll(v.elements());
  }
} ///:~

仔细研究一下打印方法:

static void printAll(Enumeration e) {
  while(e.hasMoreElements())
    System.out.println(
      e.nextElement().toString());
}

注意其中没有与序列类型有关的信息。我们拥有的全部东西便是Enumeration。为了解有关序列的情况,一个Enumeration便足够了:可取得下一个对象,亦可知道是否已抵达了末尾。取得一系列对象,然后在其中遍历,从而执行一个特定的操作——这是一个颇有价值的编程概念,本书许多地方都会沿用这一思路。

这个看似特殊的例子甚至可以更为通用,因为它使用了常规的toString()方法(之所以称为常规,是由于它属于Object类的一部分)。下面是调用打印的另一个方法(尽管在效率上可能会差一些):

System.out.println("" + e.nextElement());

它采用了封装到Java内部的“自动转换成字符串”技术。一旦编译器碰到一个字符串,后面跟随一个+,就会希望后面又跟随一个字符串,并自动调用toString()。在Java 1.1中,第一个字符串是不必要的;所有对象都会转换成字符串。亦可对此执行一次转换,获得与调用toString()同样的效果:

System.out.println((String)e.nextElement())

但我们想做的事情通常并不仅仅是调用Object方法,所以会再度面临类型转换的问题。对于自己感兴趣的类型,必须假定自己已获得了一个Enumeration,然后将结果对象转换成为那种类型(若操作错误,会得到运行期异常)。

8.4 集合的类型

标准Java 1.0和1.1库配套提供了非常少的一系列集合类。但对于自己的大多数编程要求,它们基本上都能胜任。正如大家到本章末尾会看到的,Java 1.2提供的是一套重新设计过的大型集合库。

8.4.1 Vector

Vector的用法很简单,这已在前面的例子中得到了证明。尽管我们大多数时候只需用addElement()插入对象,用elementAt()一次提取一个对象,并用elements()获得对序列的一个“枚举”。但仍有其他一系列方法是非常有用的。同我们对于Java库惯常的做法一样,在这里并不使用或讲述所有这些方法。但请务必阅读相应的电子文档,对它们的工作有一个大概的认识。

(1) 崩溃Java

Java标准集合里包含了toString()方法,所以它们能生成自己的String表达方式,包括它们容纳的对象。例如在Vector中,toString()会在Vector的各个元素中步进和遍历,并为每个元素调用toString()。假定我们现在想打印出自己类的地址。看起来似乎简单地引用this即可(特别是C++程序员有这样做的倾向):

//: CrashJava.java
// One way to crash Java
import java.util.*;

public class CrashJava {
  public String toString() {
    return "CrashJava address: " + this + "\n";
  }
  public static void main(String[] args) {
    Vector v = new Vector();
    for(int i = 0; i < 10; i++)
      v.addElement(new CrashJava());
    System.out.println(v);
  }
} ///:~

若只是简单地创建一个CrashJava对象,并将其打印出来,就会得到无穷无尽的一系列异常错误。然而,假如将CrashJava对象置入一个Vector,并象这里演示的那样打印Vector,就不会出现什么错误提示,甚至连一个异常都不会出现。此时Java只是简单地崩溃(但至少它没有崩溃我的操作系统)。这已在Java 1.1中测试通过。

此时发生的是字符串的自动类型转换。当我们使用下述语句时:

"CrashJava address: " + this

编译器就在一个字符串后面发现了一个+以及好象并非字符串的其他东西,所以它会试图将this转换成一个字符串。转换时调用的是toString(),后者会产生一个递归调用。若在一个Vector内出现这种事情,看起来栈就会溢出,同时异常控制机制根本没有机会作出响应。

若确实想在这种情况下打印出对象的地址,解决方案就是调用ObjecttoString方法。此时就不必加入this,只需使用super.toString()。当然,采取这种做法也有一个前提:我们必须从Object直接继承,或者没有一个父类覆盖了toString方法。

8.4.2 BitSet

BitSet实际是由“二进制位”构成的一个Vector。如果希望高效率地保存大量“开-关”信息,就应使用BitSet。它只有从尺寸的角度看才有意义;如果希望的高效率的访问,那么它的速度会比使用一些固有类型的数组慢一些。

此外,BitSet的最小长度是一个长整数(Long)的长度:64位。这意味着假如我们准备保存比这更小的数据,如8位数据,那么BitSet就显得浪费了。所以最好创建自己的类,用它容纳自己的标志位。

在一个普通的Vector中,随我们加入越来越多的元素,集合也会自我膨胀。在某种程度上,BitSet也不例外。也就是说,它有时会自行扩展,有时则不然。而且Java的1.0版本似乎在这方面做得最糟,它的BitSet表现十分差强人意(Java1.1已改正了这个问题)。下面这个例子展示了BitSet是如何运作的,同时演示了1.0版本的错误:

//: Bits.java
// Demonstration of BitSet
import java.util.*;

public class Bits {
  public static void main(String[] args) {
    Random rand = new Random();
    // Take the LSB of nextInt():
    byte bt = (byte)rand.nextInt();
    BitSet bb = new BitSet();
    for(int i = 7; i >=0; i--)
      if(((1 << i) &  bt) != 0)
        bb.set(i);
      else
        bb.clear(i);
    System.out.println("byte value: " + bt);
    printBitSet(bb);

    short st = (short)rand.nextInt();
    BitSet bs = new BitSet();
    for(int i = 15; i >=0; i--)
      if(((1 << i) &  st) != 0)
        bs.set(i);
      else
        bs.clear(i);
    System.out.println("short value: " + st);
    printBitSet(bs);

    int it = rand.nextInt();
    BitSet bi = new BitSet();
    for(int i = 31; i >=0; i--)
      if(((1 << i) &  it) != 0)
        bi.set(i);
      else
        bi.clear(i);
    System.out.println("int value: " + it);
    printBitSet(bi);

    // Test bitsets >= 64 bits:
    BitSet b127 = new BitSet();
    b127.set(127);
    System.out.println("set bit 127: " + b127);
    BitSet b255 = new BitSet(65);
    b255.set(255);
    System.out.println("set bit 255: " + b255);
    BitSet b1023 = new BitSet(512);
// Without the following, an exception is thrown
// in the Java 1.0 implementation of BitSet:
//    b1023.set(1023);
    b1023.set(1024);
    System.out.println("set bit 1023: " + b1023);
  }
  static void printBitSet(BitSet b) {
    System.out.println("bits: " + b);
    String bbits = new String();
    for(int j = 0; j < b.size() ; j++)
      bbits += (b.get(j) ? "1" : "0");
    System.out.println("bit pattern: " + bbits);
  }
} ///:~

随机数字生成器用于创建一个随机的byteshortint。每一个都会转换成BitSet内相应的位模型。此时一切都很正常,因为BitSet是64位的,所以它们都不会造成最终尺寸的增大。但在Java 1.0中,一旦BitSet大于64位,就会出现一些令人迷惑不解的行为。假如我们设置一个只比BitSet当前分配存储空间大出1的一个位,它能够正常地扩展。但一旦试图在更高的位置设置位,同时不先接触边界,就会得到一个恼人的异常。这正是由于BitSet在Java 1.0里不能正确扩展造成的。本例创建了一个512位的BitSet。构造器分配的存储空间是位数的两倍。所以假如设置位1024或更高的位,同时没有先设置位1023,就会在Java 1.0里得到一个异常。但幸运的是,这个问题已在Java 1.1得到了改正。所以如果是为Java 1.0写代码,请尽量避免使用BitSet

8.4.3 Stack

Stack有时也可以称为“后入先出”(LIFO)集合。换言之,我们在栈里最后“压入”的东西将是以后第一个“弹出”的。和其他所有Java集合一样,我们压入和弹出的都是“对象”,所以必须对自己弹出的东西进行“转换”。

一种很少见的做法是拒绝使用Vector作为一个Stack的基本构成元素,而是从Vector里“继承”一个Stack。这样一来,它就拥有了一个Vector的所有特征及行为,另外加上一些额外的Stack行为。很难判断出设计者到底是明确想这样做,还是属于一种固有的设计。

下面是一个简单的栈示例,它能读入数组的每一行,同时将其作为字符串压入栈。

//: Stacks.java
// Demonstration of Stack Class
import java.util.*;

public class Stacks {
  static String[] months = {
    "January", "February", "March", "April",
    "May", "June", "July", "August", "September",
    "October", "November", "December" };
  public static void main(String[] args) {
    Stack stk = new Stack();
    for(int i = 0; i < months.length; i++)
      stk.push(months[i] + " ");
    System.out.println("stk = " + stk);
    // Treating a stack as a Vector:
    stk.addElement("The last line");
    System.out.println(
      "element 5 = " + stk.elementAt(5));
    System.out.println("popping elements:");
    while(!stk.empty())
      System.out.println(stk.pop());
  }
} ///:~

months数组的每一行都通过push()继承进入栈,稍后用pop()从栈的顶部将其取出。要声明的一点是,Vector操作亦可针对Stack对象进行。这可能是由继承的特质决定的——Stack“属于”一种Vector。因此,能对Vector进行的操作亦可针对Stack进行,例如elementAt()方法。

8.4.4 Hashtable

Vector允许我们用一个数字从一系列对象中作出选择,所以它实际是将数字同对象关联起来了。但假如我们想根据其他标准选择一系列对象呢?栈就是这样的一个例子:它的选择标准是“最后压入栈的东西”。这种“从一系列对象中选择”的概念亦可叫作一个“映射”、“字典”或者“关联数组”。从概念上讲,它看起来象一个Vector,但却不是通过数字来查找对象,而是用另一个对象来查找它们!这通常都属于一个程序中的重要进程。

在Java中,这个概念具体反映到抽象类Dictionary身上。该类的接口是非常直观的size()告诉我们其中包含了多少元素;isEmpty()判断是否包含了元素(是则为true);put(Object key, Object value)添加一个值(我们希望的东西),并将其同一个键关联起来(想用于搜索它的东西);get(Object key)获得与某个键对应的值;而remove(Object Key)用于从列表中删除“键-值”对。还可以使用枚举技术:keys()产生对键的一个枚举(Enumeration);而elements()产生对所有值的一个枚举。这便是一个Dictionary(字典)的全部。

Dictionary的实现过程并不麻烦。下面列出一种简单的方法,它使用了两个Vector,一个用于容纳键,另一个用来容纳值:

//: AssocArray.java
// Simple version of a Dictionary
import java.util.*;

public class AssocArray extends Dictionary {
  private Vector keys = new Vector();
  private Vector values = new Vector();
  public int size() { return keys.size(); }
  public boolean isEmpty() {
    return keys.isEmpty();
  }
  public Object put(Object key, Object value) {
    keys.addElement(key);
    values.addElement(value);
    return key;
  }
  public Object get(Object key) {
    int index = keys.indexOf(key);
    // indexOf() Returns -1 if key not found:
    if(index == -1) return null;
    return values.elementAt(index);
  }
  public Object remove(Object key) {
    int index = keys.indexOf(key);
    if(index == -1) return null;
    keys.removeElementAt(index);
    Object returnval = values.elementAt(index);
    values.removeElementAt(index);
    return returnval;
  }
  public Enumeration keys() {
    return keys.elements();
  }
  public Enumeration elements() {
    return values.elements();
  }
  // Test it:
  public static void main(String[] args) {
    AssocArray aa = new AssocArray();
    for(char c = 'a'; c <= 'z'; c++)
      aa.put(String.valueOf(c),
             String.valueOf(c)
             .toUpperCase());
    char[] ca = { 'a', 'e', 'i', 'o', 'u' };
    for(int i = 0; i < ca.length; i++)
      System.out.println("Uppercase: " +
             aa.get(String.valueOf(ca[i])));
  }
} ///:~

在对AssocArray的定义中,我们注意到的第一个问题是它“扩展”了字典。这意味着AssocArray属于Dictionary的一种类型,所以可对其发出与Dictionary一样的请求。如果想生成自己的Dictionary,而且就在这里进行,那么要做的全部事情只是填充位于Dictionary内的所有方法(而且必须覆盖所有方法,因为它们——除构造器外——都是抽象的)。

Vector keyvalue通过一个标准索引编号链接起来。也就是说,如果用roof的一个键以及blue的一个值调用put()——假定我们准备将一个房子的各部分与它们的油漆颜色关联起来,而且AssocArray里已有100个元素,那么roof就会有101个键元素,而blue有101个值元素。而且要注意一下get(),假如我们作为键传递roof,它就会产生与keys.index.Of()的索引编号,然后用那个索引编号生成相关的值向量内的值。

main()中进行的测试是非常简单的;它只是将小写字符转换成大写字符,这显然可用更有效的方式进行。但它向我们揭示出了AssocArray的强大功能。

标准Java库只包含Dictionary的一个变种,名为Hashtable(散列表,注释③)。Java的散列表具有与AssocArray相同的接口(因为两者都是从Dictionary继承来的)。但有一个方面却反映出了差别:执行效率。若仔细想想必须为一个get()做的事情,就会发现在一个Vector里搜索键的速度要慢得多。但此时用散列表却可以加快不少速度。不必用冗长的线性搜索技术来查找一个键,而是用一个特殊的值,名为“散列码”。散列码可以获取对象中的信息,然后将其转换成那个对象“相对唯一”的整数(int)。所有对象都有一个散列码,而hashCode()是根类Object的一个方法。Hashtable获取对象的hashCode(),然后用它快速查找键。这样可使性能得到大幅度提升(④)。散列表的具体工作原理已超出了本书的范围(⑤)——大家只需要知道散列表是一种快速的“字典”(Dictionary)即可,而字典是一种非常有用的工具。

③:如计划使用RMI(在第15章详述),应注意将远程对象置入散列表时会遇到一个问题(参阅《Core Java》,作者Conrell和Horstmann,Prentice-Hall 1997年出版)

④:如这种速度的提升仍然不能满足你对性能的要求,甚至可以编写自己的散列表例程,从而进一步加快表格的检索过程。这样做可避免在与Object之间进行转换的时间延误,也可以避开由Java类库散列表例程内建的同步过程。

⑤:我的知道的最佳参考读物是《Practical Algorithms for Programmers》,作者为Andrew Binstock和John Rex,Addison-Wesley 1995年出版。

作为应用散列表的一个例子,可考虑用一个程序来检验Java的Math.random()方法的随机性到底如何。在理想情况下,它应该产生一系列完美的随机分布数字。但为了验证这一点,我们需要生成数量众多的随机数字,然后计算落在不同范围内的数字多少。散列表可以极大简化这一工作,因为它能将对象同对象关联起来(此时是将Math.random()生成的值同那些值出现的次数关联起来)。如下所示:

//: Statistics.java
// Simple demonstration of Hashtable
import java.util.*;

class Counter {
  int i = 1;
  public String toString() {
    return Integer.toString(i);
  }
}

class Statistics {
  public static void main(String[] args) {
    Hashtable ht = new Hashtable();
    for(int i = 0; i < 10000; i++) {
      // Produce a number between 0 and 20:
      Integer r =
        new Integer((int)(Math.random() * 20));
      if(ht.containsKey(r))
        ((Counter)ht.get(r)).i++;
      else
        ht.put(r, new Counter());
    }
    System.out.println(ht);
  }
} ///:~

main()中,每次产生一个随机数字,它都会封装到一个Integer对象里,使引用能够随同散列表一起使用(不可对一个集合使用基本数据类型,只能使用对象引用)。containKey()方法检查这个键是否已经在集合里(也就是说,那个数字以前发现过吗?)若已在集合里,则get()方法获得那个键关联的值,此时是一个Counter(计数器)对象。计数器内的值i随后会增加1,表明这个特定的随机数字又出现了一次。

假如键以前尚未发现过,那么方法put()仍然会在散列表内置入一个新的“键-值”对。在创建之初,Counter会自己的变量i自动初始化为1,它标志着该随机数字的第一次出现。

为显示散列表,只需把它简单地打印出来即可。Hashtable toString()方法能遍历所有键-值对,并为每一对都调用toString()Integer toString()是事先定义好的,可看到计数器使用的toString。一次运行的结果(添加了一些换行)如下:

{19=526, 18=533, 17=460, 16=513, 15=521, 14=495,
 13=512, 12=483, 11=488, 10=487, 9=514, 8=523,
 7=497, 6=487, 5=480, 4=489, 3=509, 2=503, 1=475,
 0=505}

大家或许会对Counter类是否必要感到疑惑,它看起来似乎根本没有封装类Integer的功能。为什么不用intInteger呢?事实上,由于所有集合能容纳的仅有对象引用,所以根本不可以使用整数。学过集合后,封装类的概念对大家来说就可能更容易理解了,因为不可以将任何基本数据类型置入集合里。然而,我们对Java包装器能做的唯一事情就是将其初始化成一个特定的值,然后读取那个值。也就是说,一旦包装器对象已经创建,就没有办法改变一个值。这使得Integer包装器对解决我们的问题毫无意义,所以不得不创建一个新类,用它来满足自己的要求。

(1) 创建“关键”类

在前面的例子里,我们用一个标准库的类(Integer)作为Hashtable的一个键使用。作为一个键,它能很好地工作,因为它已经具备正确运行的所有条件。但在使用散列表的时候,一旦我们创建自己的类作为键使用,就会遇到一个很常见的问题。例如,假设一套天气预报系统将Groundhog(土拔鼠)对象匹配成Prediction(预报)。这看起来非常直观:我们创建两个类,然后将Groundhog作为键使用,而将Prediction作为值使用。如下所示:

//: SpringDetector.java
// Looks plausible, but doesn't work right.
import java.util.*;

class Groundhog {
  int ghNumber;
  Groundhog(int n) { ghNumber = n; }
}

class Prediction {
  boolean shadow = Math.random() > 0.5;
  public String toString() {
    if(shadow)
      return "Six more weeks of Winter!";
    else
      return "Early Spring!";
  }
}

public class SpringDetector {
  public static void main(String[] args) {
    Hashtable ht = new Hashtable();
    for(int i = 0; i < 10; i++)
      ht.put(new Groundhog(i), new Prediction());
    System.out.println("ht = " + ht + "\n");
    System.out.println(
      "Looking up prediction for groundhog #3:");
    Groundhog gh = new Groundhog(3);
    if(ht.containsKey(gh))
      System.out.println((Prediction)ht.get(gh));
  }
} ///:~

每个Groundhog都具有一个标识号码,所以赤了在散列表中查找一个Prediction,只需指示它“告诉我与Groundhog号码3相关的Prediction”。Prediction类包含了一个布尔值,用Math.random()进行初始化,以及一个toString()为我们解释结果。在main()中,用Groundhog以及与它们相关的Prediction填充一个散列表。散列表被打印出来,以便我们看到它们确实已被填充。随后,用标识号码为3的一个Groundhog查找与Groundhog #3对应的预报。

看起来似乎非常简单,但实际是不可行的。问题在于Groundhog是从通用的Object根类继承的(若当初未指定基类,则所有类最终都是从Object继承的)。事实上是用ObjecthashCode()方法生成每个对象的散列码,而且默认情况下只使用它的对象的地址。所以,Groundhog(3)的第一个实例并不会产生与Groundhog(3)第二个实例相等的散列码,而我们用第二个实例进行检索。

大家或许认为此时要做的全部事情就是正确地覆盖hashCode()。但这样做依然行不能,除非再做另一件事情:覆盖也属于Object一部分的equals()。当散列表试图判断我们的键是否等于表内的某个键时,就会用到这个方法。同样地,默认的Object.equals()只是简单地比较对象地址,所以一个Groundhog(3)并不等于另一个Groundhog(3)

因此,为了在散列表中将自己的类作为键使用,必须同时覆盖hashCode()equals(),就象下面展示的那样:

//: SpringDetector2.java
// If you create a class that's used as a key in
// a Hashtable, you must override hashCode()
// and equals().
import java.util.*;

class Groundhog2 {
  int ghNumber;
  Groundhog2(int n) { ghNumber = n; }
  public int hashCode() { return ghNumber; }
  public boolean equals(Object o) {
    return (o instanceof Groundhog2)
      && (ghNumber == ((Groundhog2)o).ghNumber);
  }
}

public class SpringDetector2 {
  public static void main(String[] args) {
    Hashtable ht = new Hashtable();
    for(int i = 0; i < 10; i++)
      ht.put(new Groundhog2(i),new Prediction());
    System.out.println("ht = " + ht + "\n");
    System.out.println(
      "Looking up prediction for groundhog #3:");
    Groundhog2 gh = new Groundhog2(3);
    if(ht.containsKey(gh))
      System.out.println((Prediction)ht.get(gh));
  }
} ///:~

注意这段代码使用了来自前一个例子的Prediction,所以SpringDetector.java必须首先编译,否则就会在试图编译SpringDetector2.java时得到一个编译期错误。

Groundhog2.hashCode()将土拔鼠号码作为一个标识符返回(在这个例子中,程序员需要保证没有两个土拔鼠用同样的ID号码并存)。为了返回一个独一无二的标识符,并不需要hashCode()equals()方法必须能够严格判断两个对象是否相等。

equals()方法要进行两种检查:检查对象是否为null;若不为null,则继续检查是否为Groundhog2的一个实例(要用到instanceof关键字,第11章会详加论述)。即使为了继续执行equals(),它也应该是一个Groundhog2。正如大家看到的那样,这种比较建立在实际ghNumber的基础上。这一次一旦我们运行程序,就会看到它终于产生了正确的输出(许多Java库的类都覆盖了hashcode()equals()方法,以便与自己提供的内容适应)。

(2) 属性:Hashtable的一种类型

在本书的第一个例子中,我们使用了一个名为Properties(属性)的Hashtable类型。在那个例子中,下述程序行:

Properties p = System.getProperties();
p.list(System.out);

调用了一个名为getProperties()static方法,用于获得一个特殊的Properties对象,对系统的某些特征进行描述。list()属于Properties的一个方法,可将内容发给我们选择的任何流式输出。也有一个save()方法,可用它将属性列表写入一个文件,以便日后用load()方法读取。

尽管Properties类是从Hashtable继承的,但它也包含了一个散列表,用于容纳“默认”属性的列表。所以假如没有在主列表里找到一个属性,就会自动搜索默认属性。

Properties类亦可在我们的程序中使用(第17章的ClassScanner.java便是一例)。在Java库的用户文档中,往往可以找到更多、更详细的说明。

8.4.5 再论枚举器

我们现在可以开始演示Enumeration(枚举)的真正威力:将穿越一个序列的操作与那个序列的基础结构分隔开。在下面的例子里,PrintData类用一个Enumeration在一个序列中移动,并为每个对象都调用toString()方法。此时创建了两个不同类型的集合:一个Vector和一个Hashtable。并且在它们里面分别填充MouseHamster对象(本章早些时候已定义了这些类;注意必须先编译HamsterMaze.javaWorksAnyway.java,否则下面的程序不能编译)。由于Enumeration隐藏了基层集合的结构,所以PrintData不知道或者不关心Enumeration来自于什么类型的集合:

//: Enumerators2.java
// Revisiting Enumerations
import java.util.*;

class PrintData {
  static void print(Enumeration e) {
    while(e.hasMoreElements())
      System.out.println(
        e.nextElement().toString());
  }
}

class Enumerators2 {
  public static void main(String[] args) {
    Vector v = new Vector();
    for(int i = 0; i < 5; i++)
      v.addElement(new Mouse(i));

    Hashtable h = new Hashtable();
    for(int i = 0; i < 5; i++)
      h.put(new Integer(i), new Hamster(i));

    System.out.println("Vector");
    PrintData.print(v.elements());
    System.out.println("Hashtable");
    PrintData.print(h.elements());
  }
} ///:~

注意PrintData.print()利用了这些集合中的对象属于Object类这一事实,所以它调用了toString()。但在解决自己的实际问题时,经常都要保证自己的Enumeration穿越某种特定类型的集合。例如,可能要求集合中的所有元素都是一个Shape(几何形状),并含有draw()方法。若出现这种情况,必须从Enumeration.nextElement()返回的Object进行向下转换,以便产生一个Shape

8.5 排序

Java 1.0和1.1库都缺少的一样东西是算术运算,甚至没有最简单的排序运算方法。因此,我们最好创建一个Vector,利用经典的Quicksort(快速排序)方法对其自身进行排序。

编写通用的排序代码时,面临的一个问题是必须根据对象的实际类型来执行比较运算,从而实现正确的排序。当然,一个办法是为每种不同的类型都写一个不同的排序方法。然而,应认识到假若这样做,以后增加新类型时便不易实现代码的重复利用。

程序设计一个主要的目标就是“将发生变化的东西同保持不变的东西分隔开”。在这里,保持不变的代码是通用的排序算法,而每次使用时都要变化的是对象的实际比较方法。因此,我们不可将比较代码“硬编码”到多个不同的排序例程内,而是采用“回调”技术。利用回调,经常发生变化的那部分代码会封装到它自己的类内,而总是保持相同的代码则“回调”发生变化的代码。这样一来,不同的对象就可以表达不同的比较方式,同时向它们传递相同的排序代码。

下面这个“接口”(Interface)展示了如何比较两个对象,它将那些“要发生变化的东西”封装在内:

//: Compare.java
// Interface for sorting callback:
package c08;

interface Compare {
  boolean lessThan(Object lhs, Object rhs);
  boolean lessThanOrEqual(Object lhs, Object rhs);
} ///:~

对这两种方法来说,lhs代表本次比较中的“左手”对象,而rhs代表“右手”对象。

可创建Vector的一个子类,通过Compare实现“快速排序”。对于这种算法,包括它的速度以及原理等等,在此不具体说明。欲知详情,可参考Binstock和Rex编著的《Practical Algorithms for Programmers》,由Addison-Wesley于1995年出版。

//: SortVector.java
// A generic sorting vector
package c08;
import java.util.*;

public class SortVector extends Vector {
  private Compare compare; // To hold the callback
  public SortVector(Compare comp) {
    compare = comp;
  }
  public void sort() {
    quickSort(0, size() - 1);
  }
  private void quickSort(int left, int right) {
    if(right > left) {
      Object o1 = elementAt(right);
      int i = left - 1;
      int j = right;
      while(true) {
        while(compare.lessThan(
              elementAt(++i), o1))
          ;
        while(j > 0)
          if(compare.lessThanOrEqual(
             elementAt(--j), o1))
            break; // out of while
        if(i >= j) break;
        swap(i, j);
      }
      swap(i , right);
      quickSort(left, i-1);
      quickSort(i+1, right);
    }
  }
  private void swap(int loc1, int loc2) {
    Object tmp = elementAt(loc1);
    setElementAt(elementAt(loc2), loc1);
    setElementAt(tmp, loc2);
  }
} ///:~

现在,大家可以明白“回调”一词的来历,这是由于quickSort()方法“往回调用”了Compare中的方法。从中亦可理解这种技术如何生成通用的、可重复利用(复用)的代码。

为使用SortVector,必须创建一个类,令其为我们准备排序的对象实现Compare。此时内部类并不显得特别重要,但对于代码的组织却是有益的。下面是针对String对象的一个例子:

//: StringSortTest.java
// Testing the generic sorting Vector
package c08;
import java.util.*;

public class StringSortTest {
  static class StringCompare implements Compare {
    public boolean lessThan(Object l, Object r) {
      return ((String)l).toLowerCase().compareTo(
        ((String)r).toLowerCase()) < 0;
    }
    public boolean
    lessThanOrEqual(Object l, Object r) {
      return ((String)l).toLowerCase().compareTo(
        ((String)r).toLowerCase()) <= 0;
    }
  }
  public static void main(String[] args) {
    SortVector sv =
      new SortVector(new StringCompare());
    sv.addElement("d");
    sv.addElement("A");
    sv.addElement("C");
    sv.addElement("c");
    sv.addElement("b");
    sv.addElement("B");
    sv.addElement("D");
    sv.addElement("a");
    sv.sort();
    Enumeration e = sv.elements();
    while(e.hasMoreElements())
      System.out.println(e.nextElement());
  }
} ///:~

内部类是“静态”(Static)的,因为它毋需连接一个外部类即可工作。

大家可以看到,一旦设置好框架,就可以非常方便地重复使用象这样的一个设计——只需简单地写一个类,将“需要发生变化”的东西封装进去,然后将一个对象传给SortVector即可。

比较时将字符串强制为小写形式,所以大写A会排列于小写a的旁边,而不会移动一个完全不同的地方。然而,该例也显示了这种方法的一个不足,因为上述测试代码按照出现顺序排列同一个字母的大写和小写形式:A a b B c C d D。但这通常不是一个大问题,因为经常处理的都是更长的字符串,所以上述效果不会显露出来(Java 1.2的集合提供了排序功能,已解决了这个问题)。

继承(extends)在这儿用于创建一种新类型的Vector——也就是说,SortVector属于一种Vector,并带有一些附加的功能。继承在这里可发挥很大的作用,但了带来了问题。它使一些方法具有了final属性(已在第7章讲述),所以不能覆盖它们。如果想创建一个排好序的Vector,令其只接收和生成String对象,就会遇到麻烦。因为addElement()elementAt()都具有final属性,而且它们都是我们必须覆盖的方法,否则便无法实现只能接收和产生String对象。

但在另一方面,请考虑采用“组合”方法:将一个对象置入一个新类的内部。此时,不是改写上述代码来达到这个目的,而是在新类里简单地使用一个SortVector。在这种情况下,用于实现Compare接口的内部类就可以“匿名”地创建。如下所示:

//: StrSortVector.java
// Automatically sorted Vector that
// accepts and produces only Strings
package c08;
import java.util.*;

public class StrSortVector {
  private SortVector v = new SortVector(
    // Anonymous inner class:
    new Compare() {
      public boolean
      lessThan(Object l, Object r) {
        return
          ((String)l).toLowerCase().compareTo(
          ((String)r).toLowerCase()) < 0;
      }
      public boolean
      lessThanOrEqual(Object l, Object r) {
        return
          ((String)l).toLowerCase().compareTo(
          ((String)r).toLowerCase()) <= 0;
      }
    }
  );
  private boolean sorted = false;
  public void addElement(String s) {
    v.addElement(s);
    sorted = false;
  }
  public String elementAt(int index) {
    if(!sorted) {
      v.sort();
      sorted = true;
    }
    return (String)v.elementAt(index);
  }
  public Enumeration elements() {
    if(!sorted) {
      v.sort();
      sorted = true;
    }
    return v.elements();
  }
  // Test it:
  public static void main(String[] args) {
    StrSortVector sv = new StrSortVector();
    sv.addElement("d");
    sv.addElement("A");
    sv.addElement("C");
    sv.addElement("c");
    sv.addElement("b");
    sv.addElement("B");
    sv.addElement("D");
    sv.addElement("a");
    Enumeration e = sv.elements();
    while(e.hasMoreElements())
      System.out.println(e.nextElement());
  }
} ///:~

这样便可快速复用来自SortVector的代码,从而获得希望的功能。然而,并不是来自SortVectorVector的所有public方法都能在StrSortVector中出现。若按这种形式复用代码,可在新类里为包含类内的每一个方法都生成一个定义。当然,也可以在刚开始时只添加少数几个,以后根据需要再添加更多的。新类的设计最终会稳定下来。

这种方法的好处在于它仍然只接纳String对象,也只产生String对象。而且相应的检查是在编译期间进行的,而非在运行期。当然,只有addElement()elementAt()才具备这一特性;elements()仍然会产生一个Enumeration(枚举),它在编译期的类型是未定的。当然,对Enumeration以及在StrSortVector中的类型检查会照旧进行;如果真的有什么错误,运行期间会简单地产生一个异常。事实上,我们在编译或运行期间能保证一切都正确无误吗?(也就是说,“代码测试时也许不能保证”,以及“该程序的用户有可能做一些未经我们测试的事情”)。尽管存在其他选择和争论,使用继承都要容易得多,只是在转换时让人深感不便。同样地,一旦为Java加入参数化类型,就有望解决这个问题。

大家在这个类中可以看到有一个名为sorted的标志。每次调用addElement()时,都可对Vector进行排序,而且将其连续保持在一个排好序的状态。但在开始读取之前,人们总是向一个Vector添加大量元素。所以与其在每个addElement()后排序,不如一直等到有人想读取Vector,再对其进行排序。后者的效率要高得多。这种除非绝对必要,否则就不采取行动的方法叫作“懒惰求值”(还有一种类似的技术叫作“懒惰初始化”——除非真的需要一个字段值,否则不进行初始化)。

8.6 通用集合库

通过本章的学习,大家已知道标准Java库提供了一些特别有用的集合,但距完整意义的集合尚远。除此之外,象排序这样的算法根本没有提供支持。C++出色的一个地方就是它的库,特别是“标准模板库”(STL)提供了一套相当完整的集合,以及许多象排序和检索这样的算法,可以非常方便地对那些集合进行操作。有感这一现状,并以这个模型为基础,ObjectSpace公司设计了Java版本的“通用集合库”(从前叫作“Java通用库”,即JGL;但JGL这个缩写形式侵犯了Sun公司的版权——尽管本书仍然沿用这个简称)。这个库尽可能遵照STL的设计(照顾到两种语言间的差异)。JGL实现了许多功能,可满足对一个集合库的大多数常规需求,它与C++的模板机制非常相似。JGL包括相互链接起来的列表、设置、队列、映射、栈、序列以及迭代器,它们的功能比Enumeration(枚举)强多了。同时提供了一套完整的算法,如检索和排序等。在某些方面,ObjectSpace的设计也显得比Sun的库设计模式“智能”一些。举个例子来说,JGL集合中的方法不会进入final状态,所以很容易继承和改写那些方法。

JGL已包括到一些厂商发行的Java套件中,而且ObjectSpace公司自己也允许所有用户免费使用JGL,包括商业性的使用。详细情况和软件下载可访问 http://www.ObjectSpace.com 。与JGL配套提供的联机文档做得非常好,可作为自己的一个绝佳起点使用。

8.7 新集合

对我来说,集合类属于最强大的一种工具,特别适合在原创编程中使用。大家可能已感觉到我对Java 1.1提供的集合多少有点儿失望。因此,看到Java 1.2对集合重新引起了正确的注意后,确实令人非常愉快。这个版本的集合也得到了完全的重新设计(由Sun公司的Joshua Bloch)。我认为新设计的集合是Java 1.2中两项最主要的特性之一(另一项是Swing库,将在第13章叙述),因为它们极大方便了我们的编程,也使Java变成一种更成熟的编程系统。

有些设计使得元素间的结合变得更紧密,也更容易让人理解。例如,许多名字都变得更短、更明确了,而且更易使用;类型同样如此。有些名字进行了修改,更接近于通俗:我感觉特别好的一个是用“迭代器”(Inerator)代替了“枚举”(Enumeration)。

此次重新设计也加强了集合库的功能。现在新增的行为包括链接列表、队列以及撤消组队(即“双终点队列”)。

集合库的设计是相当困难的(会遇到大量库设计问题)。在C++中,STL用多个不同的类来覆盖基础。这种做法比起STL以前是个很大的进步,那时根本没做这方面的考虑。但仍然没有很好地转换到Java里面。结果就是一大堆特别容易混淆的类。在另一个极端,我曾发现一个集合库由单个类构成:collection,它同时作为VectorHashtable使用。新集合库的设计者则希望达到一种新的平衡:实现人们希望从一个成熟集合库上获得的完整功能,同时又要比STL和其他类似的集合库更易学习和使用。这样得到的结果在某些场合显得有些古怪。但和早期Java库的一些决策不同,这些古怪之处并非偶然出现的,而是以复杂性作为代价,在进行仔细权衡之后得到的结果。这样做也许会延长人们掌握一些库概念的时间,但很快就会发现自己很乐于使用那些新工具,而且变得越来越离不了它。

新的集合库考虑到了“容纳自己对象”的问题,并将其分割成两个明确的概念:

(1) 集合(Collection):一组单独的元素,通常应用了某种规则。在这里,一个List(列表)必须按特定的顺序容纳元素,而一个Set(集)不可包含任何重复的元素。相反,“包”(Bag)的概念未在新的集合库中实现,因为“列表”已提供了类似的功能。

(2) 映射(Map):一系列“键-值”对(这已在散列表身上得到了充分的体现)。从表面看,这似乎应该成为一个“键-值”对的“集合”,但假若试图按那种方式实现它,就会发现实现过程相当笨拙。这进一步证明了应该分离成单独的概念。另一方面,可以方便地查看Map的某个部分。只需创建一个集合,然后用它表示那一部分即可。这样一来,Map就可以返回自己键的一个Set、一个包含自己值的List或者包含自己“键-值”对的一个List。和数组相似,Map可方便扩充到多个“维”,毋需涉及任何新概念。只需简单地在一个Map里包含其他Map(后者又可以包含更多的Map,以此类推)。

CollectionMap可通过多种形式实现,具体由编程要求决定。下面列出的是一个帮助大家理解的新集合示意图:

这张图刚开始的时候可能让人有点儿摸不着头脑,但在通读了本章以后,相信大家会真正理解它实际只有三个集合组件:MapListSet。而且每个组件实际只有两、三种实现方式(注释⑥),而且通常都只有一种特别好的方式。只要看出了这一点,集合就不会再令人生畏。

⑥:写作本章时,Java 1.2尚处于β测试阶段,所以这张示意图没有包括以后会加入的TreeSet

虚线框代表“接口”,点线框代表“抽象”类,而实线框代表普通(实际)类。点线箭头表示一个特定的类准备实现一个接口(在抽象类的情况下,则是“部分”实现一个接口)。双线箭头表示一个类可生成箭头指向的那个类的对象。例如,任何集合都可以生成一个迭代器(Iterator),而一个列表可以生成一个ListIterator(以及原始的迭代器,因为列表是从集合继承的)。

致力于容纳对象的接口是CollectionListSetMap。在传统情况下,我们需要写大量代码才能同这些接口打交道。而且为了指定自己想使用的准确类型,必须在创建之初进行设置。所以可能创建下面这样的一个List

List x = new LinkedList();

当然,也可以决定将x作为一个LinkedList使用(而不是一个普通的List),并用x负载准确的类型信息。使用接口的好处就是一旦决定改变自己的实现细节,要做的全部事情就是在创建的时候改变它,就象下面这样:

List x = new ArrayList();

其余代码可以保持原封不动。

在类的分级结构中,可看到大量以Abstract(抽象)开头的类,这刚开始可能会使人感觉迷惑。它们实际上是一些工具,用于“部分”实现一个特定的接口。举个例子来说,假如想生成自己的Set,就不是从Set接口开始,然后自行实现所有方法。相反,我们可以从AbstractSet继承,只需极少的工作即可得到自己的新类。尽管如此,新集合库仍然包含了足够的功能,可满足我们的几乎所有需求。所以考虑到我们的目的,可忽略所有以Abstract开头的类。

因此,在观看这张示意图时,真正需要关心的只有位于最顶部的“接口”以及普通(实际)类——均用实线方框包围。通常需要生成实际类的一个对象,将其向上转换为对应的接口。以后即可在代码的任何地方使用那个接口。下面是一个简单的例子,它用String对象填充一个集合,然后打印出集合内的每一个元素:

//: SimpleCollection.java
// A simple example using the new Collections
package c08.newcollections;
import java.util.*;

public class SimpleCollection {
  public static void main(String[] args) {
    Collection c = new ArrayList();
    for(int i = 0; i < 10; i++)
      c.add(Integer.toString(i));
    Iterator it = c.iterator();
    while(it.hasNext())
      System.out.println(it.next());
  }
} ///:~

新集合库的所有代码示例都置于子目录newcollections下,这样便可提醒自己这些工作只对于Java 1.2有效。这样一来,我们必须用下述代码来调用程序:

java c08.newcollections.SimpleCollection

采用的语法与其他程序是差不多的。

大家可以看到新集合属于java.util库的一部分,所以在使用时不需要再添加任何额外的import语句。

main()的第一行创建了一个ArrayList对象,然后将其向上转换成为一个集合。由于这个例子只使用了Collection方法,所以从Collection继承的一个类的任何对象都可以正常工作。但ArrayList是一个典型的Collection,它代替了Vector的位置。
显然,add()方法的作用是将一个新元素置入集合里。然而,用户文档谨慎地指出add()“保证这个集合包含了指定的元素”。这一点是为Set作铺垫的,后者只有在元素不存在的前提下才会真的加入那个元素。对于ArrayList以及其他任何形式的Listadd()肯定意味着“直接加入”。

利用iterator()方法,所有集合都能生成一个“迭代器”(Iterator)。迭代器其实就象一个“枚举”(Enumeration),是后者的一个替代物,只是:

(1) 它采用了一个历史上默认、而且早在OOP中得到广泛采纳的名字(迭代器)。

(2) 采用了比Enumeration更短的名字:hasNext()代替了hasMoreElement(),而next()代替了nextElement()

(3) 添加了一个名为remove()的新方法,可删除由Iterator生成的上一个元素。所以每次调用next()的时候,只需调用remove()一次。

SimpleCollection.java中,大家可看到创建了一个迭代器,并用它在集合里遍历,打印出每个元素。

8.7.1 使用Collections

下面这张表格总结了用一个集合能做的所有事情(亦可对SetList做同样的事情,尽管List还提供了一些额外的功能)。Map不是从Collection继承的,所以要单独对待。

Boolean add(Object)

*Ensures that the Collection contains the argument. Returns false if it doesn’t add the argument.

Boolean addAll(Collection)

*Adds all the elements in the argument. Returns true if any elements were added.

void clear( )

*Removes all the elements in the Collection.

Boolean contains(Object)

True if the Collection contains the argument.

Boolean containsAll(Collection)

True if the Collection contains all the elements in the argument.

Boolean isEmpty( )

True if the Collection has no elements.

Iterator iterator( )

Returns an Iterator that you can use to move through the elements in the Collection.

Boolean remove(Object)

*If the argument is in the Collection, one instance of that element is removed. Returns true if a removal occurred.

Boolean removeAll(Collection)

*Removes all the elements that are contained in the argument. Returns true if any removals occurred.

Boolean retainAll(Collection)

*Retains only elements that are contained in the argument (an “intersection” from set theory). Returns true if any changes occurred.

int size( )

Returns the number of elements in the Collection.

Object[] toArray( )

Returns an array containing all the elements in the Collection.

Object[] toArray(Object[] a)

Returns an array containing all the elements in the Collection, whose type is that of the array a rather than plain Object (you must cast the array to the right type).


*This is an “optional” method, which means it might not be implemented by a particular Collection. If not, that method throws an UnsupportedOperationException. Exceptions will be covered in Chapter 9.
  • boolean add(Object) *保证集合内包含了参数。如果它没有添加参数,就返回false(假)
  • boolean addAll(Collection) *添加参数内的所有元素。如果没有添加元素,则返回true(真)
  • void clear() *删除集合内的所有元素
  • boolean contains(Object) 若集合包含参数,就返回“真”
  • boolean containsAll(Collection) 若集合包含了参数内的所有元素,就返回“真”
  • boolean isEmpty() 若集合内没有元素,就返回“真”
  • Iterator iterator() 返回一个迭代器,以用它遍历集合的各元素
  • boolean remove(Object) *如参数在集合里,就删除那个元素的一个实例。如果已进行了删除,就返回“真”
  • boolean removeAll(Collection) *删除参数里的所有元素。如果已进行了任何删除,就返回“真”
  • boolean retainAll(Collection) *只保留包含在一个参数里的元素(一个理论的“交集”)。如果已进行了任何改变,就返回“真”
  • int size() 返回集合内的元素数量
  • Object[] toArray() 返回包含了集合内所有元素的一个数组

*这是一个“可选的”方法,有的集合可能并未实现它。若确实如此,该方法就会遇到一个UnsupportedOperatiionException,即一个“操作不支持”异常,详见第9章。

下面这个例子向大家演示了所有方法。同样地,它们只对从集合继承的东西有效,一个ArrayList作为一种“不常用的分母”使用:

//: Collection1.java
// Things you can do with all Collections
package c08.newcollections;
import java.util.*;

public class Collection1 {
  // Fill with 'size' elements, start
  // counting at 'start':
  public static Collection
  fill(Collection c, int start, int size) {
    for(int i = start; i < start + size; i++)
      c.add(Integer.toString(i));
    return c;
  }
  // Default to a "start" of 0:
  public static Collection
  fill(Collection c, int size) {
    return fill(c, 0, size);
  }
  // Default to 10 elements:
  public static Collection fill(Collection c) {
    return fill(c, 0, 10);
  }
  // Create & upcast to Collection:
  public static Collection newCollection() {
    return fill(new ArrayList());
    // ArrayList is used for simplicity, but it's
    // only seen as a generic Collection
    // everywhere else in the program.
  }
  // Fill a Collection with a range of values:
  public static Collection
  newCollection(int start, int size) {
    return fill(new ArrayList(), start, size);
  }
  // Moving through a List with an iterator:
  public static void print(Collection c) {
    for(Iterator x = c.iterator(); x.hasNext();)
      System.out.print(x.next() + " ");
    System.out.println();
  }    
  public static void main(String[] args) {
    Collection c = newCollection();
    c.add("ten");
    c.add("eleven");
    print(c);
    // Make an array from the List:
    Object[] array = c.toArray();
    // Make a String array from the List:
    String[] str =
      (String[])c.toArray(new String[1]);
    // Find max and min elements; this means
    // different things depending on the way
    // the Comparable interface is implemented:
    System.out.println("Collections.max(c) = " +
      Collections.max(c));
    System.out.println("Collections.min(c) = " +
      Collections.min(c));
    // Add a Collection to another Collection
    c.addAll(newCollection());
    print(c);
    c.remove("3"); // Removes the first one
    print(c);
    c.remove("3"); // Removes the second one
    print(c);
    // Remove all components that are in the
    // argument collection:
    c.removeAll(newCollection());
    print(c);
    c.addAll(newCollection());
    print(c);
    // Is an element in this Collection?
    System.out.println(
      "c.contains(\"4\") = " + c.contains("4"));
    // Is a Collection in this Collection?
    System.out.println(
      "c.containsAll(newCollection()) = " +
      c.containsAll(newCollection()));
    Collection c2 = newCollection(5, 3);
    // Keep all the elements that are in both
    // c and c2 (an intersection of sets):
    c.retainAll(c2);
    print(c);
    // Throw away all the elements in c that
    // also appear in c2:
    c.removeAll(c2);
    System.out.println("c.isEmpty() = " +
      c.isEmpty());
    c = newCollection();
    print(c);
    c.clear(); // Remove all elements
    System.out.println("after c.clear():");
    print(c);
  }
} ///:~

通过第一个方法,我们可用测试数据填充任何集合。在当前这种情况下,只是将int转换成String。第二个方法将在本章其余的部分经常采用。

newCollection()的两个版本都创建了ArrayList,用于包含不同的数据集,并将它们作为集合对象返回。所以很明显,除了Collection接口之外,不会再用到其他什么。

print()方法也会在本节经常用到。由于它用一个迭代器(Iterator)在一个集合内遍历,而任何集合都可以产生这样的一个迭代器,所以它适用于ListSet,也适用于由一个Map生成的Collection

main()用简单的手段显示出了集合内的所有方法。

在后续的小节里,我们将比较ListSetMap的不同实现方案,同时指出在各种情况下哪一种方案应成为首选(带有星号的那个)。大家会发现这里并未包括一些传统的类,如VectorStack以及Hashtable等。因为不管在什么情况下,新集合内都有自己首选的类。

8.7.2 使用Lists

List (interface)

Order is the most important feature of a List; it promises to maintain elements in a particular sequence. List adds a number of methods to Collection that allow insertion and removal of elements in the middle of a List. (This is recommended only for a LinkedList.) A List will produce a ListIterator, and using this you can traverse the List in both directions, as well as insert and remove elements in the middle of the list (again, recommended only for a LinkedList).

ArrayList*

A List backed by an array. Use instead of Vector as a general-purpose object holder. Allows rapid random access to elements, but is slow when inserting and removing elements from the middle of a list. ListIterator should be used only for back-and-forth traversal of an ArrayList, but not for inserting and removing elements, which is expensive compared to LinkedList.

LinkedList

Provides optimal sequential access, with inexpensive insertions and deletions from the middle of the list. Relatively slow for random access. (Use ArrayList instead.) Also has addFirst( ), addLast( ), getFirst( ), getLast( ), removeFirst( ), and removeLast( ) (which are not defined in any interfaces or base classes) to allow it to be used as a stack, a queue, and a dequeue.

  • List(接口) 顺序是List最重要的特性;它可保证元素按照规定的顺序排列。ListCollection添加了大量方法,以便我们在List中部插入和删除元素(只推荐对LinkedList这样做)。List也会生成一个ListIterator(列表迭代器),利用它可在一个列表里朝两个方向遍历,同时插入和删除位于列表中部的元素(同样地,只建议对LinkedList这样做)

  • ArrayList* 由一个数组后推得到的List。作为一个常规用途的对象容器使用,用于替换原先的Vector。允许我们快速访问元素,但在从列表中部插入和删除元素时,速度却嫌稍慢。一般只应该用ListIterator对一个ArrayList进行向前和向后遍历,不要用它删除和插入元素;与LinkedList相比,它的效率要低许多

  • LinkedList 提供优化的顺序访问性能,同时可以高效率地在列表中部进行插入和删除操作。但在进行随机访问时,速度却相当慢,此时应换用ArrayList。也提供了addFirst()addLast()getFirst()getLast()removeFirst()以及removeLast()(未在任何接口或基类中定义),以便将其作为一个规格、队列以及一个双向队列使用

下面这个例子中的方法每个都覆盖了一组不同的行为:每个列表都能做的事情(basicTest()),通过一个迭代器遍历(iterMotion())、用一个迭代器改变某些东西(iterManipulation())、体验列表处理的效果(testVisual())以及只有LinkedList才能做的事情等:

//: List1.java
// Things you can do with Lists
package c08.newcollections;
import java.util.*;

public class List1 {
  // Wrap Collection1.fill() for convenience:
  public static List fill(List a) {
    return (List)Collection1.fill(a);
  }
  // You can use an Iterator, just as with a
  // Collection, but you can also use random
  // access with get():
  public static void print(List a) {
    for(int i = 0; i < a.size(); i++)
      System.out.print(a.get(i) + " ");
    System.out.println();
  }
  static boolean b;
  static Object o;
  static int i;
  static Iterator it;
  static ListIterator lit;
  public static void basicTest(List a) {
    a.add(1, "x"); // Add at location 1
    a.add("x"); // Add at end
    // Add a collection:
    a.addAll(fill(new ArrayList()));
    // Add a collection starting at location 3:
    a.addAll(3, fill(new ArrayList()));
    b = a.contains("1"); // Is it in there?
    // Is the entire collection in there?
    b = a.containsAll(fill(new ArrayList()));
    // Lists allow random access, which is cheap
    // for ArrayList, expensive for LinkedList:
    o = a.get(1); // Get object at location 1
    i = a.indexOf("1"); // Tell index of object
    // indexOf, starting search at location 2:
    i = a.indexOf("1", 2);
    b = a.isEmpty(); // Any elements inside?
    it = a.iterator(); // Ordinary Iterator
    lit = a.listIterator(); // ListIterator
    lit = a.listIterator(3); // Start at loc 3
    i = a.lastIndexOf("1"); // Last match
    i = a.lastIndexOf("1", 2); // ...after loc 2
    a.remove(1); // Remove location 1
    a.remove("3"); // Remove this object
    a.set(1, "y"); // Set location 1 to "y"
    // Keep everything that's in the argument
    // (the intersection of the two sets):
    a.retainAll(fill(new ArrayList()));
    // Remove elements in this range:
    a.removeRange(0, 2);
    // Remove everything that's in the argument:
    a.removeAll(fill(new ArrayList()));
    i = a.size(); // How big is it?
    a.clear(); // Remove all elements
  }
  public static void iterMotion(List a) {
    ListIterator it = a.listIterator();
    b = it.hasNext();
    b = it.hasPrevious();
    o = it.next();
    i = it.nextIndex();
    o = it.previous();
    i = it.previousIndex();
  }
  public static void iterManipulation(List a) {
    ListIterator it = a.listIterator();
    it.add("47");
    // Must move to an element after add():
    it.next();
    // Remove the element that was just produced:
    it.remove();
    // Must move to an element after remove():
    it.next();
    // Change the element that was just produced:
    it.set("47");
  }
  public static void testVisual(List a) {
    print(a);
    List b = new ArrayList();
    fill(b);
    System.out.print("b = ");
    print(b);
    a.addAll(b);
    a.addAll(fill(new ArrayList()));
    print(a);
    // Shrink the list by removing all the
    // elements beyond the first 1/2 of the list
    System.out.println(a.size());
    System.out.println(a.size()/2);
    a.removeRange(a.size()/2, a.size()/2 + 2);
    print(a);
    // Insert, remove, and replace elements
    // using a ListIterator:
    ListIterator x = a.listIterator(a.size()/2);
    x.add("one");
    print(a);
    System.out.println(x.next());
    x.remove();
    System.out.println(x.next());
    x.set("47");
    print(a);
    // Traverse the list backwards:
    x = a.listIterator(a.size());
    while(x.hasPrevious())
      System.out.print(x.previous() + " ");
    System.out.println();
    System.out.println("testVisual finished");
  }
  // There are some things that only
  // LinkedLists can do:
  public static void testLinkedList() {
    LinkedList ll = new LinkedList();
    Collection1.fill(ll, 5);
    print(ll);
    // Treat it like a stack, pushing:
    ll.addFirst("one");
    ll.addFirst("two");
    print(ll);
    // Like "peeking" at the top of a stack:
    System.out.println(ll.getFirst());
    // Like popping a stack:
    System.out.println(ll.removeFirst());
    System.out.println(ll.removeFirst());
    // Treat it like a queue, pulling elements
    // off the tail end:
    System.out.println(ll.removeLast());
    // With the above operations, it's a dequeue!
    print(ll);
  }
  public static void main(String args[]) {
    // Make and fill a new list each time:
    basicTest(fill(new LinkedList()));
    basicTest(fill(new ArrayList()));
    iterMotion(fill(new LinkedList()));
    iterMotion(fill(new ArrayList()));
    iterManipulation(fill(new LinkedList()));
    iterManipulation(fill(new ArrayList()));
    testVisual(fill(new LinkedList()));
    testLinkedList();
  }
} ///:~

basicTest()iterMotiion()中,只是简单地发出调用,以便揭示出正确的语法。而且尽管捕获了返回值,但是并未使用它。在某些情况下,之所以不捕获返回值,是由于它们没有什么特别的用处。在正式使用它们前,应仔细研究一下自己的联机文档,掌握这些方法完整、正确的用法。

8.7.3 使用Sets

Set拥有与Collection完全相同的接口,所以和两种不同的List不同,它没有什么额外的功能。相反,Set完全就是一个Collection,只是具有不同的行为(这是实例和多态性最理想的应用:用于表达不同的行为)。在这里,一个Set只允许每个对象存在一个实例(正如大家以后会看到的那样,一个对象的“值”的构成是相当复杂的)。

Set (interface)

Each element that you add to the Set must be unique; otherwise the Set doesn’t add the duplicate element. Objects added to a Set must define equals( ) to establish object uniqueness. Set has exactly the same interface as Collection. The Set interface does not guarantee it will maintain its elements in any particular order.

HashSet*

For Sets where fast lookup time is important. Objects must also define hashCode( ).

TreeSet

An ordered Set backed by a red-black tree. This way, you can extract an ordered sequence from a Set.
  • Set(接口) 添加到Set的每个元素都必须是独一无二的;否则Set就不会添加重复的元素。添加到Set里的对象必须定义equals(),从而建立对象的唯一性。Set拥有与Collection完全相同的接口。一个Set不能保证自己可按任何特定的顺序维持自己的元素
  • HashSet* 用于除非常小的以外的所有Set。对象也必须定义hashCode()
  • ArraySet 由一个数组后推得到的Set。面向非常小的Set设计,特别是那些需要频繁创建和删除的。对于小Set,与HashSet相比,ArraySet创建和迭代所需付出的代价都要小得多。但随着Set的增大,它的性能也会大打折扣。不需要HashCode()
  • TreeSet 由一个“红黑树”后推得到的顺序Set(注释⑦)。这样一来,我们就可以从一个Set里提到一个顺序集合

⑦:直至本书写作的时候,TreeSet仍然只是宣布,尚未正式实现。所以这里没有提供使用TreeSet的例子。

下面这个例子并没有列出用一个Set能够做的全部事情,因为接口与Collection是相同的,前例已经练习过了。相反,我们要例示的重点在于使一个Set独一无二的行为:

//: Set1.java
// Things you can do with Sets
package c08.newcollections;
import java.util.*;

public class Set1 {
  public static void testVisual(Set a) {
    Collection1.fill(a);
    Collection1.fill(a);
    Collection1.fill(a);
    Collection1.print(a); // No duplicates!
    // Add another set to this one:
    a.addAll(a);
    a.add("one");
    a.add("one");
    a.add("one");
    Collection1.print(a);
    // Look something up:
    System.out.println("a.contains(\"one\"): " +
      a.contains("one"));
  }
  public static void main(String[] args) {
    testVisual(new HashSet());
    testVisual(new TreeSet());
  }
} ///:~

重复的值被添加到Set,但在打印的时候,我们会发现Set只接受每个值的一个实例。

运行这个程序时,会注意到由HashSet维持的顺序与ArraySet是不同的。这是由于它们采用了不同的方法来保存元素,以便它们以后的定位。ArraySet保持着它们的顺序状态,而HashSet使用一个散列函数,这是特别为快速检索设计的)。创建自己的类型时,一定要注意Set需要通过一种方式来维持一种存储顺序,就象本章早些时候展示的“groundhog”(土拔鼠)例子那样。下面是一个例子:

//: Set2.java
// Putting your own type in a Set
package c08.newcollections;
import java.util.*;

class MyType implements Comparable {
  private int i;
  public MyType(int n) { i = n; }
  public boolean equals(Object o) {
    return
      (o instanceof MyType)
      && (i == ((MyType)o).i);
  }
  public int hashCode() { return i; }
  public String toString() { return i + " "; }
  public int compareTo(Object o) {
    int i2 = ((MyType) o).i;
    return (i2 < i ? -1 : (i2 == i ? 0 : 1));
  }
}

public class Set2 {
  public static Set fill(Set a, int size) {
    for(int i = 0; i < size; i++)
      a.add(new MyType(i));
    return a;
  }
  public static Set fill(Set a) {
    return fill(a, 10);
  }
  public static void test(Set a) {
    fill(a);
    fill(a); // Try to add duplicates
    fill(a);
    a.addAll(fill(new TreeSet()));
    System.out.println(a);
  }
  public static void main(String[] args) {
    test(new HashSet());
    test(new TreeSet());
  }
} ///:~

equals()hashCode()的定义遵照“groundhog”例子已经给出的形式。在两种情况下都必须定义一个equals()。但只有要把类置入一个HashSet的前提下,才有必要使用hashCode()——这种情况是完全有可能的,因为通常应先选择作为一个Set实现。

8.7.4 使用Maps

Map (interface)

Maintains key-value associations (pairs), so you can look up a value using a key.

HashMap*

Implementation based on a hash table. (Use this instead of Hashtable.) Provides constant-time performance for inserting and locating pairs. Performance can be adjusted via constructors that allow you to set the capacity and load factor of the hash table.

TreeMap

Implementation based on a red-black tree. When you view the keys or the pairs, they will be in sorted order (determined by Comparable or Comparator, discussed later). The point of a TreeMap is that you get the results in sorted order. TreeMap is the only Map with the subMap( ) method, which allows you to return a portion of the tree.

  • Map(接口) 维持“键-值”对应关系(对),以便通过一个键查找相应的值
  • HashMap* 基于一个散列表实现(用它代替Hashtable)。针对“键-值”对的插入和检索,这种形式具有最稳定的性能。可通过构造器对这一性能进行调整,以便设置散列表的“能力”和“装载因子”
  • ArrayMap 由一个ArrayList后推得到的Map。对迭代的顺序提供了精确的控制。面向非常小的Map设计,特别是那些需要经常创建和删除的。对于非常小的Map,创建和迭代所付出的代价要比HashMap低得多。但在Map变大以后,性能也会相应地大幅度降低
  • TreeMap 在一个“红-黑”树的基础上实现。查看键或者“键-值”对时,它们会按固定的顺序排列(取决于Comparable
  • Comparator,稍后即会讲到)。TreeMap最大的好处就是我们得到的是已排好序的结果。TreeMap是含有subMap()方法的唯一一种Map,利用它可以返回树的一部分

下例包含了两套测试数据以及一个fill()方法,利用该方法可以用任何两维数组(由Object构成)填充任何Map。这些工具也会在其他Map例子中用到。

//: Map1.java
// Things you can do with Maps
package c08.newcollections;
import java.util.*;

public class Map1 {
  public final static String[][] testData1 = {
    { "Happy", "Cheerful disposition" },
    { "Sleepy", "Prefers dark, quiet places" },
    { "Grumpy", "Needs to work on attitude" },
    { "Doc", "Fantasizes about advanced degree"},
    { "Dopey", "'A' for effort" },
    { "Sneezy", "Struggles with allergies" },
    { "Bashful", "Needs self-esteem workshop"},
  };
  public final static String[][] testData2 = {
    { "Belligerent", "Disruptive influence" },
    { "Lazy", "Motivational problems" },
    { "Comatose", "Excellent behavior" }
  };
  public static Map fill(Map m, Object[][] o) {
    for(int i = 0; i < o.length; i++)
      m.put(o[i][0], o[i][1]);
    return m;
  }
  // Producing a Set of the keys:
  public static void printKeys(Map m) {
    System.out.print("Size = " + m.size() +", ");
    System.out.print("Keys: ");
    Collection1.print(m.keySet());
  }
  // Producing a Collection of the values:
  public static void printValues(Map m) {
    System.out.print("Values: ");
    Collection1.print(m.values());
  }
  // Iterating through Map.Entry objects (pairs):
  public static void print(Map m) {
    Collection entries = m.entries();
    Iterator it = entries.iterator();
    while(it.hasNext()) {
      Map.Entry e = (Map.Entry)it.next();
      System.out.println("Key = " + e.getKey() +
        ", Value = " + e.getValue());
    }
  }
  public static void test(Map m) {
    fill(m, testData1);
    // Map has 'Set' behavior for keys:
    fill(m, testData1);
    printKeys(m);
    printValues(m);
    print(m);
    String key = testData1[4][0];
    String value = testData1[4][1];
    System.out.println("m.containsKey(\"" + key +
      "\"): " + m.containsKey(key));
    System.out.println("m.get(\"" + key + "\"): "
      + m.get(key));
    System.out.println("m.containsValue(\""
      + value + "\"): " +
      m.containsValue(value));
    Map m2 = fill(new TreeMap(), testData2);
    m.putAll(m2);
    printKeys(m);
    m.remove(testData2[0][0]);
    printKeys(m);
    m.clear();
    System.out.println("m.isEmpty(): "
      + m.isEmpty());
    fill(m, testData1);
    // Operations on the Set change the Map:
    m.keySet().removeAll(m.keySet());
    System.out.println("m.isEmpty(): "
      + m.isEmpty());
  }
  public static void main(String args[]) {
    System.out.println("Testing HashMap");
    test(new HashMap());
    System.out.println("Testing TreeMap");
    test(new TreeMap());
  }
} ///:~

printKeys()printValues()以及print()方法并不只是有用的工具,它们也清楚地揭示了一个MapCollection“景象”的产生过程。keySet()方法会产生一个Set,它由Map中的键后推得来。在这儿,它只被当作一个Collection对待。values()也得到了类似的对待,它的作用是产生一个List,其中包含了Map中的所有值(注意键必须是独一无二的,而值可以有重复)。由于这些Collection是由Map后推得到的,所以一个Collection中的任何改变都会在相应的Map中反映出来。

print()方法的作用是收集由entries产生的Iterator(迭代器),并用它同时打印出每个“键-值”对的键和值。程序剩余的部分提供了每种Map操作的简单示例,并对每种类型的Map进行了测试。

当创建自己的类,将其作为Map中的一个键使用时,必须注意到和以前的Set相同的问题。

8.7.5 决定实现方案

从早些时候的那幅示意图可以看出,实际上只有三个集合组件:MapListSet。而且每个接口只有两种或三种实现方案。若需使用由一个特定的接口提供的功能,如何才能决定到底采取哪一种方案呢?

为理解这个问题,必须认识到每种不同的实现方案都有自己的特点、优点和缺点。比如在那张示意图中,可以看到HashtableVectorStack的“特点”是它们都属于“传统”类,所以不会干扰原有的代码。但在另一方面,应尽量避免为新的(Java 1.2)代码使用它们。

其他集合间的差异通常都可归纳为它们具体是由什么“后推”的。换言之,取决于物理意义上用于实现目标接口的数据结构是什么。例如,ArrayListLinkedList以及Vector(大致等价于ArrayList)都实现了List接口,所以无论选用哪一个,我们的程序都会得到类似的结果。然而,ArrayList(以及Vector)是由一个数组后推得到的;而LinkedList是根据常规的双重链接列表方式实现的,因为每个单独的对象都包含了数据以及指向列表内前后元素的引用。正是由于这个原因,假如想在一个列表中部进行大量插入和删除操作,那么LinkedList无疑是最恰当的选择(LinkedList还有一些额外的功能,建立于AbstractSequentialList中)。若非如此,就情愿选择ArrayList,它的速度可能要快一些。

作为另一个例子,Set既可作为一个ArraySet实现,亦可作为HashSet实现。ArraySet是由一个ArrayList后推得到的,设计成只支持少量元素,特别适合要求创建和删除大量Set对象的场合使用。然而,一旦需要在自己的Set中容纳大量元素,ArraySet的性能就会大打折扣。写一个需要Set的程序时,应默认选择HashSet。而且只有在某些特殊情况下(对性能的提升有迫切的需求),才应切换到ArraySet

(1) 决定使用何种List

为体会各种List实现方案间的差异,最简便的方法就是进行一次性能测验。下述代码的作用是建立一个内部基类,将其作为一个测试床使用。然后为每次测验都创建一个匿名内部类。每个这样的内部类都由一个test()方法调用。利用这种方法,可以方便添加和删除测试项目。

//: ListPerformance.java
// Demonstrates performance differences in Lists
package c08.newcollections;
import java.util.*;

public class ListPerformance {
  private static final int REPS = 100;
  private abstract static class Tester {
    String name;
    int size; // Test quantity
    Tester(String name, int size) {
      this.name = name;
      this.size = size;
    }
    abstract void test(List a);
  }
  private static Tester[] tests = {
    new Tester("get", 300) {
      void test(List a) {
        for(int i = 0; i < REPS; i++) {
          for(int j = 0; j < a.size(); j++)
            a.get(j);
        }
      }
    },
    new Tester("iteration", 300) {
      void test(List a) {
        for(int i = 0; i < REPS; i++) {
          Iterator it = a.iterator();
          while(it.hasNext())
            it.next();
        }
      }
    },
    new Tester("insert", 1000) {
      void test(List a) {
        int half = a.size()/2;
        String s = "test";
        ListIterator it = a.listIterator(half);
        for(int i = 0; i < size * 10; i++)
          it.add(s);
      }
    },
    new Tester("remove", 5000) {
      void test(List a) {
        ListIterator it = a.listIterator(3);
        while(it.hasNext()) {
          it.next();
          it.remove();
        }
      }
    },
  };
  public static void test(List a) {
    // A trick to print out the class name:
    System.out.println("Testing " +
      a.getClass().getName());
    for(int i = 0; i < tests.length; i++) {
      Collection1.fill(a, tests[i].size);
      System.out.print(tests[i].name);
      long t1 = System.currentTimeMillis();
      tests[i].test(a);
      long t2 = System.currentTimeMillis();
      System.out.println(": " + (t2 - t1));
    }
  }
  public static void main(String[] args) {
    test(new ArrayList());
    test(new LinkedList());
  }
} ///:~

内部类Tester是一个抽象类,用于为特定的测试提供一个基类。它包含了一个要在测试开始时打印的字符串、一个用于计算测试次数或元素数量的size参数、用于初始化字段的一个构造器以及一个抽象方法test()test()做的是最实际的测试工作。各种类型的测试都集中到一个地方:tests数组。我们用继承于Tester的不同匿名内部类来初始化该数组。为添加或删除一个测试项目,只需在数组里简单地添加或移去一个内部类定义即可,其他所有工作都是自动进行的。

首先用元素填充传递给test()List,然后对tests数组中的测试计时。由于测试用机器的不同,结果当然也会有所区别。这个程序的宗旨是揭示出不同集合类型的相对性能比较。下面是某一次运行得到的结果:

类型 获取 迭代 插入 删除
ArrayList 110 270 1920 4780
LinkedList 1870 7580 170 110

可以看出,在ArrayList中进行随机访问(即get())以及循环迭代是最划得来的;但对于LinkedList却是一个不小的开销。但另一方面,在列表中部进行插入和删除操作对于LinkedList来说却比ArrayList划算得多。我们最好的做法也许是先选择一个ArrayList作为自己的默认起点。以后若发现由于大量的插入和删除造成了性能的降低,再考虑换成LinkedList不迟。

(2) 决定使用何种Set

可在ArraySet以及HashSet间作出选择,具体取决于Set的大小(如果需要从一个Set中获得一个顺序列表,请用TreeSet;注释⑧)。下面这个测试程序将有助于大家作出这方面的抉择:

//: SetPerformance.java
package c08.newcollections;
import java.util.*;

public class SetPerformance {
  private static final int REPS = 200;
  private abstract static class Tester {
    String name;
    Tester(String name) { this.name = name; }
    abstract void test(Set s, int size);
  }
  private static Tester[] tests = {
    new Tester("add") {
      void test(Set s, int size) {
        for(int i = 0; i < REPS; i++) {
          s.clear();
          Collection1.fill(s, size);
        }
      }
    },
    new Tester("contains") {
      void test(Set s, int size) {
        for(int i = 0; i < REPS; i++)
          for(int j = 0; j < size; j++)
            s.contains(Integer.toString(j));
      }
    },
    new Tester("iteration") {
      void test(Set s, int size) {
        for(int i = 0; i < REPS * 10; i++) {
          Iterator it = s.iterator();
          while(it.hasNext())
            it.next();
        }
      }
    },
  };
  public static void test(Set s, int size) {
    // A trick to print out the class name:
    System.out.println("Testing " +
      s.getClass().getName() + " size " + size);
    Collection1.fill(s, size);
    for(int i = 0; i < tests.length; i++) {
      System.out.print(tests[i].name);
      long t1 = System.currentTimeMillis();
      tests[i].test(s, size);
      long t2 = System.currentTimeMillis();
      System.out.println(": " +
        ((double)(t2 - t1)/(double)size));
    }
  }
  public static void main(String[] args) {
    // Small:
    test(new TreeSet(), 10);
    test(new HashSet(), 10);
    // Medium:
    test(new TreeSet(), 100);
    test(new HashSet(), 100);
    // Large:
    test(new HashSet(), 1000);
    test(new TreeSet(), 1000);
  }
} ///:~

⑧:TreeSet在本书写作时尚未成为一个正式的特性,但在这个例子中可以很轻松地为其添加一个测试。

最后对ArraySet的测试只有500个元素,而不是1000个,因为它太慢了。

类型 测试大小 添加 包含 迭代
TreeSet 10 22.0 11.0 16.0
100 22.5 13.2 12.1
1000 31.1 18.7 11.8
HashSet 10 5.0 6.0 27.0
100 6.6 6.6 10.9
1000 7.4 6.6 9.5

进行add()以及contains()操作时,HashSet显然要比ArraySet出色得多,而且性能明显与元素的多寡关系不大。一般编写程序的时候,几乎永远用不着使用ArraySet

(3) 决定使用何种Map

选择不同的Map实现方案时,注意Map的大小对于性能的影响是最大的,下面这个测试程序清楚地阐示了这一点:

//: MapPerformance.java
// Demonstrates performance differences in Maps
package c08.newcollections;
import java.util.*;

public class MapPerformance {
  private static final int REPS = 200;
  public static Map fill(Map m, int size) {
    for(int i = 0; i < size; i++) {
      String x = Integer.toString(i);
      m.put(x, x);
    }
    return m;
  }
  private abstract static class Tester {
    String name;
    Tester(String name) { this.name = name; }
    abstract void test(Map m, int size);
  }
  private static Tester[] tests = {
    new Tester("put") {
      void test(Map m, int size) {
        for(int i = 0; i < REPS; i++) {
          m.clear();
          fill(m, size);
        }
      }
    },
    new Tester("get") {
      void test(Map m, int size) {
        for(int i = 0; i < REPS; i++)
          for(int j = 0; j < size; j++)
            m.get(Integer.toString(j));
      }
    },
    new Tester("iteration") {
      void test(Map m, int size) {
        for(int i = 0; i < REPS * 10; i++) {
          Iterator it = m.entries().iterator();
          while(it.hasNext())
            it.next();
        }
      }
    },
  };
  public static void test(Map m, int size) {
    // A trick to print out the class name:
    System.out.println("Testing " +
      m.getClass().getName() + " size " + size);
    fill(m, size);
    for(int i = 0; i < tests.length; i++) {
      System.out.print(tests[i].name);
      long t1 = System.currentTimeMillis();
      tests[i].test(m, size);
      long t2 = System.currentTimeMillis();
      System.out.println(": " +
        ((double)(t2 - t1)/(double)size));
    }
  }
  public static void main(String[] args) {
    // Small:
    test(new Hashtable(), 10);
    test(new HashMap(), 10);
    test(new TreeMap(), 10);
    // Medium:
    test(new Hashtable(), 100);
    test(new HashMap(), 100);
    test(new TreeMap(), 100);
    // Large:
    test(new HashMap(), 1000);
    test(new Hashtable(), 1000);
    test(new TreeMap(), 1000);
  }
} ///:~

由于Map的大小是最严重的问题,所以程序的计时测试按Map的大小(或容量)来分割时间,以便得到令人信服的测试结果。下面列出一系列结果(在你的机器上可能不同):

类型 测试大小 置入 取出 迭代
Hashtable 10 11.0 5.0 44.0
100 7.7 7.7 16.5
1000 8.0 8.0 14.4
TreeMap 10 16.0 11.0 22.0
100 25.8 15.4 13.2
1000 33.8 20.9 13.6
HashMap 10 11.0 6.0 33.0
100 8.2 7.7 13.7
1000 8.0 7.8 11.9

即使大小为10,ArrayMap的性能也要比HashMap差——除迭代循环时以外。而在使用Map时,迭代的作用通常并不重要(get()通常是我们时间花得最多的地方)。TreeMap提供了出色的put()以及迭代时间,但get()的性能并不佳。但是,我们为什么仍然需要使用TreeMap呢?这样一来,我们可以不把它作为Map使用,而作为创建顺序列表的一种途径。树的本质在于它总是顺序排列的,不必特别进行排序(它的排序方式马上就要讲到)。一旦填充了一个TreeMap,就可以调用keySet()来获得键的一个Set“景象”。然后用toArray()产生包含了那些键的一个数组。随后,可用static方法Array.binarySearch()快速查找排好序的数组中的内容。当然,也许只有在HashMap的行为不可接受的时候,才需要采用这种做法。因为HashMap的设计宗旨就是进行快速的检索操作。最后,当我们使用Map时,首要的选择应该是HashMap。只有在极少数情况下才需要考虑其他方法。

此外,在上面那张表里,有另一个性能问题没有反映出来。下述程序用于测试不同类型Map的创建速度:

//: MapCreation.java
// Demonstrates time differences in Map creation
package c08.newcollections;
import java.util.*;

public class MapCreation {
  public static void main(String[] args) {
    final long REPS = 100000;
    long t1 = System.currentTimeMillis();
    System.out.print("Hashtable");
    for(long i = 0; i < REPS; i++)
      new Hashtable();
    long t2 = System.currentTimeMillis();
    System.out.println(": " + (t2 - t1));
    t1 = System.currentTimeMillis();
    System.out.print("TreeMap");
    for(long i = 0; i < REPS; i++)
      new TreeMap();
    t2 = System.currentTimeMillis();
    System.out.println(": " + (t2 - t1));
    t1 = System.currentTimeMillis();
    System.out.print("HashMap");
    for(long i = 0; i < REPS; i++)
      new HashMap();
    t2 = System.currentTimeMillis();
    System.out.println(": " + (t2 - t1));
  }
} ///:~

在写这个程序期间,TreeMap的创建速度比其他两种类型明显快得多(但你应亲自尝试一下,因为据说新版本可能会改善ArrayMap的性能)。考虑到这方面的原因,同时由于前述TreeMap出色的put()性能,所以如果需要创建大量Map,而且只有在以后才需要涉及大量检索操作,那么最佳的策略就是:创建和填充TreeMap;以后检索量增大的时候,再将重要的TreeMap转换成HashMap——使用HashMap(Map)构造器。同样地,只有在事实证明确实存在性能瓶颈后,才应关心这些方面的问题——先用起来,再根据需要加快速度。

8.7.6 未支持的操作

利用static(静态)数组Arrays.toList(),也许能将一个数组转换成List,如下所示:

//: Unsupported.java
// Sometimes methods defined in the Collection
// interfaces don't work!
package c08.newcollections;
import java.util.*;

public class Unsupported {
  private static String[] s = {
    "one", "two", "three", "four", "five",
    "six", "seven", "eight", "nine", "ten",
  };
  static List a = Arrays.toList(s);
  static List a2 = Arrays.toList(
    new String[] { s[3], s[4], s[5] });
  public static void main(String[] args) {
    Collection1.print(a); // Iteration
    System.out.println(
      "a.contains(" + s[0] + ") = " +
      a.contains(s[0]));
    System.out.println(
      "a.containsAll(a2) = " +
      a.containsAll(a2));
    System.out.println("a.isEmpty() = " +
      a.isEmpty());
    System.out.println(
      "a.indexOf(" + s[5] + ") = " +
      a.indexOf(s[5]));
    // Traverse backwards:
    ListIterator lit = a.listIterator(a.size());
    while(lit.hasPrevious())
      System.out.print(lit.previous());
    System.out.println();
    // Set the elements to different values:
    for(int i = 0; i < a.size(); i++)
      a.set(i, "47");
    Collection1.print(a);
    // Compiles, but won't run:
    lit.add("X"); // Unsupported operation
    a.clear(); // Unsupported
    a.add("eleven"); // Unsupported
    a.addAll(a2); // Unsupported
    a.retainAll(a2); // Unsupported
    a.remove(s[0]); // Unsupported
    a.removeAll(a2); // Unsupported
  }
} ///:~

从中可以看出,实际只实现了CollectionList接口的一部分。剩余的方法导致了不受欢迎的一种情况,名为UnsupportedOperationException。在下一章里,我们会讲述异常的详细情况,但在这里有必要进行一下简单说明。这里的关键在于“集合接口”,以及新集合库内的另一些接口,它们都包含了“可选的”方法。在实现那些接口的集合类中,或者提供、或者没有提供对那些方法的支持。若调用一个未获支持的方法,就会导致一个UnsupportedOperationException(操作未支持异常),这表明出现了一个编程错误。

大家或许会觉得奇怪,不是说“接口”和基类最大的“卖点”就是它们许诺这些方法能产生一些有意义的行为吗?上述异常破坏了那个许诺——它调用的一部分方法不仅不能产生有意义的行为,而且还会中止程序的运行。在这些情况下,类型的所谓安全保证似乎显得一钱不值!但是,情况并没有想象的那么坏。通过CollectionListSet或者Map,编译器仍然限制我们只能调用那个接口中的方法,所以它和Smalltalk还是存在一些区别的(在Smalltalk中,可为任何对象调用任何方法,而且只有在运行程序时才知道这些调用是否可行)。除此以外,以Collection作为参数的大多数方法只能从那个集合中读取数据——Collection的所有read方法都不是可选的。

这样一来,系统就可避免在设计期间出现接口的冲突。而在集合库的其他设计模式中,最终经常都会得到数量过多的接口,用它们描述基本方案的每一种变化形式,所以学习和掌握显得非常困难。有些时候,甚至难于捕捉接口中的所有特殊情况,因为人们可能设计出任何新接口。但Java的“不支持的操作”方法却达到了新集合库的一个重要设计目标:易于学习和使用。但是,为了使这一方法真正有效,却需满足下述条件:

(1) UnsupportedOperationException必须属于一种“非常”事件。也就是说,对于大多数类来说,所有操作都应是可行的。只有在一些特殊情况下,一、两个操作才可能未获支持。新集合库满足了这一条件,因为绝大多数时候用到的类——ArrayListLinkedListHashListHashMap,以及其他集合方案——都提供了对所有操作的支持。但是,如果想新建一个集合,同时不想为集合接口中的所有方法都提供有意义的定义,同时令其仍与现有库配合,这种设计方法也确实提供了一个“后门”可以利用。

(2) 若一个操作未获支持,那么UnsupportedOperationException(未支持的操作异常)极有可能在实现期间出现,则不是在产品已交付给客户以后才会出现。它毕竟指出的是一个编程错误——不正确地使用了一个类。这一点不能十分确定,通过也可以看出这种方案的“试验”特征——只有经过多次试验,才能找出最理想的工作方式。

在上面的例子中,Arrays.toList()产生了一个List(列表),该列表是由一个固定长度的数组后推出来的。因此唯一能够支持的就是那些不改变数组长度的操作。在另一方面,若请求一个新接口表达不同种类的行为(可能叫作FixedSizeList——固定长度列表),就有遭遇更大的复杂程度的危险。这样一来,以后试图使用库的时候,很快就会发现自己不知从何处下手。

对那些采用CollectionListSet或者Map作为参数的方法,它们的文档应当指出哪些可选的方法是必须实现的。举个例子来说,排序要求实现set()Iterator.set()方法,但不包括add()remove()

8.7.7 排序和搜索

Java 1.2添加了自己的一套实用工具,可用来对数组或列表进行排列和搜索。这些工具都属于两个新类的“静态”方法。这两个类分别是用于排序和搜索数组的Arrays,以及用于排序和搜索列表的Collections

(1) 数组

Arrays类为所有基本数据类型的数组提供了一个重载的sort()binarySearch(),它们亦可用于StringObject。下面这个例子显示出如何排序和搜索一个字节数组(其他所有基本数据类型都是类似的)以及一个String数组:

//: Array1.java
// Testing the sorting & searching in Arrays
package c08.newcollections;
import java.util.*;

public class Array1 {
  static Random r = new Random();
  static String ssource =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
    "abcdefghijklmnopqrstuvwxyz";
  static char[] src = ssource.toCharArray();
  // Create a random String
  public static String randString(int length) {
    char[] buf = new char[length];
    int rnd;
    for(int i = 0; i < length; i++) {
      rnd = Math.abs(r.nextInt()) % src.length;
      buf[i] = src[rnd];
    }
    return new String(buf);
  }
  // Create a random array of Strings:
  public static
  String[] randStrings(int length, int size) {
    String[] s = new String[size];
    for(int i = 0; i < size; i++)
      s[i] = randString(length);
    return s;
  }
  public static void print(byte[] b) {
    for(int i = 0; i < b.length; i++)
      System.out.print(b[i] + " ");
    System.out.println();
  }
  public static void print(String[] s) {
    for(int i = 0; i < s.length; i++)
      System.out.print(s[i] + " ");
    System.out.println();
  }
  public static void main(String[] args) {
    byte[] b = new byte[15];
    r.nextBytes(b); // Fill with random bytes
    print(b);
    Arrays.sort(b);
    print(b);
    int loc = Arrays.binarySearch(b, b[10]);
    System.out.println("Location of " + b[10] +
      " = " + loc);
    // Test String sort & search:
    String[] s = randStrings(4, 10);
    print(s);
    Arrays.sort(s);
    print(s);
    loc = Arrays.binarySearch(s, s[4]);
    System.out.println("Location of " + s[4] +
      " = " + loc);
  }
} ///:~

类的第一部分包含了用于产生随机字符串对象的实用工具,可供选择的随机字母保存在一个字符数组中。randString()返回一个任意长度的字符串;而readStrings()创建随机字符串的一个数组,同时给定每个字符串的长度以及希望的数组大小。两个print()方法简化了对示范数组的显示。在main()中,Random.nextBytes()用随机选择的字节填充数组参数(没有对应的Random方法用于创建其他基本数据类型的数组)。获得一个数组后,便可发现为了执行sort()或者binarySearch(),只需发出一次方法调用即可。与binarySearch()有关的还有一个重要的警告:若在执行一次binarySearch()之前不调用sort(),便会发生不可预测的行为,其中甚至包括无限循环。

String的排序以及搜索是相似的,但在运行程序的时候,我们会注意到一个有趣的现象:排序遵守的是字典顺序,亦即大写字母在字符集中位于小写字母的前面。因此,所有大写字母都位于列表的最前面,后面再跟上小写字母——Z居然位于a的前面。似乎连电话簿也是这样排序的。

(2) 可比较与比较器

但假若我们不满足这一排序方式,又该如何处理呢?例如本书后面的索引,如果必须对以Aa开头的词条分别到两处地方查看,那么肯定会使读者颇不耐烦。

若想对一个Object数组进行排序,那么必须解决一个问题。根据什么来判定两个Object的顺序呢?不幸的是,最初的Java设计者并不认为这是一个重要的问题,否则就已经在根类Object里定义它了。这样造成的一个后果便是:必须从外部进行Object的排序,而且新的集合库提供了实现这一操作的标准方式(最理想的是在Object里定义它)。

针对Object数组(以及String,它当然属于Object的一种),可使用一个sort(),并令其接纳另一个参数:实现了Comparator接口(即“比较器”接口,新集合库的一部分)的一个对象,并用它的单个compare()方法进行比较。这个方法将两个准备比较的对象作为自己的参数使用——若第一个参数小于第二个,返回一个负整数;若相等,返回零;若第一个参数大于第二个,则返回正整数。基于这一规则,上述例子的String部分便可重新写过,令其进行真正按字母顺序的排序:

//: AlphaComp.java
// Using Comparator to perform an alphabetic sort
package c08.newcollections;
import java.util.*;

public class AlphaComp implements Comparator {
  public int compare(Object o1, Object o2) {
    // Assume it's used only for Strings...
    String s1 = ((String)o1).toLowerCase();
    String s2 = ((String)o2).toLowerCase();
    return s1.compareTo(s2);
  }
  public static void main(String[] args) {
    String[] s = Array1.randStrings(4, 10);
    Array1.print(s);
    AlphaComp ac = new AlphaComp();
    Arrays.sort(s, ac);
    Array1.print(s);
    // Must use the Comparator to search, also:
    int loc = Arrays.binarySearch(s, s[3], ac);
    System.out.println("Location of " + s[3] +
     " = " + loc);
  }
} ///:~

通过转换为Stringcompare()方法会进行“暗示”性的测试,保证自己操作的只能是String对象——运行期系统会捕获任何差错。将两个字符串都强迫换成小写形式后,String.compareTo()方法会产生预期的结果。

若用自己的Comparator来进行一次sort(),那么在使用binarySearch()时必须使用那个相同的Comparator

Arrays类提供了另一个sort()方法,它会采用单个参数:一个Object数组,但没有Comparator。这个sort()方法也必须用同样的方式来比较两个Object。通过实现Comparable接口,它采用了赋予一个类的“自然比较方法”。这个接口含有单独一个方法——compareTo(),能分别根据它小于、等于或者大于参数而返回负数、零或者正数,从而实现对象的比较。下面这个例子简单地阐示了这一点:

//: CompClass.java
// A class that implements Comparable
package c08.newcollections;
import java.util.*;

public class CompClass implements Comparable {
  private int i;
  public CompClass(int ii) { i = ii; }
  public int compareTo(Object o) {
    // Implicitly tests for correct type:
    int argi = ((CompClass)o).i;
    if(i == argi) return 0;
    if(i < argi) return -1;
    return 1;
  }
  public static void print(Object[] a) {
    for(int i = 0; i < a.length; i++)
      System.out.print(a[i] + " ");
    System.out.println();
  }
  public String toString() { return i + ""; }
  public static void main(String[] args) {
    CompClass[] a = new CompClass[20];
    for(int i = 0; i < a.length; i++)
      a[i] = new CompClass(
        (int)(Math.random() *100));
    print(a);
    Arrays.sort(a);
    print(a);
    int loc = Arrays.binarySearch(a, a[3]);
    System.out.println("Location of " + a[3] +
     " = " + loc);
  }
} ///:~

当然,我们的compareTo()方法亦可根据实际情况增大复杂程度。

(3) 列表

可用与数组相同的形式排序和搜索一个列表(List)。用于排序和搜索列表的静态方法包含在类Collections中,但它们拥有与Arrays中差不多的签名:sort(List)用于对一个实现了Comparable的对象列表进行排序;binarySearch(List,Object)用于查找列表中的某个对象;sort(List,Comparator)利用一个“比较器”对一个列表进行排序;

binarySearch(List,Object,Comparator)则用于查找那个列表中的一个对象(注释⑨)。下面这个例子利用了预先定义好的CompClassAlphaComp来示范Collections中的各种排序工具:

//: ListSort.java
// Sorting and searching Lists with 'Collections'
package c08.newcollections;
import java.util.*;

public class ListSort {
  public static void main(String[] args) {
    final int SZ = 20;
    // Using "natural comparison method":
    List a = new ArrayList();
    for(int i = 0; i < SZ; i++)
      a.add(new CompClass(
        (int)(Math.random() *100)));
    Collection1.print(a);
    Collections.sort(a);
    Collection1.print(a);
    Object find = a.get(SZ/2);
    int loc = Collections.binarySearch(a, find);
    System.out.println("Location of " + find +
     " = " + loc);
    // Using a Comparator:
    List b = new ArrayList();
    for(int i = 0; i < SZ; i++)
      b.add(Array1.randString(4));
    Collection1.print(b);
    AlphaComp ac = new AlphaComp();
    Collections.sort(b, ac);
    Collection1.print(b);
    find = b.get(SZ/2);
    // Must use the Comparator to search, also:
    loc = Collections.binarySearch(b, find, ac);
    System.out.println("Location of " + find +
     " = " + loc);
  }
} ///:~

⑨:在本书写作时,已宣布了一个新的Collections.stableSort(),可用它进行合并式排序,但还没有它的测试版问世。

这些方法的用法与在Arrays中的用法是完全一致的,只是用一个列表代替了数组。

TreeMap也必须根据Comparable或者Comparator对自己的对象进行排序。

8.7.8 实用工具

Collections类中含有其他大量有用的实用工具:

enumeration(Collection)

Produces an old-style Enumeration for the argument.

max(Collection)

min(Collection)

Produces the maximum or minimum element in the argument using the natural comparison method of the objects in the Collection.

max(Collection, Comparator)

min(Collection, Comparator)

Produces the maximum or minimum element in the Collection using the Comparator.

nCopies(int n, Object o)

Returns an immutable List of size n whose handles all point to o.

subList(List, int min, int max)

Returns a new List backed by the specified argument List that is a window into that argument with indexes starting at min and stopping just before max.
  • enumeration(Collection) 为参数产生原始风格的Enumeration(枚举)

  • max(Collection)min(Collection) 在参数中用集合内对象的自然比较方法产生最大或最小元素

  • max(Collection,Comparator)min(Collection,Comparator) 在集合内用比较器产生最大或最小元素

  • nCopies(int n, Object o) 返回长度为n的一个不可变列表,它的所有引用均指向o

  • subList(List,int min,int max) 返回由指定参数列表后推得到的一个新列表。可将这个列表想象成一个“窗口”,它自索引为min的地方开始,正好结束于max的前面

注意min()max()都是随同Collection对象工作的,而非随同List,所以不必担心Collection是否需要排序(就象早先指出的那样,在执行一次binarySearch()——即二进制搜索——之前,必须对一个List或者一个数组执行sort())。

(1) 使CollectionMap不可修改

通常,创建CollectionMap的一个“只读”版本显得更有利一些。Collections类允许我们达到这个目标,方法是将原始容器传递进入一个方法,并令其传回一个只读版本。这个方法共有四种变化形式,分别用于Collection(如果不想把集合当作一种更特殊的类型对待)、ListSet以及Map。下面这个例子演示了为它们分别构建只读版本的正确方法:

//: ReadOnly.java
// Using the Collections.unmodifiable methods
package c08.newcollections;
import java.util.*;

public class ReadOnly {
  public static void main(String[] args) {
    Collection c = new ArrayList();
    Collection1.fill(c); // Insert useful data
    c = Collections.unmodifiableCollection(c);
    Collection1.print(c); // Reading is OK
    //! c.add("one"); // Can't change it

    List a = new ArrayList();
    Collection1.fill(a);
    a = Collections.unmodifiableList(a);
    ListIterator lit = a.listIterator();
    System.out.println(lit.next()); // Reading OK
    //! lit.add("one"); // Can't change it

    Set s = new HashSet();
    Collection1.fill(s);
    s = Collections.unmodifiableSet(s);
    Collection1.print(s); // Reading OK
    //! s.add("one"); // Can't change it

    Map m = new HashMap();
    Map1.fill(m, Map1.testData1);
    m = Collections.unmodifiableMap(m);
    Map1.print(m); // Reading OK
    //! m.put("Ralph", "Howdy!");
  }
} ///:~

对于每种情况,在将其正式变为只读以前,都必须用有有效的数据填充容器。一旦载入成功,最佳的做法就是用“不可修改”调用产生的引用替换现有的引用。这样做可有效避免将其变成不可修改后不慎改变其中的内容。在另一方面,该工具也允许我们在一个类中将能够修改的容器保持为private状态,并可从一个方法调用中返回指向那个容器的一个只读引用。这样一来,虽然我们可在类里修改它,但其他任何人都只能读。

为特定类型调用“不可修改”的方法不会造成编译期间的检查,但一旦发生任何变化,对修改特定容器的方法的调用便会产生一个UnsupportedOperationException异常。

(2) CollectionMap的同步

synchronized关键字是“多线程”机制一个非常重要的部分。我们到第14章才会对这一机制作深入的探讨。在这儿,大家只需注意到Collections类提供了对整个容器进行自动同步的一种途径。它的语法与“不可修改”的方法是类似的:

//: Synchronization.java
// Using the Collections.synchronized methods
package c08.newcollections;
import java.util.*;

public class Synchronization {
  public static void main(String[] args) {
    Collection c =
      Collections.synchronizedCollection(
        new ArrayList());
    List list = Collections.synchronizedList(
      new ArrayList());
    Set s = Collections.synchronizedSet(
      new HashSet());
    Map m = Collections.synchronizedMap(
      new HashMap());
  }
} ///:~

在这种情况下,我们通过适当的“同步”方法直接传递新容器;这样做可避免不慎暴露出未同步的版本。

新集合也提供了能防止多个进程同时修改一个容器内容的机制。若在一个容器里迭代,同时另一些进程介入,并在那个容器中插入、删除或修改一个对象,便会面临发生冲突的危险。我们可能已传递了那个对象,可能它位位于我们前面,可能容器的大小在我们调用size()后已发生了收缩——我们面临各种各样可能的危险。针对这个问题,新的集合库集成了一套解决的机制,能查出除我们的进程自己需要负责的之外的、对容器的其他任何修改。若探测到有其他方面也准备修改容器,便会立即产生一个ConcurrentModificationException(并发修改异常)。我们将这一机制称为“立即失败”——它并不用更复杂的算法在“以后”侦测问题,而是“立即”产生异常。

8.8 总结

下面复习一下由标准Java(1.0和1.1)库提供的集合(BitSet未包括在这里,因为它更象一种负有特殊使命的类):

(1) 数组包含了对象的数字化索引。它容纳的是一种已知类型的对象,所以在查找一个对象时,不必对结果进行转换处理。数组可以是多维的,而且能够容纳基本数据类型。但是,一旦把它创建好以后,大小便不能变化了。

(2) Vector(向量)也包含了对象的数字索引——可将数组和Vector想象成随机访问集合。当我们加入更多的元素时,Vector能够自动改变自身的大小。但Vector只能容纳对象的引用,所以它不可包含基本数据类型;而且将一个对象引用从集合中取出来的时候,必须对结果进行转换处理。

(3) Hashtable(散列表)属于Dictionary(字典)的一种类型,是一种将对象(而不是数字)同其他对象关联到一起的方式。散列表也支持对对象的随机访问,事实上,它的整个设计模式都在突出访问的“高速度”。

(4) Stack(栈)是一种“后入先出”(LIFO)的队列。

若你曾经熟悉数据结构,可能会疑惑为何没看到一套更大的集合。从功能的角度出发,你真的需要一套更大的集合吗?对于Hashtable,可将任何东西置入其中,并以非常快的速度检索;对于Enumeration(枚举),可遍历一个序列,并对其中的每个元素都采取一个特定的操作。那是一种功能足够强劲的工具。

Hashtable没有“顺序”的概念。Vector和数组为我们提供了一种线性顺序,但若要把一个元素插入它们任何一个的中部,一般都要付出“惨重”的代价。除此以外,队列、拆散队列、优先级队列以及树都涉及到元素的“排序”——并非仅仅将它们置入,以便以后能按线性顺序查找或移动它们。这些数据结构也非常有用,这也正是标准C++中包含了它们的原因。考虑到这个原因,只应将标准Java库的集合看作自己的一个起点。而且倘若必须使用Java 1.0或1.1,则可在需要超越它们的时候使用JGL。

如果能使用Java 1.2,那么只使用新集合即可,它一般能满足我们的所有需要。注意本书在Java 1.1身上花了大量篇幅,所以书中用到的大量集合都是只能在Java1.1中用到的那些:VectorHashtable。就目前来看,这是一个不得以而为之的做法。但是,这样处理亦可提供与老Java代码更出色的向后兼容能力。若要用Java1.2写新代码,新的集合往往能更好地为你服务。

8.9 练习

(1) 新建一个名为Gerbil的类,在构造器中初始化一个int gerbilNumber(类似本章的Mouse例子)。为其写一个名为hop()的方法,用它打印出符合hop()条件的Gerbil的编号。建一个Vector,并为Vector添加一系列Gerbil对象。现在,用elementAt()方法在Vector中遍历,并为每个Gerbil都调用hop()

(2) 修改练习1,用Enumeration在调用hop()的同时遍历Vector

(3) 在AssocArray.java中,修改这个例子,令其使用一个Hashtable,而不是AssocArray

(4) 获取练习1用到的Gerbil类,改为把它置入一个Hashtable,然后将Gerbil的名称作为一个String(键)与置入表格的每个Gerbil(值)都关联起来。获得用于keys()的一个Enumeration,并用它在Hashtable里遍历,查找每个键的Gerbil,打印出键,然后将gerbil告诉给hop()

(5) 修改第7章的练习1,用一个Vector容纳Rodent(啮齿动物),并用EnumerationRodent序列中遍历。记住Vector只能容纳对象,所以在访问单独的Rodent时必须采用一个转换(如RTTI)。

(6) 转到第7章的中间位置,找到那个GreenhouseControls.java(温室控制)例子,该例应该由三个文件构成。在Controller.java中,类EventSet仅是一个集合。修改它的代码,用一个Stack代替EventSet。当然,这时可能并不仅仅用Stack取代EventSet这样简单;也需要用一个Enumeration遍历事件集。可考虑在某些时候将集合当作Stack对待,另一些时候则当作Vector对待——这样或许能使事情变得更加简单。

(7) (有一定挑战性)在与所有Java发行包配套提供的Java源码库中找出用于Vector的源码。复制这些代码,制作名为
intVector的一个特殊版本,只在其中包含int数据。思考是否能为所有基本数据类型都制作Vector的一个特殊版本。接下来,考虑假如制作一个链接列表类,令其能随同所有基本数据类型使用,那么会发生什么情况。若在Java中提供了参数化类型,利用它们便可自动完成这一工作(还有其他许多好处)。

第8章 对象的容纳

“如果一个程序只含有数量固定的对象,而且已知它们的存在时间,那么这个程序可以说是相当简单的。”

通常,我们的程序需要根据程序运行时才知道的一些标准创建新对象。若非程序正式运行,否则我们根本不知道自己到底需要多少数量的对象,甚至不知道它们的准确类型。为了满足常规编程的需要,我们要求能在任何时候、任何地点创建任意数量的对象。所以不可依赖一个已命名的引用来容纳自己的每一个对象,就象下面这样:

MyObject myHandle;

因为根本不知道自己实际需要多少这样的东西。

为解决这个非常关键的问题,Java提供了容纳对象(或者对象的引用)的多种方式。其中内建的类型是数组,我们之前已讨论过它,本章准备加深大家对它的认识。此外,Java的工具(实用程序)库提供了一些“集合类”(亦称作“容器类”,但该术语已由AWT使用,所以这里仍采用“集合”这一称呼)。利用这些集合类,我们可以容纳乃至操纵自己的对象。本章的剩余部分会就此进行详细讨论。

9.1 基本异常

“异常条件”表示在出现什么问题的时候应中止方法或作用域的继续。为了将异常条件与普通问题区分开,异常条件是非常重要的一个因素。在普通问题的情况下,我们在当地已拥有足够的信息,可在某种程度上解决碰到的问题。而在异常条件的情况下,却无法继续下去,因为当地没有提供解决问题所需的足够多的信息。此时,我们能做的唯一事情就是跳出当地环境,将那个问题委托给一个更高级的负责人。这便是出现异常时出现的情况。

一个简单的例子是“除法”。如可能被零除,就有必要进行检查,确保程序不会冒进,并在那种情况下执行除法。但具体通过什么知道分母是零呢?在那个特定的方法里,在我们试图解决的那个问题的环境中,我们或许知道该如何对待一个零分母。但假如它是一个没有预料到的值,就不能对其进行处理,所以必须产生一个异常,而非不顾一切地继续执行下去。

产生一个异常时,会发生几件事情。首先,按照与创建Java对象一样的方法创建异常对象:在内存“堆”里,使用new来创建。随后,停止当前执行路径(记住不可沿这条路径继续下去),然后从当前的环境中释放出异常对象的引用。此时,异常控制机制会接管一切,并开始查找一个恰当的地方,用于继续程序的执行。这个恰当的地方便是“异常控制器”,它的职责是从问题中恢复,使程序要么尝试另一条执行路径,要么简单地继续。

作为产生异常的一个简单示例,大家可思考一个名为t的对象引用。有些时候,程序可能传递一个尚未初始化的引用。所以在用那个对象引用调用一个方法之前,最好进行一番检查。可将与错误有关的信息发送到一个更大的场景中,方法是创建一个特殊的对象,用它代表我们的信息,并将其“抛”(Throw)出我们当前的场景之外。这就叫作“产生一个异常”或者“抛出一个异常”。下面是它的大概形式:

if(t == null)
throw new NullPointerException();

这样便“抛”出了一个异常。在当前场景中,它使我们能放弃进一步解决该问题的企图。该问题会被转移到其他更恰当的地方解决。准确地说,那个地方不久就会显露出来。

9.1.1 异常参数

和Java的其他任何对象一样,需要用new在内存堆里创建异常,并需调用一个构造器。在所有标准异常中,存在着两个构造器:第一个是默认构造器,第二个则需使用一个字符串参数,使我们能在异常里置入相关信息:

if(t == null)
throw new NullPointerException("t = null");

稍后,字符串可用各种方法提取出来,就象稍后会展示的那样。

在这儿,关键字throw会象变戏法一样做出一系列不可思议的事情。它首先执行new表达式,创建一个不在程序常规执行范围之内的对象。而且理所当然,会为那个对象调用构造器。随后,对象实际会从方法中返回——尽管对象的类型通常并不是方法设计为返回的类型。为深入理解异常控制,可将其想象成另一种返回机制——但是不要在这个问题上深究,否则会遇到麻烦。通过“抛”出一个异常,亦可从原来的作用域中退出。但是会先返回一个值,再退出方法或作用域。

但是,与普通方法返回的相似性到此便全部结束了,因为我们返回的地方与从普通方法调用中返回的地方是迥然有异的(我们结束于一个恰当的异常控制器,它距离异常“抛”出的地方可能相当遥远——在调用栈中要低上许多级)。

此外,我们可根据需要抛出任何类型的“可抛”对象。典型情况下,我们要为每种不同类型的错误“抛”出一类不同的异常。我们的思路是在异常对象以及挑选的异常对象类型中保存信息,所以在更大场景中的某个人可知道如何对待我们的异常(通常,唯一的信息是异常对象的类型,而异常对象中保存的没什么意义)。

9.10 练习

(1) 用main()创建一个类,令其抛出try块内的Exception类的一个对象。为Exception的构造器赋予一个字符串参数。在catch从句内捕获异常,并打印出字符串参数。添加一个finally从句,并打印一条消息,证明自己真正到达那里。

(2) 用extends关键字创建自己的异常类。为这个类写一个构造器,令其采用String参数,并随同String引用把它保存到对象内。写一个方法,令其打印出保存下来的String。创建一个try-catch从句,练习实际操作新异常。

(3) 写一个类,并令一个方法抛出在练习2中创建的类型的一个异常。试着在没有异常规范的前提下编译它,观察编译器会报告什么。接着添加适当的异常规范。在一个try-catch从句中尝试自己的类以及它的异常。

(4) 在第5章,找到调用了Assert.java的两个程序,并修改它们,令其抛出自己的异常类型,而不是打印到System.err。该异常应是扩展了RuntimeException的一个内部类。

9.2 异常的捕获

若某个方法产生一个异常,必须保证该异常能被捕获,并获得正确对待。对于Java的异常控制机制,它的一个好处就是允许我们在一个地方将精力集中在要解决的问题上,然后在另一个地方对待来自那个代码内部的错误。

为理解异常是如何捕获的,首先必须掌握“警戒区”的概念。它代表一个特殊的代码区域,有可能产生异常,并在后面跟随用于控制那些异常的代码。

9.2.1 try

若位于一个方法内部,并“抛”出一个异常(或在这个方法内部调用的另一个方法产生了异常),那个方法就会在异常产生过程中退出。若不想一个throw离开方法,可在那个方法内部设置一个特殊的代码块,用它捕获异常。这就叫作“try块”,因为要在这个地方“尝试”各种方法调用。try块属于一种普通的作用域,用一个try关键字开头:

try {
// 可能产生异常的代码
}

若用一种不支持异常控制的编程语言全面检查错误,必须用设置和错误检测代码将每个方法都包围起来——即便多次调用相同的方法。而在使用了异常控制技术后,可将所有东西都置入一个try块内,在同一地点捕获所有异常。这样便可极大简化我们的代码,并使其更易辨读,因为代码本身要达到的目标再也不会与繁复的错误检查混淆。

9.2.2 异常控制器

当然,生成的异常必须在某个地方中止。这个“地方”便是异常控制器或者异常控制模块。而且针对想捕获的每种异常类型,都必须有一个相应的异常控制器。异常控制器紧接在try块后面,且用catch(捕获)关键字标记。如下所示:

try {
  // Code that might generate exceptions
} catch(Type1 id1) {
  // Handle exceptions of Type1
} catch(Type2 id2) {
  // Handle exceptions of Type2
} catch(Type3 id3) {
  // Handle exceptions of Type3
}

// etc...

每个catch从句——即异常控制器——都类似一个小型方法,它需要采用一个(而且只有一个)特定类型的参数。可在控制器内部使用标识符(id1id2等等),就象一个普通的方法参数那样。我们有时也根本不使用标识符,因为异常类型已提供了足够的信息,可有效处理异常。但即使不用,标识符也必须就位。

控制器必须“紧接”在try块后面。若“抛”出一个异常,异常控制机制就会搜寻参数与异常类型相符的第一个控制器。随后,它会进入那个catch从句,并认为异常已得到控制(一旦catch从句结束,对控制器的搜索也会停止)。只有相符的catch从句才会得到执行;它与switch语句不同,后者在每个case后都需要一个break命令,防止误执行其他语句。
try块内部,请注意大量不同的方法调用可能生成相同的异常,但只需要一个控制器。

(1) 中断与恢复

在异常控制理论中,共存在两种基本方法。在“中断”方法中(Java和C++提供了对这种方法的支持),我们假定错误非常关键,没有办法返回异常发生的地方。无论谁只要“抛”出一个异常,就表明没有办法补救错误,而且也不希望再回来。

另一种方法叫作“恢复”。它意味着异常控制器有责任来纠正当前的状况,然后取得出错的方法,假定下一次会成功执行。若使用恢复,意味着在异常得到控制以后仍然想继续执行。在这种情况下,我们的异常更象一个方法调用——我们用它在Java中设置各种各样特殊的环境,产生类似于“恢复”的行为(换言之,此时不是“抛”出一个异常,而是调用一个用于解决问题的方法)。另外,也可以将自己的try块置入一个while循环里,用它不断进入try块,直到结果满意时为止。

从历史的角度看,若程序员使用的操作系统支持可恢复的异常控制,最终都会用到类似于中断的代码,并跳过恢复进程。所以尽管“恢复”表面上十分不错,但在实际应用中却显得困难重重。其中决定性的原因可能是:我们的控制模块必须随时留意是否产生了异常,以及是否包含了由产生位置专用的代码。这便使代码很难编写和维护——大型系统尤其如此,因为异常可能在多个位置产生。

9.2.3 异常规范

在Java中,对那些要调用方法的客户程序员,我们要通知他们可能从自己的方法里“抛”出异常。这是一种有礼貌的做法,只有它才能使客户程序员准确地知道要编写什么代码来捕获所有潜在的异常。当然,若你同时提供了源码,客户程序员甚至能全盘检查代码,找出相应的throw语句。但尽管如此,通常并不随同源码提供库。为解决这个问题,Java提供了一种特殊的语法格式(并强迫我们采用),以便礼貌地告诉客户程序员该方法会“抛”出什么异常,令对方方便地加以控制。这便是我们在这里要讲述的“异常规范”,它属于方法声明的一部分,位于参数列表的后面。

异常规范采用了一个额外的关键字:throws;后面跟随全部潜在的异常类型。因此,我们的方法定义看起来应象下面这个样子:

void f() throws tooBig, tooSmall, divZero { //...

若使用下述代码:

void f() [ // ...

它意味着不会从方法里“抛”出异常(除类型为RuntimeException的异常以外,它可能从任何地方抛出——稍后还会详细讲述)。
但不能完全依赖异常规范——假若方法造成了一个异常,但没有对其进行控制,编译器会侦测到这个情况,并告诉我们必须控制异常,或者指出应该从方法里“抛”出一个异常规范。通过坚持从顶部到底部排列异常规范,Java可在编译期保证异常的正确性(注释②)。

②:这是在C++异常控制基础上一个显著的进步,后者除非到运行期,否则不会捕获不符合异常规范的错误。这使得C++的异常控制机制显得用处不大。

我们在这个地方可采取欺骗手段:要求“抛”出一个并没有发生的异常。编译器能理解我们的要求,并强迫使用这个方法的用户当作真的产生了那个异常处理。在实际应用中,可将其作为那个异常的一个“占位符”使用。这样一来,以后可以方便地产生实际的异常,毋需修改现有的代码。

9.2.4 捕获所有异常

我们可创建一个控制器,令其捕获所有类型的异常。具体的做法是捕获基类异常类型Exception(也存在其他类型的基础异常,但Exception是适用于几乎所有编程活动的基础)。如下所示:

catch(Exception e) {
System.out.println("caught an exception");
}

这段代码能捕获任何异常,所以在实际使用时最好将其置于控制器列表的末尾,防止跟随在后面的任何特殊异常控制器失效。
对于程序员常用的所有异常类来说,由于Exception类是它们的基础,所以我们不会获得关于异常太多的信息,但可调用来自它的基类Throwable的方法:

String getMessage()

获得详细的消息。

String toString()

返回对Throwable的一段简要说明,其中包括详细的消息(如果有的话)。

void printStackTrace()
void printStackTrace(PrintStream)

打印出ThrowableThrowable的调用栈路径。调用栈显示出将我们带到异常发生地点的方法调用的顺序。

第一个版本会打印出标准错误,第二个则打印出我们的选择流程。若在Windows下工作,就不能重定向标准错误。因此,我们一般愿意使用第二个版本,并将结果送给System.out;这样一来,输出就可重定向到我们希望的任何路径。

除此以外,我们还可从Throwable的基类Object(所有对象的基类型)获得另外一些方法。对于异常控制来说,其中一个可能有用的是getClass(),它的作用是返回一个对象,用它代表这个对象的类。我们可依次用getName()toString()查询这个Class类的名字。亦可对Class对象进行一些复杂的操作,尽管那些操作在异常控制中是不必要的。本章稍后还会详细讲述Class对象。

下面是一个特殊的例子,它展示了Exception方法的使用(若执行该程序遇到困难,请参考第3章3.1.2小节“赋值”):

//: ExceptionMethods.java
// Demonstrating the Exception Methods
package c09;

public class ExceptionMethods {
  public static void main(String[] args) {
    try {
      throw new Exception("Here's my Exception");
    } catch(Exception e) {
      System.out.println("Caught Exception");
      System.out.println(
        "e.getMessage(): " + e.getMessage());
      System.out.println(
        "e.toString(): " + e.toString());
      System.out.println("e.printStackTrace():");
      e.printStackTrace();
    }
  }
} ///:~

该程序输出如下:

Caught Exception
e.getMessage(): Here's my Exception
e.toString(): java.lang.Exception: Here's my Exception
e.printStackTrace():
java.lang.Exception: Here's my Exception
        at ExceptionMethods.main

可以看到,该方法连续提供了大量信息——每类信息都是前一类信息的一个子集。

9.2.5 重新“抛”出异常

在某些情况下,我们想重新抛出刚才产生过的异常,特别是在用Exception捕获所有可能的异常时。由于我们已拥有当前异常的引用,所以只需简单地重新抛出那个引用即可。下面是一个例子:

catch(Exception e) {
System.out.println("一个异常已经产生");
throw e;
}

重新“抛”出一个异常导致异常进入更高一级环境的异常控制器中。用于同一个try块的任何更进一步的catch从句仍然会被忽略。此外,与异常对象有关的所有东西都会得到保留,所以用于捕获特定异常类型的更高一级的控制器可以从那个对象里提取出所有信息。

若只是简单地重新抛出当前异常,我们打印出来的、与printStackTrace()内的那个异常有关的信息会与异常的起源地对应,而不是与重新抛出它的地点对应。若想安装新的栈跟踪信息,可调用fillInStackTrace(),它会返回一个特殊的异常对象。这个异常的创建过程如下:将当前栈的信息填充到原来的异常对象里。下面列出它的形式:

//: Rethrowing.java
// Demonstrating fillInStackTrace()

public class Rethrowing {
  public static void f() throws Exception {
    System.out.println(
      "originating the exception in f()");
    throw new Exception("thrown from f()");
  }
  public static void g() throws Throwable {
    try {
      f();
    } catch(Exception e) {
      System.out.println(
        "Inside g(), e.printStackTrace()");
      e.printStackTrace();
      throw e; // 17
      // throw e.fillInStackTrace(); // 18
    }
  }
  public static void
  main(String[] args) throws Throwable {
    try {
      g();
    } catch(Exception e) {
      System.out.println(
        "Caught in main, e.printStackTrace()");
      e.printStackTrace();
    }
  }
} ///:~

其中最重要的行号在注释内标记出来。注意第17行没有设为注释行。它的输出结果如下:

originating the exception in f()
Inside g(), e.printStackTrace()
java.lang.Exception: thrown from f()
        at Rethrowing.f(Rethrowing.java:8)
        at Rethrowing.g(Rethrowing.java:12)
        at Rethrowing.main(Rethrowing.java:24)
Caught in main, e.printStackTrace()
java.lang.Exception: thrown from f()
        at Rethrowing.f(Rethrowing.java:8)
        at Rethrowing.g(Rethrowing.java:12)
        at Rethrowing.main(Rethrowing.java:24)

因此,异常栈路径无论如何都会记住它的真正起点,无论自己被重复“抛”了好几次。
若将第17行标注(变成注释行),而撤消对第18行的标注,就会换用fillInStackTrace(),结果如下:

originating the exception in f()
Inside g(), e.printStackTrace()
java.lang.Exception: thrown from f()
        at Rethrowing.f(Rethrowing.java:8)
        at Rethrowing.g(Rethrowing.java:12)
        at Rethrowing.main(Rethrowing.java:24)
Caught in main, e.printStackTrace()
java.lang.Exception: thrown from f()
        at Rethrowing.g(Rethrowing.java:18)
        at Rethrowing.main(Rethrowing.java:24)

由于使用的是fillInStackTrace(),第18行成为异常的新起点。

针对g()main()Throwable类必须在异常规约中出现,因为fillInStackTrace()会生成一个Throwable对象的引用。由于ThrowableException的一个基类,所以有可能获得一个能够“抛”出的对象(具有Throwable属性),但却并非一个Exception(异常)。因此,在main()中用于Exception的引用可能丢失自己的目标。为保证所有东西均井然有序,编译器强制Throwable使用一个异常规范。举个例子来说,下述程序的异常便不会在main()中被捕获到:

//: ThrowOut.java
public class ThrowOut {
  public static void
  main(String[] args) throws Throwable {
    try {
      throw new Throwable();
    } catch(Exception e) {
      System.out.println("Caught in main()");
    }
  }
} ///:~

也有可能从一个已经捕获的异常重新“抛”出一个不同的异常。但假如这样做,会得到与使用fillInStackTrace()类似的效果:与异常起源地有关的信息会全部丢失,我们留下的是与新的throw有关的信息。如下所示:

//: RethrowNew.java
// Rethrow a different object from the one that
// was caught

public class RethrowNew {
  public static void f() throws Exception {
    System.out.println(
      "originating the exception in f()");
    throw new Exception("thrown from f()");
  }
  public static void main(String[] args) {
    try {
      f();
    } catch(Exception e) {
      System.out.println(
        "Caught in main, e.printStackTrace()");
      e.printStackTrace();
      throw new NullPointerException("from main");
    }
  }
} ///:~

输出如下:

originating the exception in f()
Caught in main, e.printStackTrace()
java.lang.Exception: thrown from f()
        at RethrowNew.f(RethrowNew.java:8)
        at RethrowNew.main(RethrowNew.java:13)
java.lang.NullPointerException: from main
        at RethrowNew.main(RethrowNew.java:18)

最后一个异常只知道自己来自main(),而非来自f()。注意Throwable在任何异常规范中都不是必需的。

永远不必关心如何清除前一个异常,或者与之有关的其他任何异常。它们都属于用new创建的、以内存堆为基础的对象,所以垃圾收集器会自动将其清除。

9.3 标准Java异常

Java包含了一个名为Throwable的类,它对可以作为异常“抛”出的所有东西进行了描述。Throwable对象有两种常规类型(亦即“从Throwable继承”)。其中,Error代表编译期和系统错误,我们一般不必特意捕获它们(除在特殊情况以外)。Exception是可以从任何标准Java库的类方法中“抛”出的基本类型。此外,它们亦可从我们自己的方法以及运行期偶发事件中“抛”出。

为获得异常的一个综合概念,最好的方法是阅读由http://java.sun.com提供的联机Java文档(当然,首先下载它们更好)。为了对各种异常有一个大概的印象,这个工作是相当有价值的。但大家不久就会发现,除名字外,一个异常和下一个异常之间并不存在任何特殊的地方。此外,Java提供的异常数量正在日益增多;从本质上说,把它们印到一本书里是没有意义的。大家从其他地方获得的任何新库可能也提供了它们自己的异常。我们最需要掌握的是基本概念,以及用这些异常能够做什么。

java.lang.Exception

这是程序能捕获的基本异常。其他异常都是从它派生出去的。这里要注意的是异常的名字代表发生的问题,而且异常名通常都是精心挑选的,可以很清楚地说明到底发生了什么事情。异常并不全是在java.lang中定义的;有些是为了提供对其他库的支持,如utilnet以及io等——我们可以从它们的完整类名中看出这一点,或者观察它们从什么继承。例如,所有IO异常都是从java.io.IOException继承的。

9.3.1 RuntimeException的特殊情况

本章的第一个例子是:

if(t == null)
throw new NullPointerException();

看起来似乎在传递进入一个方法的每个引用中都必须检查null(因为不知道调用者是否已传递了一个有效的引用),这无疑是相当可怕的。但幸运的是,我们根本不必这样做——它属于Java进行的标准运行期检查的一部分。若对一个空引用发出了调用,Java会自动产生一个NullPointerException异常。所以上述代码在任何情况下都是多余的。

这个类别里含有一系列异常类型。它们全部由Java自动生成,毋需我们亲自动手把它们包含到自己的异常规范里。最方便的是,通过将它们置入单独一个名为RuntimeException的基类下面,它们全部组合到一起。这是一个很好的继承例子:它建立了一系列具有某种共通性的类型,都具有某些共通的特征与行为。此外,我们没必要专门写一个异常规范,指出一个方法可能会“抛”出一个RuntimeException,因为已经假定可能出现那种情况。由于它们用于指出编程中的错误,所以几乎永远不必专门捕获一个“运行期异常”——RuntimeException——它在默认情况下会自动得到处理。若必须检查RuntimeException,我们的代码就会变得相当繁复。在我们自己的包里,可选择“抛”出一部分RuntimeException

如果不捕获这些异常,又会出现什么情况呢?由于编译器并不强制异常规范捕获它们,所以假如不捕获的话,一个RuntimeException可能过滤掉我们到达main()方法的所有途径。为体会此时发生的事情,请试试下面这个例子:

//: NeverCaught.java
// Ignoring RuntimeExceptions

public class NeverCaught {
  static void f() {
    throw new RuntimeException("From f()");
  }
  static void g() {
    f();
  }
  public static void main(String[] args) {
    g();
  }
} ///:~

大家已经看到,一个RuntimeException(或者从它继承的任何东西)属于一种特殊情况,因为编译器不要求为这些类型指定异常规范。

输出如下:

java.lang.RuntimeException: From f()
at NeverCaught.f(NeverCaught.java:9)
at NeverCaught.g(NeverCaught.java:12)
at NeverCaught.main(NeverCaught.java:15)

所以答案就是:假若一个RuntimeException获得到达main()的所有途径,同时不被捕获,那么当程序退出时,会为那个异常调用printStackTrace()

注意也许能在自己的代码中仅忽略RuntimeException,因为编译器已正确实行了其他所有控制。因为RuntimeException在此时代表一个编程错误:

(1) 一个我们不能捕获的错误(例如,由客户程序员接收传递给自己方法的一个空引用)。

(2) 作为一名程序员,一个应在自己的代码中检查的错误(如ArrayIndexOutOfBoundException,此时应注意数组的大小)。
可以看出,最好的做法是在这种情况下异常,因为它们有助于程序的调试。

另外一个有趣的地方是,我们不可将Java异常划分为单一用途的工具。的确,它们设计用于控制那些讨厌的运行期错误——由代码控制范围之外的其他力量产生。但是,它也特别有助于调试某些特殊类型的编程错误,那些是编译器侦测不到的。

9.4 创建自己的异常

并不一定非要使用Java异常。这一点必须掌握,因为经常都需要创建自己的异常,以便指出自己的库可能生成的一个特殊错误——但创建Java分级结构的时候,这个错误是无法预知的。

为创建自己的异常类,必须从一个现有的异常类型继承——最好在含义上与新异常近似。继承一个异常相当简单:

//: Inheriting.java
// Inheriting your own exceptions

class MyException extends Exception {
  public MyException() {}
  public MyException(String msg) {
    super(msg);
  }
}

public class Inheriting {
  public static void f() throws MyException {
    System.out.println(
      "Throwing MyException from f()");
    throw new MyException();
  }
  public static void g() throws MyException {
    System.out.println(
      "Throwing MyException from g()");
    throw new MyException("Originated in g()");
  }
  public static void main(String[] args) {
    try {
      f();
    } catch(MyException e) {
      e.printStackTrace();
    }
    try {
      g();
    } catch(MyException e) {
      e.printStackTrace();
    }
  }
} ///:~

继承在创建新类时发生:

class MyException extends Exception {
  public MyException() {}
  public MyException(String msg) {
    super(msg);
  }
}

这里的关键是extends Exception,它的意思是:除包括一个Exception的全部含义以外,还有更多的含义。增加的代码数量非常少——实际只添加了两个构造器,对MyException的创建方式进行了定义。请记住,假如我们不明确调用一个基类构造器,编译器会自动调用基类默认构造器。在第二个构造器中,通过使用super关键字,明确调用了带有一个String参数的基类构造器。

该程序输出结果如下:

Throwing MyException from f()
MyException
        at Inheriting.f(Inheriting.java:16)
        at Inheriting.main(Inheriting.java:24)
Throwing MyException from g()
MyException: Originated in g()
        at Inheriting.g(Inheriting.java:20)
        at Inheriting.main(Inheriting.java:29)

可以看到,在从f()“抛”出的MyException异常中,缺乏详细的消息。

创建自己的异常时,还可以采取更多的操作。我们可添加额外的构造器及成员:

//: Inheriting2.java
// Inheriting your own exceptions

class MyException2 extends Exception {
  public MyException2() {}
  public MyException2(String msg) {
    super(msg);
  }
  public MyException2(String msg, int x) {
    super(msg);
    i = x;
  }
  public int val() { return i; }
  private int i;
}

public class Inheriting2 {
  public static void f() throws MyException2 {
    System.out.println(
      "Throwing MyException2 from f()");
    throw new MyException2();
  }
  public static void g() throws MyException2 {
    System.out.println(
      "Throwing MyException2 from g()");
    throw new MyException2("Originated in g()");
  }
  public static void h() throws MyException2 {
    System.out.println(
      "Throwing MyException2 from h()");
    throw new MyException2(
      "Originated in h()", 47);
  }
  public static void main(String[] args) {
    try {
      f();
    } catch(MyException2 e) {
      e.printStackTrace();
    }
    try {
      g();
    } catch(MyException2 e) {
      e.printStackTrace();
    }
    try {
      h();
    } catch(MyException2 e) {
      e.printStackTrace();
      System.out.println("e.val() = " + e.val());
    }
  }
} ///:~

此时添加了一个数据成员i;同时添加了一个特殊的方法,用它读取那个值;也添加了一个额外的构造器,用它设置那个值。输出结果如下:

Throwing MyException2 from f()
MyException2
        at Inheriting2.f(Inheriting2.java:22)
        at Inheriting2.main(Inheriting2.java:34)
Throwing MyException2 from g()
MyException2: Originated in g()
        at Inheriting2.g(Inheriting2.java:26)
        at Inheriting2.main(Inheriting2.java:39)
Throwing MyException2 from h()
MyException2: Originated in h()
        at Inheriting2.h(Inheriting2.java:30)
        at Inheriting2.main(Inheriting2.java:44)
e.val() = 47

由于异常不过是另一种形式的对象,所以可以继续这个进程,进一步增强异常类的能力。但要注意,对使用自己这个包的客户程序员来说,他们可能错过所有这些增强。因为他们可能只是简单地寻找准备生成的异常,除此以外不做任何事情——这是大多数Java库异常的标准用法。若出现这种情况,有可能创建一个新异常类型,其中几乎不包含任何代码:

//: SimpleException.java
class SimpleException extends Exception {
} ///:~

它要依赖编译器来创建默认构造器(会自动调用基类的默认构造器)。当然,在这种情况下,我们不会得到一个SimpleException(String)构造器,但它实际上也不会经常用到。

9.5 异常的限制

覆盖一个方法时,只能产生已在方法的基类版本中定义的异常。这是一个重要的限制,因为它意味着与基类协同工作的代码也会自动应用于从基类派生的任何对象(当然,这属于基本的OOP概念),其中包括异常。

下面这个例子演示了强加在异常身上的限制类型(在编译期):

//: StormyInning.java
// Overridden methods may throw only the
// exceptions specified in their base-class
// versions, or exceptions derived from the
// base-class exceptions.

class BaseballException extends Exception {}
class Foul extends BaseballException {}
class Strike extends BaseballException {}

abstract class Inning {
  Inning() throws BaseballException {}
  void event () throws BaseballException {
   // Doesn't actually have to throw anything
  }
  abstract void atBat() throws Strike, Foul;
  void walk() {} // Throws nothing
}

class StormException extends Exception {}
class RainedOut extends StormException {}
class PopFoul extends Foul {}

interface Storm {
  void event() throws RainedOut;
  void rainHard() throws RainedOut;
}

public class StormyInning extends Inning
    implements Storm {
  // OK to add new exceptions for constructors,
  // but you must deal with the base constructor
  // exceptions:
  StormyInning() throws RainedOut,
    BaseballException {}
  StormyInning(String s) throws Foul,
    BaseballException {}
  // Regular methods must conform to base class:
//! void walk() throws PopFoul {} //Compile error
  // Interface CANNOT add exceptions to existing
  // methods from the base class:
//! public void event() throws RainedOut {}
  // If the method doesn't already exist in the
  // base class, the exception is OK:
  public void rainHard() throws RainedOut {}
  // You can choose to not throw any exceptions,
  // even if base version does:
  public void event() {}
  // Overridden methods can throw
  // inherited exceptions:
  void atBat() throws PopFoul {}
  public static void main(String[] args) {
    try {
      StormyInning si = new StormyInning();
      si.atBat();
    } catch(PopFoul e) {
    } catch(RainedOut e) {
    } catch(BaseballException e) {}
    // Strike not thrown in derived version.
    try {
      // What happens if you upcast?
      Inning i = new StormyInning();
      i.atBat();
      // You must catch the exceptions from the
      // base-class version of the method:
    } catch(Strike e) {
    } catch(Foul e) {
    } catch(RainedOut e) {
    } catch(BaseballException e) {}
  }
} ///:~

Inning中,可以看到无论构造器还是event()方法都指出自己会“抛”出一个异常,但它们实际上没有那样做。这是合法的,因为它允许我们强迫用户捕获可能在覆盖过的event()版本里添加的任何异常。同样的道理也适用于abstract方法,就象在atBat()里展示的那样。

interface Storm非常有趣,因为它包含了在Incoming中定义的一个方法——event(),以及不是在其中定义的一个方法。这两个方法都会“抛”出一个新的异常类型:RainedOut。当执行到StormyInning extendsimplements Storm的时候,可以看到Storm中的event()方法不能改变Inning中的event()的异常接口。同样地,这种设计是十分合理的;否则的话,当我们操作基类时,便根本无法知道自己捕获的是否正确的东西。当然,假如interface中定义的一个方法不在基类里,比如rainHard(),它产生异常时就没什么问题。

对异常的限制并不适用于构造器。在StormyInning中,我们可看到一个构造器能够“抛”出它希望的任何东西,无论基类构造器“抛”出什么。然而,由于必须坚持按某种方式调用基类构造器(在这里,会自动调用默认构造器),所以派生类构造器必须在自己的异常规范中声明所有基类构造器异常。

StormyInning.walk()不会编译的原因是它“抛”出了一个异常,而Inning.walk()却不会“抛”出。若允许这种情况发生,就可让自己的代码调用Inning.walk(),而且它不必控制任何异常。但在以后替换从Inning派生的一个类的对象时,异常就会“抛”出,造成代码执行的中断。通过强迫派生类方法遵守基类方法的异常规范,对象的替换可保持连贯性。

覆盖过的event()方法向我们显示出一个方法的派生类版本可以不产生任何异常——即便基类版本要产生异常。同样地,这样做是必要的,因为它不会中断那些已假定基类版本会产生异常的代码。差不多的道理亦适用于atBat(),它会“抛”出PopFoul——从Foul派生出来的一个异常,而Foul异常是由atBat()的基类版本产生的。这样一来,假如有人在自己的代码里操作Inning,同时调用了atBat(),就必须捕获Foul异常。由于PopFoul是从Foul派生的,所以异常控制器(模块)也会捕获PopFoul

最后一个有趣的地方在main()内部。在这个地方,假如我们明确操作一个StormyInning对象,编译器就会强迫我们只捕获特定于那个类的异常。但假如我们向上转换到基类型,编译器就会强迫我们捕获针对基类的异常。通过所有这些限制,异常控制代码的“健壮”程度获得了大幅度改善(注释③)。

③:ANSI/ISO C++施加了类似的限制,要求派生方法异常与基类方法抛出的异常相同,或者从后者派生。在这种情况下,C++实际上能够在编译期间检查异常规范。

我们必须认识到这一点:尽管异常规范是由编译器在继承期间强行遵守的,但异常规范并不属于方法类型的一部分,后者仅包括了方法名以及参数类型。因此,我们不可在异常规范的基础上覆盖方法。除此以外,尽管异常规范存在于一个方法的基类版本中,但并不表示它必须在方法的派生类版本中存在。这与方法的“继承”颇有不同(进行继承时,基类中的方法也必须在派生类中存在)。换言之,用于一个特定方法的“异常规范接口”可能在继承和覆盖时变得更“窄”,但它不会变得更“宽”——这与继承时的类接口规则是正好相反的。

9.6 用finally清除

无论一个异常是否在try块中发生,我们经常都想执行一些特定的代码。对一些特定的操作,经常都会遇到这种情况,但在恢复内存时一般都不需要(因为垃圾收集器会自动照料一切)。为达到这个目的,可在所有异常控制器的末尾使用一个finally从句(注释④)。所以完整的异常控制小节象下面这个样子:

try {
// 要保卫的区域:
// 可能“抛”出A,B,或C的危险情况
} catch (A a1) {
// 控制器 A
} catch (B b1) {
// 控制器 B
} catch (C c1) {
// 控制器 C
} finally {
// 每次都会发生的情况
}

④:C++异常控制未提供finally从句,因为它依赖构造器来达到这种清除效果。

为演示finally从句,请试验下面这个程序:

//: FinallyWorks.java
// The finally clause is always executed

public class FinallyWorks {
  static int count = 0;
  public static void main(String[] args) {
    while(true) {
      try {
        // post-increment is zero first time:
        if(count++ == 0)
          throw new Exception();
        System.out.println("No exception");
      } catch(Exception e) {
        System.out.println("Exception thrown");
      } finally {
        System.out.println("in finally clause");
        if(count == 2) break; // out of "while"
      }
    }
  }
} ///:~

通过该程序,我们亦可知道如何应付Java异常(类似C++的异常)不允许我们恢复至异常产生地方的这一事实。若将自己的try块置入一个循环内,就可建立一个条件,它必须在继续程序之前满足。亦可添加一个static计数器或者另一些设备,允许循环在放弃以前尝试数种不同的方法。这样一来,我们的程序可以变得更加“健壮”。

输出如下:

Exception thrown
in finally clause
No exception
in finally clause

无论是否“抛”出一个异常,finally从句都会执行。

9.6.1 用finally做什么

在没有“垃圾收集”以及“自动调用析构器”机制的一种语言中(注释⑤),finally显得特别重要,因为程序员可用它担保内存的正确释放——无论在try块内部发生了什么状况。但Java提供了垃圾收集机制,所以内存的释放几乎绝对不会成为问题。另外,它也没有构造器可供调用。既然如此,Java里何时才会用到finally呢?

⑤:“析构器”(Destructor)是“构造器”(Constructor)的反义词。它代表一个特殊的函数,一旦某个对象失去用处,通常就会调用它。我们肯定知道在哪里以及何时调用析构器。C++提供了自动的析构器调用机制,但Delphi的Object Pascal版本1及2却不具备这一能力(在这种语言中,析构器的含义与用法都发生了变化)。

除将内存设回原始状态以外,若要设置另一些东西,finally就是必需的。例如,我们有时需要打开一个文件或者建立一个网络连接,或者在屏幕上画一些东西,甚至设置外部世界的一个开关,等等。如下例所示:

//: OnOffSwitch.java
// Why use finally?

class Switch {
  boolean state = false;
  boolean read() { return state; }
  void on() { state = true; }
  void off() { state = false; }
}

public class OnOffSwitch {
  static Switch sw = new Switch();
  public static void main(String[] args) {
    try {
      sw.on();
      // Code that can throw exceptions...
      sw.off();
    } catch(NullPointerException e) {
      System.out.println("NullPointerException");
      sw.off();
    } catch(IllegalArgumentException e) {
      System.out.println("IOException");
      sw.off();
    }
  }
} ///:~

这里的目标是保证main()完成时开关处于关闭状态,所以将sw.off()置于try块以及每个异常控制器的末尾。但产生的一个异常有可能不是在这里捕获的,这便会错过sw.off()。然而,利用finally,我们可以将来自try块的关闭代码只置于一个地方:

//: WithFinally.java
// Finally Guarantees cleanup

class Switch2 {
  boolean state = false;
  boolean read() { return state; }
  void on() { state = true; }
  void off() { state = false; }
}

public class WithFinally {
  static Switch2 sw = new Switch2();
  public static void main(String[] args) {
    try {
      sw.on();
      // Code that can throw exceptions...
    } catch(NullPointerException e) {
      System.out.println("NullPointerException");
    } catch(IllegalArgumentException e) {
      System.out.println("IOException");
    } finally {
      sw.off();
    }
  }
} ///:~

在这儿,sw.off()已移至一个地方。无论发生什么事情,都肯定会运行它。

即使异常不在当前的catch从句集里捕获,finally都会在异常控制机制转到更高级别搜索一个控制器之前得以执行。如下所示:

//: AlwaysFinally.java
// Finally is always executed

class Ex extends Exception {}

public class AlwaysFinally {
  public static void main(String[] args) {
    System.out.println(
      "Entering first try block");
    try {
      System.out.println(
        "Entering second try block");
      try {
        throw new Ex();
      } finally {
        System.out.println(
          "finally in 2nd try block");
      }
    } catch(Ex e) {
      System.out.println(
        "Caught Ex in first try block");
    } finally {
      System.out.println(
        "finally in 1st try block");
    }
  }
} ///:~

该程序的输出展示了具体发生的事情:

Entering first try block
Entering second try block
finally in 2nd try block
Caught Ex in first try block
finally in 1st try block

若调用了breakcontinue语句,finally语句也会得以执行。请注意,与作上标签的breakcontinue一道,finally排除了Java对goto跳转语句的需求。

9.6.2 缺点:丢失的异常

一般情况下,Java的异常实现方案都显得十分出色。不幸的是,它依然存在一个缺点。尽管异常指出程序里存在一个危机,而且绝不应忽略,但一个异常仍有可能简单地“丢失”。在采用finally从句的一种特殊配置下,便有可能发生这种情况:

//: LostMessage.java
// How an exception can be lost

class VeryImportantException extends Exception {
  public String toString() {
    return "A very important exception!";
  }
}

class HoHumException extends Exception {
  public String toString() {
    return "A trivial exception";
  }
}

public class LostMessage {
  void f() throws VeryImportantException {
    throw new VeryImportantException();
  }
  void dispose() throws HoHumException {
    throw new HoHumException();
  }
  public static void main(String[] args)
      throws Exception {
    LostMessage lm = new LostMessage();
    try {
      lm.f();
    } finally {
      lm.dispose();
    }
  }
} ///:~

输出如下:

A trivial exception
        at LostMessage.dispose(LostMessage.java:21)
        at LostMessage.main(LostMessage.java:29)

可以看到,这里不存在VeryImportantException(非常重要的异常)的迹象,它只是简单地被finally从句中的HoHumException代替了。

这是一项相当严重的缺陷,因为它意味着一个异常可能完全丢失。而且就象前例演示的那样,这种丢失显得非常“自然”,很难被人查出蛛丝马迹。而与此相反,C++里如果第二个异常在第一个异常得到控制前产生,就会被当作一个严重的编程错误处理。或许Java以后的版本会纠正这个问题(上述结果是用Java 1.1生成的)。

9.7 构造器

为异常编写代码时,我们经常要解决的一个问题是:“一旦产生异常,会正确地进行清除吗?”大多数时候都会非常安全,但在构造器中却是一个大问题。构造器将对象置于一个安全的起始状态,但它可能执行一些操作——如打开一个文件。除非用户完成对象的使用,并调用一个特殊的清除方法,否则那些操作不会得到正确的清除。若从一个构造器内部“抛”出一个异常,这些清除行为也可能不会正确地发生。所有这些都意味着在编写构造器时,我们必须特别加以留意。

由于前面刚学了finally,所以大家可能认为它是一种合适的方案。但事情并没有这么简单,因为finally每次都会执行清除代码——即使我们在清除方法运行之前不想执行清除代码。因此,假如真的用finally进行清除,必须在构造器正常结束时设置某种形式的标志。而且只要设置了标志,就不要执行finally块内的任何东西。由于这种做法并不完美(需要将一个地方的代码同另一个地方的结合起来),所以除非特别需要,否则一般不要尝试在finally中进行这种形式的清除。

在下面这个例子里,我们创建了一个名为InputFile的类。它的作用是打开一个文件,然后每次读取它的一行内容(转换为一个字符串)。它利用了由Java标准IO库提供的FileReader以及BufferedReader类(将于第10章讨论)。这两个类都非常简单,大家现在可以毫无困难地掌握它们的基本用法:

//: Cleanup.java
// Paying attention to exceptions
// in constructors
import java.io.*;

class InputFile {
  private BufferedReader in;
  InputFile(String fname) throws Exception {
    try {
      in =
        new BufferedReader(
          new FileReader(fname));
      // Other code that might throw exceptions
    } catch(FileNotFoundException e) {
      System.out.println(
        "Could not open " + fname);
      // Wasn't open, so don't close it
      throw e;
    } catch(Exception e) {
      // All other exceptions must close it
      try {
        in.close();
      } catch(IOException e2) {
        System.out.println(
          "in.close() unsuccessful");
      }
      throw e;
    } finally {
      // Don't close it here!!!
    }
  }
  String getLine() {
    String s;
    try {
      s = in.readLine();
    } catch(IOException e) {
      System.out.println(
        "readLine() unsuccessful");
      s = "failed";
    }
    return s;
  }
  void cleanup() {
    try {
      in.close();
    } catch(IOException e2) {
      System.out.println(
        "in.close() unsuccessful");
    }
  }
}

public class Cleanup {
  public static void main(String[] args) {
    try {
      InputFile in =
        new InputFile("Cleanup.java");
      String s;
      int i = 1;
      while((s = in.getLine()) != null)
        System.out.println(""+ i++ + ": " + s);
      in.cleanup();
    } catch(Exception e) {
      System.out.println(
        "Caught in main, e.printStackTrace()");
      e.printStackTrace();
    }
  }
} ///:~

该例使用了Java 1.1 IO类。

用于InputFile的构造器采用了一个String(字符串)参数,它代表我们想打开的那个文件的名字。在一个try块内部,它用该文件名创建了一个FileReader。对FileReader来说,除非转移并用它创建一个能够实际与之“交谈”的BufferedReader,否则便没什么用处。注意InputFile的一个好处就是它同时合并了这两种行动。

FileReader构造器不成功,就会产生一个FileNotFoundException(文件未找到异常)。必须单独捕获这个异常——这属于我们不想关闭文件的一种特殊情况,因为文件尚未成功打开。其他任何捕获从句(catch)都必须关闭文件,因为文件已在进入那些捕获从句时打开(当然,如果多个方法都能产生一个FileNotFoundException异常,就需要稍微用一些技巧。此时,我们可将不同的情况分隔到数个try块内)。close()方法会抛出一个尝试过的异常。即使它在另一个catch从句的代码块内,该异常也会得以捕获——对Java编译器来说,那个catch从句不过是另一对花括号而已。执行完本地操作后,异常会被重新“抛”出。这样做是必要的,因为这个构造器的执行已经失败,我们不希望调用方法来假设对象已正确创建以及有效。

在这个例子中,没有采用前述的标志技术,finally从句显然不是关闭文件的正确地方,因为这可能在每次构造器结束的时候关闭它。由于我们希望文件在InputFile对象处于活动状态时一直保持打开状态,所以这样做并不恰当。

getLine()方法会返回一个字符串,其中包含了文件中下一行的内容。它调用了readLine(),后者可能产生一个异常,但那个异常会被捕获,使getLine()不会再产生任何异常。对异常来说,一项特别的设计问题是决定在这一级完全控制一个异常,还是进行部分控制,并传递相同(或不同)的异常,或者只是简单地传递它。在适当的时候,简单地传递可极大简化我们的编码工作。

getLine()方法会变成:

String getLine() throws IOException {
return in.readLine();
}

但是当然,调用者现在需要对可能产生的任何IOException进行控制。

用户使用完毕InputFile对象后,必须调用cleanup()方法,以便释放由BufferedReader以及/或者FileReader占用的系统资源(如文件引用)——注释⑥。除非InputFile对象使用完毕,而且到了需要弃之不用的时候,否则不应进行清除。大家可能想把这样的机制置入一个finalize()方法内,但正如第4章指出的那样,并非总能保证finalize()获得正确的调用(即便确定它会调用,也不知道何时开始)。这属于Java的一项缺陷——除内存清除之外的所有清除都不会自动进行,所以必须知会客户程序员,告诉他们有责任用finalize()保证清除工作的正确进行。

⑥:在C++里,“析构器”可帮我们控制这一局面。

Cleanup.java中,我们创建了一个InputFile,用它打开用于创建程序的相同的源文件。同时一次读取该文件的一行内容,而且添加相应的行号。所有异常都会在main()中被捕获——尽管我们可选择更大的可靠性。

这个示例也向大家展示了为何在本书的这个地方引入异常的概念。异常与Java的编程具有很高的集成度,这主要是由于编译器会强制它们。只有知道了如何操作那些异常,才可更进一步地掌握编译器的知识。

9.8 异常匹配

“抛”出一个异常后,异常控制系统会按当初编写的顺序搜索“最接近”的控制器。一旦找到相符的控制器,就认为异常已得到控制,不再进行更多的搜索工作。

在异常和它的控制器之间,并不需要非常精确的匹配。一个派生类对象可与基类的一个控制器相配,如下例所示:

//: Human.java
// Catching Exception Hierarchies

class Annoyance extends Exception {}
class Sneeze extends Annoyance {}

public class Human {
  public static void main(String[] args) {
    try {
      throw new Sneeze();
    } catch(Sneeze s) {
      System.out.println("Caught Sneeze");
    } catch(Annoyance a) {
      System.out.println("Caught Annoyance");
    }
  }
} ///:~

Sneeze异常会被相符的第一个catch从句捕获。当然,这只是第一个。然而,假如我们删除第一个catch从句:

    try {
      throw new Sneeze();
    } catch(Annoyance a) {
      System.out.println("Caught Annoyance");
    }

那么剩下的catch从句依然能够工作,因为它捕获的是Sneeze的基类。换言之,catch(Annoyance e)能捕获一个Annoyance以及从它派生的任何类。这一点非常重要,因为一旦我们决定为一个方法添加更多的异常,而且它们都是从相同的基类继承的,那么客户程序员的代码就不需要更改。至少能够假定它们捕获的是基类。

若将基类捕获从句置于第一位,试图“屏蔽”派生类异常,就象下面这样:

    try {
      throw new Sneeze();
    } catch(Annoyance a) {
      System.out.println("Caught Annoyance");
    } catch(Sneeze s) {
      System.out.println("Caught Sneeze");
    }

则编译器会产生一条出错消息,因为它发现永远不可能抵达Sneeze捕获从句。

9.8.1 异常准则

用异常做下面这些事情:

(1) 解决问题并再次调用造成异常的方法。

(2) 平息事态的发展,并在不重新尝试方法的前提下继续。

(3) 计算另一些结果,而不是希望方法产生的结果。

(4) 在当前环境中尽可能解决问题,以及将相同的异常重新“抛”出一个更高级的环境。

(5) 在当前环境中尽可能解决问题,以及将不同的异常重新“抛”出一个更高级的环境。

(6) 中止程序执行。

(7) 简化编码。若异常方案使事情变得更加复杂,那就会令人非常烦恼,不如不用。

(8) 使自己的库和程序变得更加安全。这既是一种“短期投资”(便于调试),也是一种“长期投资”(改善应用程序的健壮性)

9.9 总结

通过先进的错误纠正与恢复机制,我们可以有效地增强代码的健壮程度。对我们编写的每个程序来说,错误恢复都属于一个基本的考虑目标。它在Java中显得尤为重要,因为该语言的一个目标就是创建不同的程序组件,以便其他用户(客户程序员)使用。为构建一套健壮的系统,每个组件都必须非常健壮。

在Java里,异常控制的目的是使用尽可能精简的代码创建大型、可靠的应用程序,同时排除程序里那些不能控制的错误。

异常的概念很难掌握。但只有很好地运用它,才可使自己的项目立即获得显著的收益。Java强迫遵守异常所有方面的问题,所以无论库设计者还是客户程序员,都能够连续一致地使用它。

第9章 异常差错控制

Java的基本原理就是“形式错误的代码不会运行”。

与C++类似,捕获错误最理想的是在编译期间,最好在试图运行程序以前。然而,并非所有错误都能在编译期间侦测到。有些问题必须在运行期间解决,让错误的缔结者通过一些手续向接收者传递一些适当的信息,使其知道该如何正确地处理遇到的问题。

在C++和其他早期语言中,可通过几种手续来达到这个目的。而且它们通常是作为一种规定建立起来的,而非作为程序设计语言的一部分。典型地,我们需要返回一个值或设置一个标志(位),接收者会检查这些值或标志,判断具体发生了什么事情。然而,随着时间的流逝,终于发现这种做法会助长那些使用一个库的程序员的麻痹情绪。他们往往会这样想:“是的,错误可能会在其他人的代码中出现,但不会在我的代码中”。这样的后果便是他们一般不检查是否出现了错误(有时出错条件确实显得太愚蠢,不值得检验;注释①)。另一方面,若每次调用一个方法时都进行全面、细致的错误检查,那么代码的可读性也可能大幅度降低。由于程序员可能仍然在用这些语言维护自己的系统,所以他们应该对此有着深刻的体会:若按这种方式控制错误,那么在创建大型、健壮、易于维护的程序时,肯定会遇到不小的阻挠。

①:C程序员研究一下printf()的返回值便知端详。

解决的方法是在错误控制中排除所有偶然性,强制格式的正确。这种方法实际已有很长的历史,因为早在60年代便在操作系统里采用了“异常控制”手段;甚至可以追溯到BASIC语言的on error goto语句。但C++的异常控制建立在Ada的基础上,而Java又主要建立在C++的基础上(尽管它看起来更象Object Pascal)。

“异常”(Exception)这个词表达的是一种“例外”情况,亦即正常情况之外的一种“异常”。在问题发生的时候,我们可能不知具体该如何解决,但肯定知道已不能不顾一切地继续下去。此时,必须坚决地停下来,并由某人、某地指出发生了什么事情,以及该采取何种对策。但为了真正解决问题,当地可能并没有足够多的信息。因此,我们需要将其移交给更级的负责人,令其作出正确的决定(类似一个命令链)。

异常机制的另一项好处就是能够简化错误控制代码。我们再也不用检查一个特定的错误,然后在程序的多处地方对其进行控制。此外,也不需要在方法调用的时候检查错误(因为保证有人能捕获这里的错误)。我们只需要在一个地方处理问题:“异常控制模块”或者“异常控制器”。这样可有效减少代码量,并将那些用于描述具体操作的代码与专门纠正错误的代码分隔开。一般情况下,用于读取、写入以及调试的代码会变得更富有条理。

由于异常控制是由Java编译器强行实现的,所以毋需深入学习异常控制,便可正确使用本书编写的大量例子。本章向大家介绍了用于正确控制异常所需的代码,以及在某个方法遇到麻烦的时候,该如何生成自己的异常。

附录A 使用非JAVA代码

JAVA语言及其标准API(应用程序编程接口)应付应用程序的编写已绰绰有余。但在某些情况下,还是必须使用非JAVA编码。例如,我们有时要访问操作系统的专用特性,与特殊的硬件设备打交道,重复使用现有的非Java接口,或者要使用“对时间敏感”的代码段,等等。与非Java代码的沟通要求获得编译器和“虚拟机”的专门支持,并需附加的工具将Java代码映射成非Java代码(也有一个简单方法:在第15章的“一个Web应用”小节中,有个例子解释了如何利用标准输入输出同非Java代码连接)。目前,不同的开发商为我们提供了不同的方案:Java 1.1有“Java固有接口”(Java Native Interface,JNI),网景提出了自己的“Java运行期接口”(Java Runtime Interface)计划,而微软提供了J/Direct、“本源接口”(Raw Native Interface,RNI)以及Java/COM集成方案。

各开发商在这个问题上所持的不同态度对程序员是非常不利的。若Java应用必须调用固有方法,则程序员或许要实现固有方法的不同版本——具体由应用程序运行的平台决定。程序员也许实际需要不同版本的Java代码,以及不同的Java虚拟机。

另一个方案是CORBA(通用对象请求代理结构),这是由OMG(对象管理组,一家非赢利性的公司协会)开发的一种集成技术。CORBA并非任何语言的一部分,只是实现通用通信总线及服务的一种规范。利用它可在由不同语言实现的对象之间实现“相互操作”的能力。这种通信总线的名字叫作ORB(对象请求代理),是由其他开发商实现的一种产品,但并不属于Java语言规范的一部分。
本附录将对JNI,J/DIRECT,RNI,JAVA/COM集成和CORBA进行概述。但不会作更深层次的探讨,甚至有时还假定读者已对相关的概念和技术有了一定程度的认识。但到最后,大家应该能够自行比较不同的方法,并根据自己要解决的问题挑选出最恰当的一种。

A.1 Java固有接口

JNI是一种包容极广的编程接口,允许我们从Java应用程序里调用固有方法。它是在Java 1.1里新增的,维持着与Java 1.0的相应特性——“固有方法接口”(NMI)——某种程度的兼容。NMI设计上一些特点使其未获所有虚拟机的支持。考虑到这个原因,Java语言将来的版本可能不再提供对NMI的支持,这儿也不准备讨论它。

目前,JNI只能与用C或C++写成的固有方法打交道。利用JNI,我们的固有方法可以:

  • 创建、检查及更新Java对象(包括数组和字符串)

  • 调用Java方法

  • 俘获和丢弃“异常”

  • 装载类并获取类信息

  • 进行运行期类型检查

所以,原来在Java中能对类及对象做的几乎所有事情在固有方法中同样可以做到。

A.1.1 调用固有方法

我们先从一个简单的例子开始:一个Java程序调用固有方法,后者再调用Win32的API函数MessageBox(),显示出一个图形化的文
本框。这个例子稍后也会与J/Direct一志使用。若您的平台不是Win32,只需将包含了下述内容的C头:

#include <windows.h>

替换成:

#include <stdio.h>

并将对MessageBox()的调用换成调用printf()即可。

第一步是写出对固有方法及它的参数进行声明的Java代码:

class ShowMsgBox {
  public static void main(String [] args) {
    ShowMsgBox app = new ShowMsgBox();
    app.ShowMessage("Generated with JNI");
  }
  private native void ShowMessage(String msg);
  static {
    System.loadLibrary("MsgImpl");
  }
}

在固有方法声明的后面,跟随有一个static代码块,它会调用System.loadLibrary()(可在任何时候调用它,但这样做更恰当)System.loadLibrary()将一个DLL载入内存,并建立同它的链接。DLL必须位于您的系统路径,或者在包含了Java类文件的目录中。根据具体的平台,JVM会自动添加适当的文件扩展名。

1. C头文件生成器:javah

现在编译您的Java源文件,并对编译出来的.class文件运行javahjavah是在1.0版里提供的,但由于我们要使用Java 1.1 JNI,所以必须指定-jni参数:

javah -jni ShowMsgBox

javah会读入类文件,并为每个固有方法声明在C或C++头文件里生成一个函数原型。下面是输出结果——ShowMsgBox.h源文件(为符合本书的要求,稍微进行了一下修改):

/* DO NOT EDIT THIS FILE
   - it is machine generated */
#include <jni.h>
/* Header for class ShowMsgBox */

#ifndef _Included_ShowMsgBox
#define _Included_ShowMsgBox
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     ShowMsgBox
 * Method:    ShowMessage
 * Signature: (Ljava/lang/String;)V
 */
JNIEXPORT void JNICALL
Java_ShowMsgBox_ShowMessage
  (JNIEnv *, jobject, jstring);

#ifdef __cplusplus
}
#endif
#endif

#ifdef_cplusplus这个预处理引导命令可以看出,该文件既可由C编译器编译,亦可由C++编译器编译。第一个#include命令包括jni.h——一个头文件,作用之一是定义在文件其余部分用到的类型;JNIEXPORTJNICALL是一些宏,它们进行了适当的扩充,以便与那些不同平台专用的引导命令配合;JNIEnvjobject以及jstring则是JNI数据类型定义。

2. 名称管理和函数签名

JNI统一了固有方法的命名规则;这一点是非常重要的,因为它属于虚拟机将Java调用与固有方法链接起来的机制的一部分。从根本上说,所有固有方法都要以一个“Java”起头,后面跟随Java方法的名字;下划线字符则作为分隔符使用。若Java固有方法“重载”(即命名重复),那么也把函数签名追加到名字后面。在原型前面的注释里,大家可看到固有的签名。欲了解命名规则和固有方法签名更详细的情况,请参考相应的JNI文档。

3. 实现自己的DLL

此时,我们要做的全部事情就是写一个C或C++源文件,在其中包含由javah生成的头文件;并实现固有方法;然后编译它,生成一个动态链接库。这一部分的工作是与平台有关的,所以我假定读者已经知道如何创建一个DLL。通过调用一个Win32 API,下面的代码实现了固有方法。随后,它会编译和链接到一个名为MsgImpl.dll的文件里:

#include <windows.h>
#include "ShowMsgBox.h"

BOOL APIENTRY DllMain(HANDLE hModule,
  DWORD dwReason, void** lpReserved) {
  return TRUE;
}

JNIEXPORT void JNICALL
Java_ShowMsgBox_ShowMessage(JNIEnv * jEnv,
  jobject this, jstring jMsg) {
  const char * msg;
  msg = (*jEnv)->GetStringUTFChars(jEnv, jMsg,0);
  MessageBox(HWND_DESKTOP, msg,
    "Thinking in Java: JNI",
    MB_OK | MB_ICONEXCLAMATION);
  (*jEnv)->ReleaseStringUTFChars(jEnv, jMsg,msg);
}

若对Win32没有兴趣,只需跳过MessageBox()调用;最有趣的部分是它周围的代码。传递到固有方法内部的参数是返回Java的大门。第一个参数是类型JNIEnv的,其中包含了回调JVM需要的所有挂钩(下一节再详细讲述)。由于方法的类型不同,第二个参数也有自己不同的含义。对于象上例那样的非static方法(也叫作实例方法),第二个参数等价于C++的this指针,并类似于Java的this:都引用了调用固有方法的那个对象。对于static方法,它是对特定Class对象的一个引用,方法就是在那个Class对象里实现的。

剩余的参数代表传递到固有方法调用里的Java对象。基本类型也是以这种形式传递的,但它们进行的“按值”传递。

在后面的小节里,我们准备讲述如何从一个固有方法的内部访问和控制JVM,同时对上述代码进行更详尽的解释。

A.1.2 访问JNI函数:JNIEnv参数

利用JNI函数,程序员可从一个固有方法的内部与JVM打交道。正如大家在前面的例子中看到的那样,每个JNI固有方法都会接收一个特殊的参数作为自己的第一个参数:JNIEnv参数——它是指向类型为JNIEnv_的一个特殊JNI数据结构的指针。JNI数据结构的一个元素是指向由JVM生成的一个数组的指针;该数组的每个元素都是指向一个JNI函数的指针。可从固有方法的内部发出对JNI函数的调用,做法是撤消对这些指针的引用(具体的操作实际很简单)。每种JVM都以自己的方式实现了JNI函数,但它们的地址肯定位于预先定义好的偏移处。

利用JNIEnv参数,程序员可访问一系列函数。这些函数可划分为下述类别:

  • 获取版本信息
  • 进行类和对象操作
  • 控制对Java对象的全局和局部引用
  • 访问实例字段和静态字段
  • 调用实例方法和静态方法
  • 执行字符串和数组操作
  • 产生和控制Java异常

JNI函数的数量相当多,这里不再详述。相反,我会向大家揭示使用这些函数时背后的一些基本原理。欲了解更详细的情况,请参阅自己所用编译器的JNI文档。

若观察一下jni.h头文件,就会发现在#ifdef _cplusplus预处理器条件的内部,当由C++编译器编译时,JNIEnv_结构被定义成一个类。这个类包含了大量内嵌函数。通过一种简单而且熟悉的语法,这些函数让我们可以从容访问JNI函数。例如,前例包含了下面这行代码:

(*jEnv)->ReleaseStringUTFChars(jEnv, jMsg,msg);

它在C++里可改写成下面这个样子:

jEnv->ReleaseStringUTFChars(jMsg,msg);

大家可注意到自己不再需要同时撤消对jEnv的两个引用,相同的指针不再作为第一个参数传递给JNI函数调用。在这些例子剩下的地方,我会使用C++风格的代码。

1. 访问Java字符串

作为访问JNI函数的一个例子,请思考上述的代码。在这里,我们利用JNIEnv的参数jEnv来访问一个Java字符串。Java字符串采取的是Unicode格式,所以假若收到这样一个字符串,并想把它传给一个非Unicode函数(如printf()),首先必须用JNI函数GetStringUTFChars()将其转换成ASCII字符。该函数能接收一个Java字符串,然后把它转换成UTF-8字符(用8位宽度容纳ASCII值,或用16位宽度容纳Unicode;若原始字符串的内容完全由ASCII构成,那么结果字符串也是ASCII)。

GetStringUTFCharsJNIEnv间接指向的那个结构里的一个字段,而这个字段又是指向一个函数的指针。为访问JNI函数,我们用传统的C语法来调用一个函数(通过指针)。利用上述形式可实现对所有JNI函数的访问。

A.1.3 传递和使用Java对象

在前例中,我们将一个字符串传递给固有方法。事实上,亦可将自己创建的Java对象传递给固有方法。

在我们的固有方法内部,可访问已收到的那些对象的字段及方法。

为传递对象,声明固有方法时要采用原始的Java语法。如下例所示,MyJavaClass有一个public(公共)字段,以及一个public方法。UseObjects类声明了一个固有方法,用于接收MyJavaClass类的一个对象。为调查固有方法是否能控制自己的参数,我们设置了参数的public字段,调用固有方法,然后打印出public字段的值。

class MyJavaClass {
  public void divByTwo() { aValue /= 2; }
  public int aValue;
}

public class UseObjects {
  public static void main(String [] args) {
    UseObjects app = new UseObjects();
    MyJavaClass anObj = new MyJavaClass();
    anObj.aValue = 2;
    app.changeObject(anObj);
    System.out.println("Java: " + anObj.aValue);
  }
  private native void
  changeObject(MyJavaClass obj);
  static {
    System.loadLibrary("UseObjImpl");
  }
}

编译好代码,并将.class文件传递给javah后,就可以实现固有方法。在下面这个例子中,一旦取得字段和方法ID,就会通过JNI函数访问它们。

JNIEXPORT void JNICALL
Java_UseObjects_changeObject(
  JNIEnv * env, jobject jThis, jobject obj) {
  jclass cls;
  jfieldID fid;
  jmethodID mid;
  int value;
  cls = env->GetObjectClass(obj);
  fid = env->GetFieldID(cls,
        "aValue", "I");
  mid = env->GetMethodID(cls,
        "divByTwo", "()V");
  value = env->GetIntField(obj, fid);
  printf("Native: %d\n", value);
  env->SetIntField(obj, fid, 6);
  env->CallVoidMethod(obj, mid);
  value = env->GetIntField(obj, fid);
  printf("Native: %d\n", value);
}

除第一个参数外,C++函数会接收一个jobject,它代表Java对象引用“固有”的那一面——那个引用是我们从Java代码里传递的。我们简单地读取aValue,把它打印出来,改变这个值,调用对象的divByTwo()方法,再将值重新打印一遍。

为访问一个字段或方法,首先必须获取它的标识符。利用适当的JNI函数,可方便地取得类对象、元素名以及签名信息。这些函数会返回一个标识符,利用它可访问对应的元素。尽管这一方式显得有些曲折,但我们的固有方法确实对Java对象的内部布局一无所知。因此,它必须通过由JVM返回的索引访问字段和方法。这样一来,不同的JVM就可实现不同的内部对象布局,同时不会对固有方法造成影响。

若运行Java程序,就会发现从Java那一侧传来的对象是由我们的固有方法处理的。但传递的到底是什么呢?是指针,还是Java引用?而且垃圾收集器在固有方法调用期间又在做什么呢?

垃圾收集器会在固有方法执行期间持续运行,但在一次固有方法调用期间,我们的对象可保证不会被当作“垃圾”收集去。为确保这一点,事先创建了“局部引用”,并在固有方法调用之后立即清除。由于它们的“生命期”与调用过程息息相关,所以能够保证对象在固有方法调用期间的有效性。

由于这些引用会在每次函数调用的时候创建和析构,所以不可在static变量中制作固有方法的局部副本(本地拷贝)。若希望一个引用在函数存在期间持续有效,就需要一个全局引用。全局引用不是由JVM创建的,但通过调用特定的JNI函数,程序员可将局部引用扩展为全局引用。创建一个全局引用时,需对引用对象的“生存时间”负责。全局引用(以及它引用的对象)会一直留在内存里,直到用特定的JNI函数明确释放了这个引用。它类似于C的malloc()free()

A.1.4 JNI和Java异常

利用JNI,可丢弃、捕捉、打印以及重新丢弃Java异常,就象在一个Java程序里那样。但对程序员来说,需自行调用专用的JNI函数,以便对异常进行处理。下面列出用于异常处理的一些JNI函数:

  • Throw():丢弃一个现有的异常对象;在固有方法中用于重新丢弃一个异常。
  • ThrowNew():生成一个新的异常对象,并将其丢弃。
  • ExceptionOccurred():判断一个异常是否已被丢弃,但尚未清除。
  • ExceptionDescribe():打印一个异常和栈跟踪信息。
  • ExceptionClear():清除一个待决的异常。
  • FatalError():造成一个严重错误,不返回。

在所有这些函数中,最不能忽视的就是ExceptionOccurred()ExceptionClear()。大多数JNI函数都能产生异常,而且没有象在Java的try块内的那种语言特性可供利用。所以在每一次JNI函数调用之后,都必须调用ExceptionOccurred(),了解异常是否已被丢弃。若侦测到一个异常,可选择对其加以控制(可能时还要重新丢弃它)。然而,必须确保异常最终被清除。这可以在自己的函数中用ExceptionClear()来实现;若异常被重新丢弃,也可能在其他某些函数中进行。但无论如何,这一工作是必不可少的。

我们必须保证异常被彻底清除。否则,假若在一个异常待决的情况下调用一个JNI函数,获得的结果往往是无法预知的。也有少数几个JNI函数可在异常时安全调用;当然,它们都是专门的异常控制函数。

A.1.5 JNI和线程处理

由于Java是一种多线程语言,几个线程可能同时发出对一个固有方法的调用(若另一个线程发出调用,固有方法可能在运行期间暂停)。此时,完全要由程序员来保证固有调用在多线程的环境中安全进行。例如,要防范用一种未进行监视的方法修改共享数据。此时,我们主要有两个选择:将固有方法声明为“同步”,或在固有方法内部采取其他某些策略,确保数据处理正确地并发进行。

此外,绝对不要通过线程传递JNIEnv,因为它指向的内部结构是在“每线程”的基础上分配的,而且包含了只对那些特定的线程才有意义的信息。

A.1.6 使用现成代码

为实现JNI固有方法,最简单的方法就是在一个Java类里编写固有方法的原型,编译那个类,再通过javah运行.class文件。但假若我们已有一个大型的、早已存在的代码库,而且想从Java里调用它们,此时又该如何是好呢?不可将DLL中的所有函数更名,使其符合JNI命名规则,这种方案是不可行的。最好的方法是在原来的代码库“外面”写一个封装DLL。Java代码会调用新DLL里的函数,后者再调用原始的DLL函数。这个方法并非仅仅是一种解决方案;大多数情况下,我们甚至必须这样做,因为必须面向对象引用调用JNI函数,否则无法使用它们。

A.2 微软的解决方案

到本书完稿时为止,微软仍未提供对JNI的支持,只是用自己的专利方法提供了对非Java代码调用的支持。这一支持内建到编译器Microsoft JVM以及外部工具中。只有程序用Microsoft Java编译器编译,而且只有在Microsoft Java虚拟机(JVM)上运行的时候,本节讲述的特性才会有效。若计划在因特网上发行自己的应用,或者本单位的内联网建立在不同平台的基础上,就可能成为一个严重的问题。

微软与Win32代码的接口为我们提供了连接Win32的三种途径:

(1) J/Direct:方便调用Win32 DLL函数的一种途径,具有某些限制。

(2) 本原接口(RNI):可调用Win32 DLL函数,但必须自行解决“垃圾收集”问题。

(3) Java/COM集成:可从Java里直接揭示或调用COM服务。

后续的小节将分别探讨这三种技术。

写作本书的时候,这些特性均通过了Microsoft SDK for Java 2.0 beta 2的支持。可从微软公司的Web站点下载这个开发平台(要经历一个痛苦的选择过程,他们叫作“Active Setup”)。Java SDK是一套命令行工具的集合,但编译引擎可轻易嵌入Developer Studio环境,以便我们用Visual J++ 1.1来编译Java 1.1代码。

A.3 J/Direct

J/Direct是调用Win32 DLL函数最简单的方式。它的主要设计目标是与Win32API打交道,但完全可用它调用其他任何API。但是,尽管这一特性非常方便,但它同时也造成了某些限制,且降低了性能(与RNI相比)。但J/Direct也有一些明显的优点。首先,除希望调用的那个DLL里的代码之外,没有必要再编写额外的非Java代码,换言之,我们不需要一个包装器或者代理/存根DLL。其次,函数参数与标准数据类型之间实现了自动转换。若必须传递用户自定义的数据类型,那么J/Direct可能不按我们的希望工作。第三,就象下例展示的那样,它非常简单和直接。只需少数几行,这个例子便能调用Win32 API函数MessageBox(),它能弹出一个小的模态窗口,并带有一个标题、一条消息、一个可选的图标以及几个按钮。

public class ShowMsgBox {
  public static void main(String args[])
  throws UnsatisfiedLinkError   {
    MessageBox(0,
      "Created by the MessageBox() Win32 func",
      "Thinking in Java", 0);
  }
  /** @dll.import("USER32") */
  private static native int
  MessageBox(int hwndOwner, String text,
    String title, int fuStyle);
}

令人震惊的是,这里便是我们利用J/Direct调用Win32 DLL函数所需的全部代码。其中的关键是位于示范代码底部的MessageBox()声明之前的@dll.import引导命令。它表面上看是一条注释,但实际并非如此。它的作用是告诉编译器:引导命令下面的函数是在USER32 DLL里实现的,而且应相应地调用。我们要做的全部事情就是提供与DLL内实现的函数相符的一个原型,并调用函数。但是毋需在Java版本里手工键入需要的每一个Win32 API函数,一个Microsoft Java包会帮我们做这件事情(很快就会详细解释)。为了让这个例子正常工作,函数必须“按名称”由DLL导出。但是,也可以用@dll.import引导命令“按顺序”链接。举个例子来说,我们可指定函数在DLL里的入口位置。稍后还会具体讲述@dll.import引导命令的特性。

用非Java代码进行链接的一个重要问题就是函数参数的自动配置。正如大家看到的那样,MessageBox()的Java声明采用了两个字符串参数,但原来的C方案则采用了两个char指针。编译器会帮助我们自动转换标准数据类型,同时遵照本章后一节要讲述的规则。

最好,大家或许已注意到了main()声明中的UnsatisfiedLinkError异常。在运行期的时候,一旦链接程序不能从非Java函数里解析出符号,就会触发这一异常(事件)。这可能是由多方面的原因造成的:.dll文件未找到;不是一个有效的DLL;或者J/Direct未获您所使用的虚拟机的支持。为了使DLL能被找到,它必须位于WindowsWindows\System目录下,位于由PATH环境变量列出的一个目录中,或者位于和.class文件相同的目录。J/Direct获得了Microsoft Java编译器1.02.4213版本及更高版本的支持,也获得了Microsoft JVM 4.79.2164及更高版本的支持。为了解自己编译器的版本号,请在命令行下运行JVC,不要加任何参数。为了解JVM的版本号,请找到msjava.dll的图标,并利用右键弹出菜单观察它的属性。

A.3.1 @dll.import引导命令

作为使用J/Direct唯一的途径,@dll.import引导命令相当灵活。它提供了为数众多的修改符,可用它们自定义同非Java代码建立链接关系的方式。它亦可应用于类内的一些方法,或应用于整个类。也就是说,我们在那个类内声明的所有方法都是在相同的DLL里实现的。下面让我们具体研究一下这些特性。

1. 别名处理和按顺序链接

为了使@dll.import引导命令能象上面显示的那样工作,DLL内的函数必须按名字导出。然而,我们有时想使用与DLL里原始名字不同的一个名字(别名处理),否则函数就可能按编号(比如按顺序)导出,而不是按名字导出。下面这个例子声明了FinestraDiMessaggio()(用意大利语说的MessageBox)。正如大家看到的那样,使用的语法是非常简单的。

public class Aliasing {
  public static void main(String args[])
  throws UnsatisfiedLinkError   {
    FinestraDiMessaggio(0,
      "Created by the MessageBox() Win32 func",
      "Thinking in Java", 0);
  }
  /** @dll.import("USER32",
  entrypoint="MessageBox") */
  private static native int
  FinestraDiMessaggio(int hwndOwner, String text,
    String title, int fuStyle);
}

下面这个例子展示了如何同DLL里并非按名字导出的一个函数建立链接,那个函数事实是按它们在DLL里的位置导出的。这个例子假设有一个名为MYMATH的DLL,这个DLL在位置编号3处包含了一个函数。那个函数获取两个整数作为参数,并返回两个整数的和。

public class ByOrdinal {
  public static void main(String args[])
  throws UnsatisfiedLinkError {
    int j=3, k=9;
    System.out.println("Result of DLL function:"
      + Add(j,k));
  }
  /** @dll.import("MYMATH", entrypoint = "#3") */
  private static native int Add(int op1,int op2);
}

可以看出,唯一的差异就是entrypoint参数的形式。

2. 将@dll.import应用于整个类

@dll.import引导命令可应用于整个类。也就是说,那个类的所有方法都是在相同的DLL里实现的,并具有相同的链接属性。引导命令不会由子类继承;考虑到这个原因,而且由于DLL里的函数是自然的static函数,所以更佳的设计模式是将API函数封装到一个独立的类里,如下所示:

/** @dll.import("USER32") */
class MyUser32Access {
  public static native int
  MessageBox(int hwndOwner, String text,
    String title, int fuStyle);
  public native static boolean
  MessageBeep(int uType);
}

public class WholeClass {
  public static void main(String args[])
  throws UnsatisfiedLinkError {
    MyUser32Access.MessageBeep(4);
    MyUser32Access.MessageBox(0,
      "Created by the MessageBox() Win32 func",
      "Thinking in Java", 0);
  }
}

由于MessageBeep()MessageBox()函数已在不同的类里被声明成static函数,所以必须在调用它们时规定作用域。大家也许认为必须用上述的方法将所有Win32 API(函数、常数和数据类型)都映射成Java类。但幸运的是,根本不必这样做。

A.3.2 com.ms.win32

Win32 API的体积相当庞大——包含了数以千计的函数、常数以及数据类型。当然,我们并不想将每个Win32 API函数都写成对应Java形式。微软考虑到了这个问题,发行了一个Java包,可通过J/Direct将Win32 API映射成Java类。这个包的名字叫作com.ms.win32。安装Java SDK 2.0时,若在安装选项中进行了相应的设置,这个包就会安装到我们的类路径中。这个包由大量Java类构成,它们完整再现了Win32 API的常数、数据类型以及函数。包容能力最大的三个类是User32.classKernel.class以及Gdi32.class。它们包含的是Win32 API的核心内容。为使用它们,只需在自己的Java代码里导入即可。前面的ShowMsgBox示例可用com.ms.win32改写成下面这个样子(这里也考虑到了用更恰当的方式使用UnsatisfiedLinkError):

import com.ms.win32.*;

public class UseWin32Package {
  public static void main(String args[]) {
    try {
      User32.MessageBeep(
        winm.MB_ICONEXCLAMATION);
      User32.MessageBox(0,
        "Created by the MessageBox() Win32 func",
        "Thinking in Java",
        winm.MB_OKCANCEL |
        winm.MB_ICONEXCLAMATION);
    } catch(UnsatisfiedLinkError e) {
      System.out.println("Can’t link Win32 API");
      System.out.println(e);
    }
  }
}

Java包是在第一行导入的。现在,可在不进行其他声明的前提下调用MessageBeep()MessageBox()函数。在MessageBeep()里,我们可看到包导入时也声明了Win32常数。这些常数是在大量Java接口里定义的,全部命名为winxx代表欲使用之常数的首字母)。

写作本书时,com.ms.win32包的开发仍未正式完成,但已可堪使用。

A.3.3 汇集

“汇集”(Marshaling)是指将一个函数参数从它原始的二进制形式转换成与语言无关的某种形式,再将这种通用形式转换成适合调用函数采用的二进制格式。在前面的例子中,我们调用了MessageBox()函数,并向它传递了两个字符串。MessageBox()是个C函数,而且Java字符串的二进制布局与C字符串并不相同。但尽管如此,参数仍获得了正确的传递。这是由于在调用C代码前,J/Direct已帮我们考虑到了将Java字符串转换成C字符串的问题。这种情况适合所有标准的Java类型。下面这张表格总结了简单数据类型的默认对应关系:

Java C
byte BYTECHAR
short SHORTWORD
int INTUINTLONGULONGDWORD
char TCHAR
long __int64
float Float
double Double
boolean BOOL
String LPCTSTR(只允许在OLE模式中作为返回值)
byte[] BYTE *
short[] WORD *
char[] TCHAR *
int[] DWORD *

这个列表还可继续下去,但已很能说明问题了。大多数情况下,我们不必关心与简单数据类型之间的转换问题。但一旦必须传递用户自定义类型的参数,情况就立即变得不同了。例如,可能需要传递一个结构化的、用户自定义的数据类型,或者需要把一个指针传给原始内存区域。在这些情况下,有一些特殊的编译引导命令标记一个Java类,使其能作为一个指针传给结构(@dll.struct引导命令)。欲知使用这些关键字的细节,请参考产品文档。

A.3.4 编写回调函数

有些Win32 API函数要求将一个函数指针作为自己的参数使用。Windows API函数随后就可以调用参数函数(通常是在以后发生特定的事件时)。这一技术就叫作“回调函数”。回调函数的例子包括窗口进程以及我们在打印过程中设置的回调(为后台打印程序提供回调函数的地址,使其能更新状态,并在必要的时候中止打印)。

另一个例子是API函数EnumWindows(),它能枚举目前系统内所有顶级窗口。EnumWindows()要求获取一个函数指针作为自己的参数,然后搜索由Windows内部维护的一个列表。对于列表内的每个窗口,它都会调用回调函数,将窗口引用作为一个参数传给回调。

为了在Java里达到同样的目的,必须使用com.ms.dll包里的Callback类。我们从Callback里继承,并取消callback()。这个方法只能接近int参数,并会返回intvoid。方法签名和具体的实现取决于使用这个回调的Windows API函数。

现在,我们要进行的全部工作就是创建这个Callback派生类的一个实例,并将其作为函数指针传递给API函数。随后,J/Direct会帮助我们自动完成剩余的工作。

下面这个例子调用了Win32 API函数EnumWindows()EnumWindowsProc类里的callback()方法会获取每个顶级窗口的引用,获取标题文字,并将其打印到控制台窗口。

import com.ms.dll.*;
import com.ms.win32.*;

class EnumWindowsProc extends Callback {
  public boolean callback(int hwnd, int lparam) {
    StringBuffer text = new StringBuffer(50);
    User32.GetWindowText(
      hwnd, text, text.capacity()+1);
    if(text.length() != 0)
      System.out.println(text);
    return true;  // to continue enumeration.
  }
}

public class ShowCallback {
  public static void main(String args[])
  throws InterruptedException {
    boolean ok = User32.EnumWindows(
      new EnumWindowsProc(), 0);
    if(!ok)
      System.err.println("EnumWindows failed.");
    Thread.currentThread().sleep(3000);
  }
}

sleep()的调用允许窗口进程在main()退出前完成。

A.3.5 其他J/Direct特性

通过@dll.import引导命令内的修改符(标记),还可用到J/Direct的另两项特性。第一项是对OLE函数的简化访问,第二项是选择API函数的ANSI及Unicode版本。

根据约定,所有OLE函数都会返回类型为HRESULT的一个值,它是由COM定义的一个结构化整数值。若在COM那一级编写程序,并希望从一个OLE函数里返回某些不同的东西,就必须将一个特殊的指针传递给它——该指针指向函数即将在其中填充数据的那个内存区域。但在Java中,我们没有指针可用;此外,这种方法并不简练。利用J/Direct,我们可在@dll.import引导命令里使用ole修改符,从而方便地调用OLE函数。标记为ole函数的一个固有方法会从Java形式的方法签名(通过它决定返回类型)自动转换成COM形式的函数。

第二项特性是选择ANSI或者Unicode字符串控制方法。对字符串进行控制的大多数Win32 API函数都提供了两个版本。例如,假设我们观察由USER32.DLL导出的符号,那么不会找到一个MessageBox()函数,相反会看到MessageBoxA()MessageBoxW()函数——分别是该函数的ANSI和Unicode版本。如果在@dll.import引导命令里不规定想调用哪个版本,JVM就会试着自行判断。但这一操作会在程序执行时花费较长的时间。所以,我们一般可用ansiunicodeauto修改符硬性规定。

欲了解这些特性更详细的情况,请参考微软公司提供的技术文档。

A.4 本原接口(RNI)

同J/Direct相比,RNI是一种比非Java代码复杂得多的接口;但它的功能也十分强大。RNI比J/Direct更接近于JVM,这也使我们能写出更有效的代码,能处理固有方法中的Java对象,而且能实现与JVM内部运行机制更紧密的集成。

RNI在概念上类似Sun公司的JNI。考虑到这个原因,而且由于该产品尚未正式完工,所以我只在这里指出它们之间的主要差异。欲了解更详细的情况,请参考微软公司的文档。

JNI和RNI之间存在几方面引人注目的差异。下面列出的是由msjavah生成的C头文件(微软提供的msjavah在功能上相当于Sun的javah),应用于前面在JNI例子里使用的Java类文件ShowMsgBox

/*  DO NOT EDIT -
automatically generated by msjavah  */
#include <native.h>
#pragma warning(disable:4510)
#pragma warning(disable:4512)
#pragma warning(disable:4610)

struct Classjava_lang_String;
#define Hjava_lang_String Classjava_lang_String

/*  Header for class ShowMsgBox  */

#ifndef _Included_ShowMsgBox
#define _Included_ShowMsgBox

#define HShowMsgBox ClassShowMsgBox
typedef struct ClassShowMsgBox {
#include <pshpack4.h>
  long MSReserved;
#include <poppack.h>
} ClassShowMsgBox;

#ifdef __cplusplus
extern "C" {
#endif
__declspec(dllexport) void __cdecl
ShowMsgBox_ShowMessage (struct HShowMsgBox *,
  struct Hjava_lang_String *);
#ifdef __cplusplus
}
#endif

#endif  /* _Included_ShowMsgBox */

#pragma warning(default:4510)
#pragma warning(default:4512)
#pragma warning(default:4610)

除可读性较差外,代码里还隐藏着一些技术性问题,待我一一道来。

在RNI中,固有方法的程序员知道对象的二进制布局。这样便允许我们直接访问自己希望的信息;我们不必象在JNI里那样获得一个字段或方法标识符。但由于并非所有虚拟机都需要将相同的二进制布局应用于自己的对象,所以上面的固有方法只能在Microsoft JVM下运行。

在JNI中,通过JNIEnv参数可访问大量函数,以便同JVM打交道。在RNI中,用于控制JVM运作的函数变成了可直接调用。它们中的某一些(如控制异常的那一个)类似于它们的JNI“兄弟”。但大多数RNI函数都有与JNI中不同的名字和用途。

JNI和RNI最重大的一个区别是“垃圾收集”的模型。在JNI中,垃圾收集在固有方法执行期间遵守与Java代码执行时相同的规则。而在RNI中,要由程序员在固有方法活动期间自行负责“垃圾收集器”器的启动与中止。默认情况下,垃圾收集器在进入固有方法前处于不活动状态;这样一来,程序员就可假定准备使用的对象用不着在那个时间段内进行垃圾收集。然而一旦固有方法准备长时间执行,程序员就应考虑激活垃圾收集器——通过调用GCEnable()这个RNI函数(GC是“Garbage Collector”的缩写,即“垃圾收集”)。

也存在与全局引用特性类似的机制——程序员可利用可保证特定的对象在GC活动期间不至于被当作“垃圾”收掉。概念是类似的,但名称有所差异——在RNI中,人们把它叫作GCFrames

A.4.1 RNI总结

RNI与Microsoft JVM紧密集成这一事实既是它的优点,也是它的缺点。RNI比JNI复杂得多,但它也为我们提供了对JVM内部活动的高度控制;其中包括垃圾收集。此外,它显然针对速度进行了优化,采纳了C程序员熟悉的一些折衷方案和技术。但除了微软的JVM之外,它并不适于其他JVM。

A.5 Java/COM集成

COM(以前称为OLE)代表微软公司的“组件对象模型”(Component Object Model),它是所有ActiveX技术(包括ActiveX控件、Automation以及ActiveX文档)的基础。但COM还包含了更多的东西。它是一种特殊的规范,按照它开发出来的组件对象可通过操作系统的专门特性实现“相互操作”。在实际应用中,为Win32系统开发的所有新软件都与COM有着一定的关系——操作系统通过COM对象揭示出自己的一些特性。由其他厂商开发的组件也可以建立在COM的基础上,我们能创建和注册自己的COM组件。通过这样或那样的形式,如果我们想编写Win32代码,那么必须和COM打交道。在这里,我们将仅仅重述COM编程的基本概念,而且假定读者已掌握了COM服务器(能为COM客户提供服务的任何COM对象)以及COM客户(能从COM服务器那里申请服务的一个COM对象)的概念。本节将尽可能地使叙述变得简单。工具实际的功能要强大得多,而且我们可通过更高级的途径来使用它们。但这也要求对COM有着更深刻的认识,那已经超出了本附录的范围。如果您对这个功能强大、但与不同平台有关的特性感兴趣,应该研究COM和微软公司的文档资料,仔细阅读有关Java/COM集成的那部分内容。如果想获得更多的资料,向您推荐Dale Rogerson编著的《Inside COM》,该书由Microsoft Press于1997年出版。

由于COM是所有新型Win32应用程序的结构核心,所以通过Java代码使用(或揭示)COM服务的能力就显得尤为重要。Java/COM集成无疑是Microsoft Java编译器以及虚拟机最有趣的特性。Java和COM在它们的模型上是如此相似,所以这个集成在概念上是相当直观的,而且在技术上也能轻松实现无缝结合——为访问COM,几乎不需要编写任何特殊的代码。大多数技术细节都是由编译器和/或虚拟机控制的。最终的结果便是Java程序员可象对待原始Java对象那样对待COM对象。而且COM客户可象使用其他COM服务器那样使用由Java实现的COM服务器。在这里提醒大家,尽管我使用的是通用术语“COM”,但根据扩展,完全可用Java实现一个ActiveX Automation服务器,亦可在Java程序中使用一个ActiveX控件。

Java和COM最引人注目的相似之处就是COM接口与Java的interface关键字的关系。这是接近完美的一种相符,因为:

  • COM对象揭示出了接口(也只有接口)

  • COM接口本身并不具备实现方案;要由揭示出接口的那个COM对象负责它的实现

  • COM接口是对语义上相关的一组函数的说明;不会揭示出任何数据

  • COM类将COM接口组合到了一起。Java类可实现任意数量的Java接口。

  • COM有一个引用对象模型;程序员永远不可能“拥有”一个对象,只能获得对对象一个或多个接口的引用。Java也有一个引用对象模型——对一个对象的引用可“转换”成对它的某个接口的引用。

  • COM对象在内存里的“生存时间”取决于使用对象的客户数量;若这个数量变成零,对象就会将自己从内存中删去。在Java中,一个对象的生存时间也由客户的数量决定。若不再有对那个对象的引用,对象就会等候垃圾收集器的处理。

Java与COM之间这种紧密的对应关系不仅使Java程序员可以方便地访问COM特性,也使Java成为编写COM代码的一种有效语言。COM是与语言无关的,但COM开发事实上采用的语言是C++和Visual Basic。同Java相比,C++在进行COM开发时显得更加强大,并可生成更有效的代码,只是它很难使用。Visual Basic比Java简单得多,但它距离基础操作系统太远了,而且它的对象模型并未实现与

COM很好的对应(映射)关系。Java是两者之间一种很好的折衷方案。
接下来,让我们对COM开发的一些关键问题进行讨论。编写Java/COM客户和服务器时,这些问题是首先需要弄清楚的。

A.5.1 COM基础

COM是一种二进制规范,致力于实现可相互操作的对象。例如,COM认为一个对象的二进制布局必须能够调用另一个COM对象里的服务。由于是对二进制布局的一种描述,所以只要某种语言能生成这样的一种布局,就可通过它实现COM对象。通常,程序员不必关注象这样的一些低级细节,因为编译器可自动生成正确的布局。例如,假设您的程序是用C++写的,那么大多数编译器都能生成符合COM规范的一张虚拟函数表格。对那些不生成可执行代码的语言,比如VB和Java,在运行期则会自动挂接到COM。

COM库也提供了几个基本的函数,比如用于创建对象或查找系统中一个已注册COM类的函数。

一个组件对象模型的基本目标包括:

  • 让对象调用其他对象里的服务

  • 允许新类型对象(或更新对象)无缝插入环境

第一点正是面向对象程序设计要解决的问题:我们有一个客户对象,它能向一个服务器对象发出请求。在这种情况下,“客户”和“服务器”这两个术语是在常规意义上使用的,并非指一些特定的硬件配置。对于任何面向对象的语言,第一个目标都是很容易达到的——只要您的代码是一个完整的代码块,同时实现了服务器对象代码以及客户对象代码。若改变了客户和服务器对象相互间的沟通形式,只需简单地重新编译和链接一遍即可。重新启动应用程序时,它就会自动采用组件的最新版本。

但假若应用程序由一些未在自己控制之下的组件对象构成,情况就会变得迥然有异——我们不能控制它们的源码,而且它们的更新可能完全独立于我们的应用程序进行。例如,当我们在自己的程序里使用由其他厂商开发的ActiveX控件时,就会面临这一情况。控件会安装到我们的系统里,我们的程序能够(在运行期)定位服务器代码,激活对象,同它建立链接,然后使用它。以后,我们可安装控件的新版本,我们的应用程序应该仍然能够运行;即使在最糟的情况下,它也应礼貌地报告一条出错消息,比如“控件未找到”等等;一般不会莫名其妙地挂起或死机。

在这些情况下,我们的组件是在独立的可执行代码文件里实现的:DLL或EXE。若服务器对象在一个独立的可执行代码文件里实现,就需要由操作系统提供的一个标准方法,从而激活这些对象。当然,我们并不想在自己的代码里使用DLL或EXE的物理名称及位置,因为这些参数可能经常发生变化。此时,我们想使用的是由操作系统维护的一些标识符。另外,我们的应用程序需要对服务器展示出来的服务进行的一个描述。下面这两个小节将分别讨论这两个问题。

1. GUID和注册表

COM采用结构化的整数值(长度为128位)唯一性地标识系统中注册的COM项目。这些数字的正式名称叫作GUID(Globally Unique IDentifier,全局唯一标识符),可由特殊的工具生成。此外,这些数字可以保证在“任何空间和时间”里独一无二,没有重复。在空间,是由于数字生成器会读取网卡的ID号码;在时间,是由于同时会用到系统的日期和时间。可用GUID标识COM类(此时叫作CLSID)或者COM接口(IID)。尽管名字不同,但基本概念与二进制结构都是相同的。GUID亦可在其他环境中使用,这里不再赘述。

GUID以及相关的信息都保存在Windows注册表中,或者说保存在“注册数据库”(Registration Database)中。这是一种分级式的数据库,内建于操作系统中,容纳了与系统软硬件配置有关的大量信息。对于COM,注册表会跟踪系统内安装的组件,比如它们的CLSID、实现它们的可执行文件的名字及位置以及其他大量细节。其中一个比较重要的细节是组件的ProgID;ProgID在概念上类似于GUID,因为它们都标识着一个COM组件。区别在于GUID是一个二进制的、通过算法生成的值。而ProgID则是由程序员定义的字符串值。ProgID是随同一个CLSID分配的。

我们说一个COM组件已在系统内注册,最起码的一个条件就是它的CLSID和它的执行文件已存在于注册表中(ProgID通常也已就位)。在后面的例子里,我们主要任务就是注册与使用COM组件。

注册表的一项重要特点就是它作为客户和服务器对象之间的一个去耦层使用。利用注册表内保存的一些信息,客户会激活服务器;其中一项信息是服务器执行模块的物理位置。若这个位置发生了变动,注册表内的信息就会相应地更新。但这个更新过程对于客户来说是“透明”或者看不见的。后者只需直接使用ProgID或CLSID即可。换句话说,注册表使服务器代码的位置透明成为了可能。随着DCOM(分布式COM)的引入,在本地机器上运行的一个服务器甚至可移到网络中的一台远程机器,整个过程甚至不会引起客户对它的丝毫注意(大多数情况下如此)。

2. 类型库

由于COM具有动态链接的能力,同时由于客户和服务器代码可以分开独立发展,所以客户随时都要动态侦测由服务器展示出来的服务。这些服务是用“类型库”(Type Library)中一种二进制的、与语言无关的形式描述的(就象接口和方法签名)。它既可以是一个独立的文件(通常采用.TLB扩展名),也可以是链接到执行程序内部的一种Win32资源。运行期间,客户会利用类型库的信息调用服务器中的函数。

我们可以写一个Microsoft Interface Definition Language(微软接口定义语言,MIDL)源文件,用MIDL编译器编译它,从而生成一个.TLB文件。MIDL语言的作用是对COM类、接口以及方法进行描述。它在名称、语法以及用途上都类似OMB/CORBA IDL。然而,Java程序员不必使用MIDL。后面还会讲到另一种不同的Microsoft工具,它能读入Java类文件,并能生成一个类型库。

3. COM:HRESULT中的函数返回代码

由服务器展示出来的COM函数会返回一个值,采用预先定义好的HRESULT类型。HRESULT代表一个包含了三个字段的整数。这样便可使用多个失败和成功代码,同时还可以使用其他信息。由于COM函数返回的是一个HRESULT,所以不能用返回值从函数调用里取回原始数据。若必须返回数据,可传递指向一个内存区域的指针,函数将在那个区域里填充数据。我们把这称为“外部参数”。作为Java/COM程序员,我们不必过于关注这个问题,因为虚拟机会帮助我们自动照管一切。这个问题将在后续的小节里讲述。

A.5.2 MS Java/COM集成

同C++/COM程序员相比,Microsoft Java编译器、虚拟机以及各式各样的工具极大简化了Java/COM程序员的工作。编译器有特殊的引导命令和包,可将Java类当作COM类对待。但在大多数情况下,我们只需依赖Microsoft JVM为COM提供的支持,同时利用两个有力的外部工具。

Microsoft Java Virtual Machine(JVM)在COM和Java对象之间扮演了一座桥梁的角色。若将Java对象创建成一个COM服务器,那么我们的对象仍然会在JVM内部运行。Microsoft JVM是作为一个DLL实现的,它向操作系统展示出了COM接口。在内部,JVM将对这些COM接口的函数调用映射成Java对象中的方法调用。当然,JVM必须知道哪个Java类文件对应于服务器执行模块;之所以能够找出这方面的信息,是由于我们事前已用Javareg在Windows注册表内注册了类文件。Javareg是与Microsoft Java SDK配套提供的一个工具程序,能读入一个Java类文件,生成相应的类型库以及一个GUID,并可将类注册到系统内。亦可用Javareg注册远程服务器。例如,可用它注册在不同机器上运行的一个服务器。

如果想写一个Java/COM客户,必须经历一系列不同的步骤。Java/COM“客户”是一些特殊的Java代码,它们想激活和使用系统内注册的一个COM服务器。同样地,虚拟机会与COM服务器沟通,并将它提供的服务作为Java类内的各种方法展示(揭示)出来。另一个Microsoft工具是jactivex,它能读取一个类型库,并生成相应的Java源文件,在其中包含特殊的编译器引导命令。生成的源文件属于我们在指定类型库之后命名的一个包的一部分。下一步是在自己的COM客户Java源文件中导入那个包。

接下来让我们讨论两个例子。

A.5.3 用Java设计COM服务器

本节将介绍ActiveX控件、Automation服务器或者其他任何符合COM规范的服务器的开发过程。下面这个例子实现了一个简单的Automation服务器,它能执行整数加法。我们用setAddend()方法设置addend的值。每次调用sum()方法的时候,addend就会添加到当前result里。我们用getResult()获得result值,并用clear()重新设置值。用于实现这一行为的Java类是非常简单的:

public class Adder {
  private int addend;
  private int result;
  public void setAddend(int a) { addend = a; }
  public int getAddend() { return addend; }
  public int getResult() { return result; }
  public void sum() { result += addend;  }
  public void clear() {
    result = 0;
    addend = 0;
  }
}

为了将这个Java类作为一个COM对象使用,我们将Javareg工具应用于编译好的Adder.class文件。这个工具提供了一系列选项;在这种情况下,我们指定Java类文件名("Adder"),想为这个服务器在注册表里置入的ProgID("JavaAdder.Adder.1"),以及想为即将生成的类型库指定的名字("JavaAdder.tlb")。由于尚未给出CLSID,所以Javareg会自动生成一个。若我们再次对同样的服务器调用Javareg,就会直接使用现成的CLSID。

javareg /register
/class:Adder /progid:JavaAdder.Adder.1
/typelib:JavaAdder.tlb

Javareg也会将新服务器注册到Windows注册表。此时,我们必须记住将Adder.class复制到Windows\Java\trustlib目录。考虑到安全方面的原因(特别是涉及程序片调用COM服务的问题),只有在COM服务器已安装到trustlib目录的前提下,这些服务器才会被激活。

现在,我们已在自己的系统中安装了一个新的Automation服务器。为进行测试,我们需要一个Automation控制器,而Automation控制器就是Visual Basic(VB)。在下面,大家会看到几行VB代码。按照VB的格式,我设置了一个文本框,用它从用户那里接收要相加的值。并用一个标签显示结果,用两个下推按钮分别调用sum()clear()方法。最开始,我们声明了一个名为Adder的对象变量。在Form_Load子例程中(在窗体首次显示时载入),会调用Adder自动服务器的一个新实例,并对窗体的文本字段进行初始化。一旦用户按下Sum或者Clear按钮,就会调用服务器中对应的方法。

Dim Adder As Object

Private Sub Form_Load()
    Set Adder = CreateObject("JavaAdder.Adder.1")
    Addend.Text = Adder.getAddend
    Result.Caption = Adder.getResult
End Sub

Private Sub SumBtn_Click()
    Adder.setAddend (Addend.Text)
    Adder.Sum
    Result.Caption = Adder.getResult
End Sub

Private Sub ClearBtn_Click()
    Adder.Clear
    Addend.Text = Adder.getAddend
    Result.Caption = Adder.getResult
End Sub

注意,这段代码根本不知道服务器是用Java实现的。

运行这个程序并调用了CreateObject()函数以后,就会在Windows注册表里搜索指定的ProgID。在与ProgID有关的信息中,最重要的是Java类文件的名字。作为一个响应,会启动Java虚拟机,而且在JVM内部调用Java对象的实例。从那个时候开始,JVM就会自动接管客户和服务器代码之间的交流。

A.5.4 用Java设计COM客户

现在,让我们转到另一侧,并用Java开发一个COM客户。这个程序会调用系统已安装的COM服务器内的服务。就目前这个例子来说,我们使用的是在前一个例子里为服务器实现的一个客户。尽管代码在Java程序员的眼中看起来比较熟悉,但在幕后发生的一切却并不寻常。本例使用了用Java写成的一个服务器,但它可应用于系统内安装的任何ActiveX控件、ActiveX Automation服务器或者ActiveX组件——只要我们有一个类型库。

首先,我们将Jactivex工具应用于服务器的类型库。Jactivex有一系列选项和开关可供选择。但它最基本的形式是读取一个类型库,并生成Java源文件。这个源文件保存于我们的windows/java/trustlib目录中。通过下面这行代码,它应用于为外部COM Automation服务器生成的类型库:

jactivex /javatlb JavaAdder.tlb

Jactivex完成以后,我们再来看看自己的windows/java/trustlib目录。此时可在其中看到一个新的子目录,名为javaadder。这个目录包含了用于新包的源文件。这是在Java里与类型库的功能差不多的一个库。这些文件需要使用Microsoft编译器的专用引导命令:@comjactivex生成多个文件的原因是COM使用多个实体来描述一个COM服务器(另一个原因是我没有对MIDL文件和Java/COM工具的使用进行细致的调整)。

名为Adder.java的文件等价于MIDL文件中的一个coclass引导命令:它是对一个COM类的声明。其他文件则是由服务器揭示出来的COM接口的Java等价物。这些接口(比如Adder_DispatchDefault.java)都属于“分发”(Dispatch)接口,属于Automation控制器与Automation服务器之间的沟通机制的一部分。Java/COM集成特性也支持双接口的实现与使用。但是,IDispatch和双接口的问题已超出了本附录的范围。

在下面,大家可看到对应的客户代码。第一行只是导入由jactivex生成的包。然后创建并使用COM Automation服务器的一个实例,就象它是一个原始的Java类那样。请注意行内的类型模型,其中“例示”了COM对象(即生成并调用它的一个实例)。这与COM对象模型是一致的。在COM中,程序员永远不会得到对整个对象的一个引用。相反,他们只能拥有对类内实现的一个或多个接口的引用。

“例示”Adder类的一个Java对象以后,就相当于指示COM激活服务器,并创建这个COM对象的一个实例。但我们随后必须指定自己想使用哪个接口,在由服务器实现的接口中挑选一个。这正是类型模型完成的工作。这儿使用的是“默认遣送”接口,它是Automation控制器用于同一个Automation服务器通信的标准接口。欲了解这方面的细节,请参考由Ibid编著的《Inside COM》。请注意激活服务器并选择一个COM接口是多么容易!

import javaadder.*;

public class JavaClient {
  public static void main(String [] args) {
    Adder_DispatchDefault iAdder =
         (Adder_DispatchDefault) new Adder();
    iAdder.setAddend(3);
    iAdder.sum();
    iAdder.sum();
    iAdder.sum();
    System.out.println(iAdder.getResult());
  }
}

现在,我们可以编译它,并开始运行程序。

1. com.ms.com

com.ms.com包为COM的开发定义了数量众多的类。它支持GUID的使用——Variant(变体)和SafeArray Automation(安全数组自动)类型——能与ActiveX控件在一个较深的层次打交道,并可控制COM异常。

由于篇幅有限,这里不可能涉及所有这些主题。但我想着重强调一下COM异常的问题。根据规范,几乎所有COM函数都会返回一个HRESULT值,它告诉我们函数调用是否成功,以及失败的原因。但若观察服务器和客户代码中的Java方法签名,就会发现没有HRESULT。相反,我们用函数返回值从一些函数那里取回数据。“虚拟机”(VM)会将Java风格的函数调用转换成COM风格的函数调用,甚至包括返回参数。但假若我们在服务器里调用的一个函数在COM这一级失败,又会在虚拟机里出现什么事情呢?在这种情况下,JVM会认为HRESULT值标志着一次失败,并会产生类com.ms.com.ComFailException的一个固有Java异常。这样一来,我们就可用Java异常控制机制来管理COM错误,而不是检查函数的返回值。
如欲深入了解这个包内包含的类,请参考微软公司的产品文档。

A.5.5 ActiveX/Beans集成

Java/COM集成一个有趣的结果就是ActiveX/Beans的集成。也就是说,Java Bean可包含到象VB或任何一种Microsoft Office产品那样的ActiveX容器里。而一个ActiveX控件可包含到象Sun BeanBox这样的Beans容器里。Microsoft JVM会帮助我们考虑到所有的细节。一个ActiveX控件仅仅是一个COM服务器,它展示了预先定义好的、请求的接口。Bean只是一个特殊的Java类,它遵循特定的编程风格。但在写作本书的时候,这一集成仍然不能算作完美。例如,虚拟机不能将JavaBeans事件映射成为COM事件模型。若希望从ActiveX容器内部的一个Bean里对事件加以控制,Bean必须通过低级技术拦截象鼠标行动这类的系统事件,不能采用标准的JavaBeans委托事件模型。

抛开这个问题不管,ActiveX/Beans集成仍然是非常有趣的。由于牵涉的概念与工具与上面讨论的完全相同,所以请参阅您的Microsoft文档,了解进一步的细节。

A.5.6 固有方法与程序片的注意事项

固有方法为我们带来了安全问题的一些考虑。若您的Java代码发出对一个固有方法的调用,就相当于将控制权传递到了虚拟机“体系”的外面。固有方法拥有对操作系统的完全访问权限!当然,如果由自己编写固有方法,这正是我们所希望的。但这对程序片来说却是不可接受的——至少不能默许这样做。我们不想看到从因特网远程服务器下载回来的一个程序片自由自在地操作文件系统以及机器的其他敏感区域,除非特别允许它这样做。为了用J/Direct,RNI和COM集成防止此类情况的发生,只有受到信任(委托)的Java代码才有权发出对固有方法的调用。根据程序片的具体使用,必须满足不同的条件才可放行。例如,使用J/Direct的一个程序片必须拥有数字化签名,指出自己受到完全信任。在写作本书的时候,并不是所有这些安全机制都已实现(对于Microsoft SDK for Java,beta 2版本)。所以当新版本出现以后,请务必留意它的文档说明。

A.6 CORBA

在大型的分布式应用中,我们的某些要求并非前面讲述的方法能够满足的。举个例子来说,我们可能想同以前遗留下来的数据仓库打交道,或者需要从一个服务器对象里获取服务,无论它的物理位置在哪里。在这些情况下,都要求某种形式的“远程过程调用”(RPC),而且可能要求与语言无关。此时,CORBA可为我们提供很大的帮助。

CORBA并非一种语言特性,而是一种集成技术。它代表着一种具体的规范,各个开发商通过遵守这一规范,可设计出符合CORBA标准的集成产品。CORBA规范是由OMG开发出来的。这家非赢利性的机构致力于定义一个标准框架,从而实现分布式、与语言无关对象的相互操作。

利用CORBA,我们可实现对Java对象以及非Java对象的远程调用,并可与传统的系统进行沟通——采用一种“位置透明”的形式。Java增添了连网支持,是一种优秀的“面向对象”程序设计语言,可构建出图形化和非图形化的应用(程序)。Java和OMG对象模型存在着很好的对应关系;例如,无论Java还是CORBA都实现了“接口”的概念,并且都拥有一个引用(参考)对象模型。

A.6.1 CORBA基础

由OMG制订的对象相互操作规范通常称为“对象管理体系”(ObjectManagement Architecture,OMA)。OMA定义了两个组件:“核心对象模型”(Core Object Model)和“OMA参考体系”(OMA Reference Model)。OMA参考体系定义了一套基层服务结构及机制,实现了对象相互间进行操作的能力。OMA参考体系包括“对象请求代理”(Object Request Broker,ORB)、“对象服务”(Object Services,也称作CORBAservices)以及一些通用机制。

ORB是对象间相互请求的一条通信总线。进行请求时,毋需关心对方的物理位置在哪里。这意味着在客户代码中看起来象一次方案调用的过程实际是非常复杂的一次操作。首先,必须存在与服务器对象的一条连接途径。而且为了创建一个连接,ORB必须知道具体实现服务器的代码存放在哪里。建好连接后,必须对方法参数进行“汇集”。例如,将它们转换到一个二进制流里,以便通过网络传送。必须传递的其他信息包括服务器的机器名称、服务器进程以及对那个进程内的服务器对象进行标识的信息等等。最后,这些信息通过一种低级线路协议传递,信息在服务器那一端解码,最后正式执行调用。ORB将所有这些复杂的操作都从程序员眼前隐藏起来了,并使程序员的工作几乎和与调用本地对象的方法一样简单。

并没有硬性规定应如何实现ORB核心,但为了在不同开发商的ORB之间实现一种基本的兼容,OMG定义了一系列服务,它们可通过标准接口访问。

1. CORBA接口定义语言(IDL)

CORBA是面向语言的透明而设计的:一个客户对象可调用属于不同类的服务器对象方法,无论对方是用何种语言实现的。当然,客户对象事先必须知道由服务器对象揭示的方法名称及签名。这时便要用到IDL。CORBA IDL是一种与语言无关的设计方法,可用它指定数据类型、属性、操作、接口以及更多的东西。IDL的语法类似于C++或Java语法。下面这张表格为大家总结了三种语言一些通用概念,并展示了它们的对应关系。

CORBA IDL Java C++
模块(Module) 包(Package) 命名空间(Namespace)
接口(Interface) 接口(Interface) 纯抽象类(Pure abstract class)
方法(Method) 方法(Method) 成员函数(Member function)

继承概念也获得了支持——就象C++那样,同样使用冒号运算符。针对需要由服务器和客户实现和使用的属性、方法以及接口,程序员要写出一个IDL描述。随后,IDL会由一个由厂商提供的IDL/Java编译器进行编译,后者会读取IDL源码,并生成相应的Java代码。

IDL编译器是一个相当有用的工具:它不仅生成与IDL等价的Java源码,也会生成用于汇集方法参数的代码,并可发出远程调用。我们将这种代码称为“根干”(Stub and Skeleton)代码,它组织成多个Java源文件,而且通常属于同一个Java包的一部分。

2. 命名服务

命名服务属于CORBA基本服务之一。CORBA对象是通过一个引用访问的。尽管引用信息用我们的眼睛来看没什么意义,但可为引用分配由程序员定义的字符串名。这一操作叫作“引用的字符串化”。一个叫作“命名服务”(Naming Service)的OMA组件专门用于执行“字符串到对象”以及“对象到字符串”转换及映射。由于命名服务扮演了服务器和客户都能查询和操作的一个电话本的角色,所以它作为一个独立的进程运行。创建“对象到字符串”映射的过程叫作“绑定一个对象”;删除映射关系的过程叫作“取消绑定”;而让对象引用传递一个字符串的过程叫作“解析名称”。

比如在启动的时候,服务器应用可创建一个服务器对象,将对象同命名服务绑定起来,然后等候客户发出请求。客户首先获得一个服务器引用,解析出字符串名,然后通过引用发出对服务器的调用。

同样地,“命名服务”规范也属于CORBA的一部分,但实现它的应用程序是由ORB厂商(开发商)提供的。由于厂商不同,我们访问命名服务的方式也可能有所区别。

A.6.2 一个例子

这儿显示的代码可能并不详尽,因为不同的ORB有不同的方法来访问CORBA服务,所以无论什么例子都要取决于具体的厂商(下例使用了JavaIDL,这是Sun公司的一个免费产品。它配套提供了一个简化版本的ORB、一个命名服务以及一个“IDL→Java”编译器)。除此之外,由于Java仍处在发展初期,所以在不同的Java/CORBA产品里并不是包含了所有CORBA特性。

我们希望实现一个服务器,令其在一些机器上运行,其他机器能向它查询正确的时间。我们也希望实现一个客户,令其请求正确的时间。在这种情况下,我们让两个程序都用Java实现。但在实际应用中,往往分别采用不同的语言。

1. 编写IDL源码

第一步是为提供的服务编写一个IDL描述。这通常是由服务器程序员完成的。随后,程序员就可用任何语言实现服务器,只需那种语言里存在着一个CORBA IDL编译器。

IDL文件已分发给客户端的程序员,并成为两种语言间的桥梁。

下面这个例子展示了时间服务器的IDL描述情况:

module RemoteTime {
   interface ExactTime {
      string getTime();
   };
};

这是对RemoteTime命名空间内的ExactTime接口的一个声明。该接口由单独一个方法构成,它以字符串格式返回当前时间。

2. 创建根干

第二步是编译IDL,创建Java根干代码。我们将利用这些代码实现客户和服务器。与JavaIDL产品配套提供的工具是idltojava

idltojava -fserver -fclient RemoteTime.idl

其中两个标记告诉idltojava同时为根和干生成代码。idltojava会生成一个Java包,它在IDL模块、RemoteTime以及生成的Java文件置入RemoteTime子目录后命名。_ExactTimeImplBase.java代表我们用于实现服务器对象的“干”;

_ExactTimeStub.java将用于客户。在ExactTime.java中,用Java方式表示了IDL接口。此外还包含了用到的其他支持文件,例如用于简化访问命名服务的文件。

3. 实现服务器和客户

大家在下面看到的是服务器端使用的代码。服务器对象是在ExactTimeServer类里实现的。RemoteTimeServer这个应用的作用是:创建一个服务器对象,通过ORB为其注册,指定对象引用时采用的名称,然后“安静”地等候客户发出请求。

import RemoteTime.*;

import org.omg.CosNaming.*;
import org.omg.CosNaming.NamingContextPackage.*;
import org.omg.CORBA.*;

import java.util.*;
import java.text.*;

// Server object implementation
class ExactTimeServer extends _ExactTimeImplBase{
  public String getTime(){
    return DateFormat.
        getTimeInstance(DateFormat.FULL).
          format(new Date(
              System.currentTimeMillis()));
  }
}

// Remote application implementation
public class RemoteTimeServer {
  public static void main(String args[])  {
    try {
      // ORB creation and initialization:
      ORB orb = ORB.init(args, null);
      // Create the server object and register it:
      ExactTimeServer timeServerObjRef =
        new ExactTimeServer();
      orb.connect(timeServerObjRef);
      // Get the root naming context:
      org.omg.CORBA.Object objRef =
        orb.resolve_initial_references(
          "NameService");
      NamingContext ncRef =
        NamingContextHelper.narrow(objRef);
      // Assign a string name to the
      // object reference (binding):
      NameComponent nc =
        new NameComponent("ExactTime", "");
      NameComponent path[] = {nc};
      ncRef.rebind(path, timeServerObjRef);
      // Wait for client requests:
      java.lang.Object sync =
        new java.lang.Object();
      synchronized(sync){
        sync.wait();
      }
    }
    catch (Exception e)  {
      System.out.println(
         "Remote Time server error: " + e);
      e.printStackTrace(System.out);
    }
  }
}

正如大家看到的那样,服务器对象的实现是非常简单的;它是一个普通的Java类,从IDL编译器生成的“干”代码中继承而来。但在与ORB以及其他CORBA服务进行联系的时候,情况却变得稍微有些复杂。

4. 一些CORBA服务

这里要简单介绍一下JavaIDL相关代码所做的工作(注意暂时忽略了CORBA代码与不同厂商有关这一事实)。main()的第一行代码用于启动ORB。而且理所当然,这正是服务器对象需要同它进行沟通的原因。就在ORB初始化以后,紧接着就创建了一个服务器对象。实际上,它正式名称应该是“短期服务对象”:从客户那里接收请求,“生存时间”与创建它的进程是相同的。创建好短期服务对象后,就会通过ORB对其进行注册。这意味着ORB已知道它的存在,可将请求转发给它。

到目前为止,我们拥有的全部东西就是一个timeServerObjRef——只有在当前服务器进程里才有效的一个对象引用。下一步是为这个服务对象分配一个字符串形式的名字。客户会根据那个名字寻找服务对象。我们通过命名服务(Naming Service)完成这一操作。首先,我们需要对命名服务的一个对象引用。通过调用resolve_initial_references(),可获得对命名服务的字符串式对象引用(在JavaIDL中是NameService),并将这个引用返回。这是对采用narrow()方法的一个特定NamingContext引用的模型。我们现在可开始使用命名服务了。

为了将服务对象同一个字符串形式的对象引用绑定在一起,我们首先创建一个NameComponent对象,用ExactTime进行初始化。ExactTime是我们想用于绑定服务对象的名称字符串。随后使用rebind()方法,这是受限于对象引用的字符串化引用。我们用rebind()分配一个引用——即使它已经存在。而假若引用已经存在,那么bind()会造成一个异常。在CORBA中,名称由一系列NameContext构成——这便是我们为什么要用一个数组将名称与对象引用绑定起来的原因。

服务对象最好准备好由客户使用。此时,服务器进程会进入一种等候状态。同样地,由于它是一种“短期服务”,所以生存时间要受服务器进程的限制。JavaIDL目前尚未提供对“持久对象”(只要创建它们的进程保持运行状态,对象就会一直存在下去)的支持。
现在,我们已对服务器代码的工作有了一定的认识。接下来看看客户代码:

import RemoteTime.*;
import org.omg.CosNaming.*;
import org.omg.CORBA.*;

public class RemoteTimeClient {
  public static void main(String args[]) {
    try {
      // ORB creation and initialization:
      ORB orb = ORB.init(args, null);
      // Get the root naming context:
      org.omg.CORBA.Object objRef =
        orb.resolve_initial_references(
          "NameService");
      NamingContext ncRef =
        NamingContextHelper.narrow(objRef);
      // Get (resolve) the stringified object
      // reference for the time server:
      NameComponent nc =
        new NameComponent("ExactTime", "");
      NameComponent path[] = {nc};
      ExactTime timeObjRef =
        ExactTimeHelper.narrow(
          ncRef.resolve(path));
      // Make requests to the server object:
      String exactTime = timeObjRef.getTime();
      System.out.println(exactTime);
    } catch (Exception e) {
      System.out.println(
         "Remote Time server error: " + e);
      e.printStackTrace(System.out);
    }
  }
}

前几行所做的工作与它们在服务器进程里是一样的:ORB获得初始化,并解析出对命名服务的一个引用。
接下来,我们需要用到服务对象的一个对象引用,所以将字符串形式的对象引用直接传递给resolve()方法,并用narrow()方法将结果转换到ExactTime接口引用里。最后调用getTime()

5. 激活名称服务进程

现在,我们已分别获得了一个服务器和一个客户应用,它们已作好相互间进行沟通的准备。大家知道两者都需要利用命名服务绑定和解析字符串形式的对象引用。在运行服务或者客户之前,我们必须启动命名服务进程。在JavaIDL中,命名服务属于一个Java应用,是随产品配套提供的。但它可能与其他产品有所不同。JavaIDL命名服务在JVM的一个实例里运行,并(默认)监视网络端口900。

6. 激活服务器与客户

现在,我们已准备好启动服务器和客户应用(之所以按这一顺序,是由于服务器的存在是“短期”的)。若各个方面都设置无误,那么获得的就是在客户控制台窗口内的一行输出文字,提醒我们当前的时间是多少。当然,这一结果本身并没有什么令人兴奋的。但应注意一个问题:即使都处在同一台机器上,客户和服务器应用仍然运行于不同的虚拟机内。它们之间的通信是通过一个基本的集成层进行的——即ORB与命名服务的集成。

这只是一个简单的例子,面向非网络环境设计。但通常将ORB配置成“与位置无关”。若服务器与客户分别位于不同的机器上,那么ORB可用一个名为“安装库”(Implementation Repository)的组件解析出远程字符串式引用。尽管“安装库”属于CORBA的一部分,但它几乎没有具体的规格,所以各厂商的实现方式是不尽相同的。

正如大家看到的那样,CORBA还有许多方面的问题未在这儿进行详细讲述。但通过以上的介绍,应已对其有一个基本的认识。若想获得CORBA更详细的资料,最传真的起点莫过于OMB Web站点,地址是 http://www.omg.org 。这个地方提供了丰富的文档资料、白页、程序以及对其他CORBA资源和产品的链接。

A.6.3 Java程序片和CORBA

Java程序片可扮演一名CORBA客户的角色。这样一来,程序片就可访问由CORBA对象揭示的远程信息和服务。但程序片只能同最初下载它的那个服务器连接,所以程序片与它沟通的所有CORBA对象都必须位于那台服务器上。这与CORBA的宗旨是相悖的:它许诺可以实现“位置的透明”,或者“与位置无关”。

将Java程序片作为CORBA客户使用时,也会带来一些安全方面的问题。如果您在内联网中,一个办法是放宽对浏览器的安全限制。或者设置一道防火墙,以便建立与外部服务器安全连接。

针对这一问题,有些Java ORB产品专门提供了自己的解决方案。例如,有些产品实现了一种名为“HTTP通道”(HTTP Tunneling)的技术,另一些则提供了特别的防火墙功能。

作为放到附录中的内容,所有这些主题都显得太复杂了。但它们确实是需要重点注意的问题。

A.6.4 比较CORBA与RMI

我们已经知道,CORBA的一项主要特性就是对RPC(远程过程调用)的支持。利用这一技术,我们的本地对象可调用位置远程对象内的方法。当然,目前已有一项固有的Java特性可以做完全相同的事情:RMI(参考第15章)。尽管RMI使Java对象之间进行RPC调用成为可能,但CORBA能在用任何语言编制的对象之间进行RPC。这显然是一项很大的区别。

然而,可通过RMI调用远程、非Java代码的服务。我们需要的全部东西就是位于服务器那一端的、某种形式的封装Java对象,它将非Java代码“包裹”于其中。封装对象通过RMI同Java客户建立外部连接,并于内部建立与非Java代码的连接——采用前面讲到的某种技术,如JNI或J/Direct。

使用这种方法时,要求我们编写某种类型的“集成层”——这其实正是CORBA帮我们做的事情。但是这样做以后,就不再需要其他厂商开发的ORB了。

A.7 总结

我们在这个附录讨论的都是从一个Java应用里调用非Java代码最基本的技术。每种技术都有自己的优缺点。但目前最主要的问题是并非所有这些特性都能在所有JVM中找到。因此,即使一个Java程序能调用位于特定平台上的固有方法,仍有可能不适用于安装了不同JVM的另一种平台。

Sun公司提供的JNI具有灵活、简单(尽管它要求对JVM内核进行大量控制)、功能强大以及通用于大多数JVM的优点。到本书完稿时为止,微软仍未提供对JNI的支持,而是提供了自己的J/Direct(调用Win32 DLL函数的一种简便方法)和RNI(特别适合编写高效率的代码,但要求对JVM内核有很深入的理解)。微软也提供了自己的专利Java/COM集成方案。这一方案具有很强大的功能,且将Java变成了编写COM服务器和客户的有效语言。只有微软公司的编译器和JVM能提供对J/Direct、RNI以及Java/COM的支持。

我们最后研究的是CORBA,它使我们的Java对象可与其他对象沟通——无论它们的物理位置在哪里,也无论是用何种语言实现的。CORBA与前面提到的所有技术都不同,因为它并未集成到Java语言里,而是采用了其他厂商(第三方)的集成技术,并要求我们购买其他厂商提供的ORB。CORBA是一种有趣和通用的方案,但如果只是想发出对操作系统的调用,它也许并非一种最佳方案。

附录B 对比C++和Java

“作为一名C++程序员,我们早已掌握了面向对象程序设计的基本概念,而且Java的语法无疑是非常熟悉的。事实上,Java本来就是从C++派生出来的。”

然而,C++和Java之间仍存在一些显著的差异。可以这样说,这些差异代表着技术的极大进步。一旦我们弄清楚了这些差异,就会理解为什么说Java是一种优秀的程序设计语言。本附录将引导大家认识用于区分Java和C++的一些重要特征。

(1) 最大的障碍在于速度:解释过的Java要比C的执行速度慢上约20倍。无论什么都不能阻止Java语言进行编译。写作本书的时候,刚刚出现了一些准实时编译器,它们能显著加快速度。当然,我们完全有理由认为会出现适用于更多流行平台的纯固有编译器,但假若没有那些编译器,由于速度的限制,必须有些问题是Java不能解决的。

(2) 和C++一样,Java也提供了两种类型的注释。

(3) 所有东西都必须置入一个类。不存在全局函数或者全局数据。如果想获得与全局函数等价的功能,可考虑将static方法和static数据置入一个类里。注意没有象结构、枚举或者联合这一类的东西,一切只有“类”(Class)!

(4) 所有方法都是在类的主体定义的。所以用C++的眼光看,似乎所有函数都已嵌入,但实情并非如何(嵌入的问题在后面讲述)。

(5) 在Java中,类定义采取几乎和C++一样的形式。但没有标志结束的分号。没有class foo这种形式的类声明,只有类定义。

class aType()
void aMethod() {/* 方法主体 */}
}

(6) Java中没有作用域范围运算符::。Java利用点号做所有的事情,但可以不用考虑它,因为只能在一个类里定义元素。即使那些方法定义,也必须在一个类的内部,所以根本没有必要指定作用域的范围。我们注意到的一项差异是对static方法的调用:使用ClassName.methodName()。除此以外,package(包)的名字是用点号建立的,并能用import关键字实现C++的#include的一部分功能。例如下面这个语句:

import java.awt.*;

#include并不直接映射成import,但在使用时有类似的感觉。)

(7) 与C++类似,Java含有一系列“基本类型”(Primitive type),以实现更有效率的访问。在Java中,这些类型包括booleancharbyteshortintlongfloat以及double。所有基本类型的大小都是固有的,且与具体的机器无关(考虑到移植的问题)。这肯定会对性能造成一定的影响,具体取决于不同的机器。对类型的检查和要求在Java里变得更苛刻。例如:

  • 条件表达式只能是boolean(布尔)类型,不可使用整数。

  • 必须使用象X+Y这样的一个表达式的结果;不能仅仅用X+Y来实现“副作用”。

(8) char(字符)类型使用国际通用的16位Unicode字符集,所以能自动表达大多数国家的字符。

(9) 静态引用的字符串会自动转换成String对象。和C及C++不同,没有独立的静态字符数组字符串可供使用。

(10) Java增添了三个右移位运算符>>>,具有与“逻辑”右移位运算符类似的功用,可在最末尾插入零值。>>则会在移位的同时插入符号位(即“算术”移位)。

(11) 尽管表面上类似,但与C++相比,Java数组采用的是一个颇为不同的结构,并具有独特的行为。有一个只读的length成员,通过它可知道数组有多大。而且一旦超过数组边界,运行期检查会自动丢弃一个异常。所有数组都是在内存“堆”里创建的,我们可将一个数组分配给另一个(只是简单地复制数组引用)。数组标识符属于第一级对象,它的所有方法通常都适用于其他所有对象。

(12) 对于所有不属于基本类型的对象,都只能通过new命令创建。和C++不同,Java没有相应的命令可以“在栈上”创建不属于基本类型的对象。所有基本类型都只能在栈上创建,同时不使用new命令。所有主要的类都有自己的“封装(器)”类,所以能够通过new创建等价的、以内存“堆”为基础的对象(基本类型数组是一个例外:它们可象C++那样通过集合初始化进行分配,或者使用new)。

(13) Java中不必进行提前声明。若想在定义前使用一个类或方法,只需直接使用它即可——编译器会保证使用恰当的定义。所以和在C++中不同,我们不会碰到任何涉及提前引用的问题。

(14) Java没有预处理机。若想使用另一个库里的类,只需使用import命令,并指定库名即可。不存在类似于预处理机的宏。

(15) Java用包代替了命名空间。由于将所有东西都置入一个类,而且由于采用了一种名为“封装”的机制,它能针对类名进行类似于命名空间分解的操作,所以命名的问题不再进入我们的考虑之列。数据包也会在单独一个库名下收集库的组件。我们只需简单地import(导入)一个包,剩下的工作会由编译器自动完成。

(16) 被定义成类成员的对象引用会自动初始化成null。对基本类数据成员的初始化在Java里得到了可靠的保障。若不明确地进行初始化,它们就会得到一个默认值(零或等价的值)。可对它们进行明确的初始化(显式初始化):要么在类内定义它们,要么在构造器中定义。采用的语法比C++的语法更容易理解,而且对于static和非static成员来说都是固定不变的。我们不必从外部定义static成员的存储方式,这和C++是不同的。

(17) 在Java里,没有象C和C++那样的指针。用new创建一个对象的时候,会获得一个引用(本书一直将其称作“引用”)。例如:

String s = new String("howdy");

然而,C++引用在创建时必须进行初始化,而且不可重定义到一个不同的位置。但Java引用并不一定局限于创建时的位置。它们可根据情况任意定义,这便消除了对指针的部分需求。在C和C++里大量采用指针的另一个原因是为了能指向任意一个内存位置(这同时会使它们变得不安全,也是Java不提供这一支持的原因)。指针通常被看作在基本变量数组中四处移动的一种有效手段。Java允许我们以更安全的形式达到相同的目标。解决指针问题的终极方法是“固有方法”(已在附录A讨论)。将指针传递给方法时,通常不会带来太大的问题,因为此时没有全局函数,只有类。而且我们可传递对对象的引用。Java语言最开始声称自己“完全不采用指针!”但随着许多程序员都质问没有指针如何工作?于是后来又声明“采用受到限制的指针”。大家可自行判断它是否“真”的是一个指针。但不管在何种情况下,都不存在指针“算术”。

(18) Java提供了与C++类似的“构造器”(Constructor)。如果不自己定义一个,就会获得一个默认构造器。而如果定义了一个非默认的构造器,就不会为我们自动定义默认构造器。这和C++是一样的。注意没有复制构造器,因为所有参数都是按引用传递的。

(19) Java中没有“析构器”(Destructor)。变量不存在“作用域”的问题。一个对象的“存在时间”是由对象的存在时间决定的,并非由垃圾收集器决定。有个finalize()方法是每一个类的成员,它在某种程度上类似于C++的“析构器”。但finalize()是由垃圾收集器调用的,而且只负责释放“资源”(如打开的文件、套接字、端口、URL等等)。如需在一个特定的地点做某样事情,必须创建一个特殊的方法,并调用它,不能依赖finalize()。而在另一方面,C++中的所有对象都会(或者说“应该”)析构,但并非Java中的所有对象都会被当作“垃圾”收集掉。由于Java不支持析构器的概念,所以在必要的时候,必须谨慎地创建一个清除方法。而且针对类内的基类以及成员对象,需要明确调用所有清除方法。

(20) Java具有方法“重载”机制,它的工作原理与C++函数的重载几乎是完全相同的。

(21) Java不支持默认参数。

(22) Java中没有goto。它采取的无条件跳转机制是“break 标签”或者“continue 标准”,用于跳出当前的多重嵌套循环。

(23) Java采用了一种单根式的分级结构,因此所有对象都是从根类Object统一继承的。而在C++中,我们可在任何地方启动一个新的继承树,所以最后往往看到包含了大量树的“一片森林”。在Java中,我们无论如何都只有一个分级结构。尽管这表面上看似乎造成了限制,但由于我们知道每个对象肯定至少有一个Object接口,所以往往能获得更强大的能力。C++目前似乎是唯一没有强制单根结构的唯一一种OO语言。

(24) Java没有模板或者参数化类型的其他形式。它提供了一系列集合:Vector(向量),Stack(栈)以及Hashtable(散列表),用于容纳Object引用。利用这些集合,我们的一系列要求可得到满足。但这些集合并非是为实现象C++“标准模板库”(STL)那样的快速调用而设计的。Java 1.2中的新集合显得更加完整,但仍不具备正宗模板那样的高效率使用手段。

(25) “垃圾收集”意味着在Java中出现内存漏洞的情况会少得多,但也并非完全不可能(若调用一个用于分配存储空间的固有方法,垃圾收集器就不能对其进行跟踪监视)。然而,内存漏洞和资源漏洞多是由于编写不当的finalize()造成的,或是由于在已分配的一个块尾释放一种资源造成的(“析构器”在此时显得特别方便)。垃圾收集器是在C++基础上的一种极大进步,使许多编程问题消弥于无形之中。但对少数几个垃圾收集器力有不逮的问题,它却是不大适合的。但垃圾收集器的大量优点也使这一处缺点显得微不足道。

(26) Java内建了对多线程的支持。利用一个特殊的Thread类,我们可通过继承创建一个新线程(放弃了run()方法)。若将synchronized(同步)关键字作为方法的一个类型限制符使用,相互排斥现象会在对象这一级发生。在任何给定的时间,只有一个线程能使用一个对象的synchronized方法。在另一方面,一个synchronized方法进入以后,它首先会“锁定”对象,防止其他任何synchronized方法再使用那个对象。只有退出了这个方法,才会将对象“解锁”。在线程之间,我们仍然要负责实现更复杂的同步机制,方法是创建自己的“监视器”类。递归的synchronized方法可以正常运作。若线程的优先等级相同,则时间的“分片”不能得到保证。

(27) 我们不是象C++那样控制声明代码块,而是将访问限定符(publicprivateprotected)置入每个类成员的定义里。若未规定一个“显式”(明确的)限定符,就会默认为“友好的”(friendly)。这意味着同一个包里的其他元素也可以访问它(相当于它们都成为C++的“friends”——朋友),但不可由包外的任何元素访问。类——以及类内的每个方法——都有一个访问限定符,决定它是否能在文件的外部“可见”。private关键字通常很少在Java中使用,因为与排斥同一个包内其他类的访问相比,“友好的”访问通常更加有用。然而,在多线程的环境中,对private的恰当运用是非常重要的。Java的protected关键字意味着“可由继承者访问,亦可由包内其他元素访问”。注意Java没有与C++的protected关键字等价的元素,后者意味着“只能由继承者访问”(以前可用“private protected”实现这个目的,但这一对关键字的组合已被取消了)。

(28) 嵌套的类。在C++中,对类进行嵌套有助于隐藏名称,并便于代码的组织(但C++的“命名空间”已使名称的隐藏显得多余)。Java的“封装”或“打包”概念等价于C++的命名空间,所以不再是一个问题。Java 1.1引入了“内部类”的概念,它秘密保持指向外部类的一个引用——创建内部类对象的时候需要用到。这意味着内部类对象也许能访问外部类对象的成员,毋需任何条件——就好象那些成员直接隶属于内部类对象一样。这样便为回调问题提供了一个更优秀的方案——C++是用指向成员的指针解决的。

(29) 由于存在前面介绍的那种内部类,所以Java里没有指向成员的指针。

(30) Java不存在“嵌入”(inline)方法。Java编译器也许会自行决定嵌入一个方法,但我们对此没有更多的控制权力。在Java中,可为一个方法使用final关键字,从而“建议”进行嵌入操作。然而,嵌入函数对于C++的编译器来说也只是一种建议。

(31) Java中的继承具有与C++相同的效果,但采用的语法不同。Java用extends关键字标志从一个基类的继承,并用super关键字指出准备在基类中调用的方法,它与我们当前所在的方法具有相同的名字(然而,Java中的super关键字只允许我们访问父类的方法——亦即分级结构的上一级)。通过在C++中设定基类的作用域,我们可访问位于分级结构较深处的方法。亦可用super关键字调用基类构造器。正如早先指出的那样,所有类最终都会从Object里自动继承。和C++不同,不存在明确的构造器初始化列表。但编译器会强迫我们在构造器主体的开头进行全部的基类初始化,而且不允许我们在主体的后面部分进行这一工作。通过组合运用自动初始化以及来自未初始化对象引用的异常,成员的初始化可得到有效的保证。

public class Foo extends Bar {
  public Foo(String msg) {
    super(msg); // Calls base constructor
  }
  public baz(int i) { // Override
    super.baz(i); // Calls base method
  }
}

(32) Java中的继承不会改变基类成员的保护级别。我们不能在Java中指定publicprivate或者protected继承,这一点与C++是相同的。此外,在派生类中的优先方法不能减少对基类方法的访问。例如,假设一个成员在基类中属于public,而我们用另一个方法代替了它,那么用于替换的方法也必须属于public(编译器会自动检查)。

(33) Java提供了一个interface关键字,它的作用是创建抽象基类的一个等价物。在其中填充抽象方法,且没有数据成员。这样一来,对于仅仅设计成一个接口的东西,以及对于用extends关键字在现有功能基础上的扩展,两者之间便产生了一个明显的差异。不值得用abstract关键字产生一种类似的效果,因为我们不能创建属于那个类的一个对象。一个abstract(抽象)类可包含抽象方法(尽管并不要求在它里面包含什么东西),但它也能包含用于具体实现的代码。因此,它被限制成一个单一的继承。通过与接口联合使用,这一方案避免了对类似于C++虚拟基类那样的一些机制的需要。

为创建可进行“例示”(即创建一个实例)的一个interface(接口)的版本,需使用implements关键字。它的语法类似于继承的语法,如下所示:

public interface Face {
  public void smile();
}
public class Baz extends Bar implements Face {
  public void smile( ) {
    System.out.println("a warm smile");
  }
}

(34) Java中没有virtual关键字,因为所有非static方法都肯定会用到动态绑定。在Java中,程序员不必自行决定是否使用动态绑定。C++之所以采用了virtual,是由于我们对性能进行调整的时候,可通过将其省略,从而获得执行效率的少量提升(或者换句话说:“如果不用,就没必要为它付出代价”)。virtual经常会造成一定程度的混淆,而且获得令人不快的结果。final关键字为性能的调整规定了一些范围——它向编译器指出这种方法不能被取代,所以它的范围可能被静态约束(而且成为嵌入状态,所以使用C++非virtual调用的等价方式)。这些优化工作是由编译器完成的。

(35) Java不提供多重继承机制(MI),至少不象C++那样做。与protected类似,MI表面上是一个很不错的主意,但只有真正面对一个特定的设计问题时,才知道自己需要它。由于Java使用的是“单根”分级结构,所以只有在极少的场合才需要用到MI。interface关键字会帮助我们自动完成多个接口的合并工作。

(36) 运行期的类型识别功能与C++极为相似。例如,为获得与引用X有关的信息,可使用下述代码:

X.getClass().getName();

为进行一个“类型安全”的紧缩转换,可使用:

derived d = (derived)base;

这与旧式风格的C转换是一样的。编译器会自动调用动态转换机制,不要求使用额外的语法。尽管它并不象C++的new casts那样具有易于定位转换的优点,但Java会检查使用情况,并丢弃那些“异常”,所以它不会象C++那样允许坏转换的存在。

(37) Java采取了不同的异常控制机制,因为此时已经不存在构造器。可添加一个finally从句,强制执行特定的语句,以便进行必要的清除工作。Java中的所有异常都是从基类Throwable里继承而来的,所以可确保我们得到的是一个通用接口。

public void f(Obj b) throws IOException {
  myresource mr = b.createResource();
  try {
    mr.UseResource();
  } catch (MyException e) {
    // handle my exception
  } catch (Throwable e) {
    // handle all other exceptions
  } finally {
    mr.dispose(); // special cleanup
  }
}

(38) Java的异常规范比C++的出色得多。丢弃一个错误的异常后,不是象C++那样在运行期间调用一个函数,Java异常规范是在编译期间检查并执行的。除此以外,被取代的方法必须遵守那一方法的基类版本的异常规范:它们可丢弃指定的异常或者从那些异常派生出来的其他异常。这样一来,我们最终得到的是更为“健壮”的异常控制代码。

(39) Java具有方法重载的能力,但不允许运算符重载。String类不能用++=运算符连接不同的字符串,而且String表达式使用自动的类型转换,但那是一种特殊的内建情况。

(40) 通过事先的约定,C++中经常出现的const问题在Java里已得到了控制。我们只能传递指向对象的引用,本地副本永远不会为我们自动生成。若希望使用类似C++按值传递那样的技术,可调用s,生成参数的一个本地副本(尽管clone()的设计依然尚显粗糙——参见第12章)。根本不存在被自动调用的副本构造器。为创建一个编译期的常数值,可象下面这样编码:

static final int SIZE = 255
static final int BSIZE = 8 * SIZE

(41) 由于安全方面的原因,“应用程序”的编程与“程序片”的编程之间存在着显著的差异。一个最明显的问题是程序片不允许我们进行磁盘的写操作,因为这样做会造成从远程站点下载的、不明来历的程序可能胡乱改写我们的磁盘。随着Java 1.1对数字签名技术的引用,这一情况已有所改观。根据数字签名,我们可确切知道一个程序片的全部作者,并验证他们是否已获得授权。Java 1.2会进一步增强程序片的能力。

(42) 由于Java在某些场合可能显得限制太多,所以有时不愿用它执行象直接访问硬件这样的重要任务。Java解决这个问题的方案是“固有方法”,允许我们调用由其他语言写成的函数(目前只支持C和C++)。这样一来,我们就肯定能够解决与平台有关的问题(采用一种不可移植的形式,但那些代码随后会被隔离起来)。程序片不能调用固有方法,只有应用程序才可以。

(43) Java提供对注释文档的内建支持,所以源码文件也可以包含它们自己的文档。通过一个单独的程序,这些文档信息可以提取出来,并重新格式化成HTML。这无疑是文档管理及应用的极大进步。

(44) Java包含了一些标准库,用于完成特定的任务。C++则依靠一些非标准的、由其他厂商提供的库。这些任务包括(或不久就要包括):

  • 连网

  • 数据库连接(通过JDBC)

  • 多线程

  • 分布式对象(通过RMI和CORBA)

  • 压缩

  • 商贸

由于这些库简单易用,而且非常标准,所以能极大加快应用程序的开发速度。

(45) Java 1.1包含了Java Beans标准,后者可创建在可视编程环境中使用的组件。由于遵守同样的标准,所以可视组件能够在所有厂商的开发环境中使用。由于我们并不依赖一家厂商的方案进行可视组件的设计,所以组件的选择余地会加大,并可提高组件的效能。除此之外,Java Beans的设计非常简单,便于程序员理解;而那些由不同的厂商开发的专用组件框架则要求进行更深入的学习。

(46) 若访问Java引用失败,就会丢弃一次异常。这种丢弃测试并不一定要正好在使用一个引用之前进行。根据Java的设计规范,只是说异常必须以某种形式丢弃。许多C++运行期系统也能丢弃那些由于指针错误造成的异常。

(47) Java通常显得更为健壮,为此采取的手段如下:

  • 对象引用初始化成null(一个关键字)

  • 引用肯定会得到检查,并在出错时丢弃异常

  • 所有数组访问都会得到检查,及时发现边界异常情况

  • 自动垃圾收集,防止出现内存漏洞

  • 明确、“傻瓜式”的异常控制机制

  • 为多线程提供了简单的语言支持

  • 对网络程序片进行字节码校验

附录C Java编程规则

本附录包含了大量有用的建议,帮助大家进行低级程序设计,并提供了代码编写的一般性指导:

(1) 类名首字母应该大写。字段、方法以及对象(引用)的首字母应小写。对于所有标识符,其中包含的所有单词都应紧靠在一起,而且大写中间单词的首字母。例如:

ThisIsAClassName
thisIsMethodOrFieldName

若在定义中出现了常数初始化字符,则大写static final基本类型识别符中的所有字母。这样便可标志出它们属于编译期的常数。

Java包(Package)属于一种特殊情况:它们全都是小写字母,即便中间的单词亦是如此。对于域名扩展名称,如com,org,net或者edu等,全部都应小写(这也是Java 1.1和Java 1.2的区别之一)。

(2) 为了常规用途而创建一个类时,请采取“经典形式”,并包含对下述元素的定义:

equals()
hashCode()
toString()
clone()(implement Cloneable)
implement Serializable

(3) 对于自己创建的每一个类,都考虑置入一个main(),其中包含了用于测试那个类的代码。为使用一个项目中的类,我们没必要删除测试代码。若进行了任何形式的改动,可方便地返回测试。这些代码也可作为如何使用类的一个示例使用。

(4) 应将方法设计成简要的、功能性单元,用它描述和实现一个不连续的类接口部分。理想情况下,方法应简明扼要。若长度很大,可考虑通过某种方式将其分割成较短的几个方法。这样做也便于类内代码的重复使用(有些时候,方法必须非常大,但它们仍应只做同样的一件事情)。

(5) 设计一个类时,请设身处地为客户程序员考虑一下(类的使用方法应该是非常明确的)。然后,再设身处地为管理代码的人考虑一下(预计有可能进行哪些形式的修改,想想用什么方法可把它们变得更简单)。

(6) 使类尽可能短小精悍,而且只解决一个特定的问题。下面是对类设计的一些建议:

  • 一个复杂的开关语句:考虑采用“多态”机制

  • 数量众多的方法涉及到类型差别极大的操作:考虑用几个类来分别实现

  • 许多成员变量在特征上有很大的差别:考虑使用几个类

(7) 让一切东西都尽可能地“私有”——private。可使库的某一部分“公共化”(一个方法、类或者一个字段等等),就永远不能把它拿出。若强行拿出,就可能破坏其他人现有的代码,使他们不得不重新编写和设计。若只公布自己必须公布的,就可放心大胆地改变其他任何东西。在多线程环境中,隐私是特别重要的一个因素——只有private字段才能在非同步使用的情况下受到保护。

(8) 谨惕“巨大对象综合症”。对一些习惯于顺序编程思维、且初涉OOP领域的新手,往往喜欢先写一个顺序执行的程序,再把它嵌入一个或两个巨大的对象里。根据编程原理,对象表达的应该是应用程序的概念,而非应用程序本身。

(9) 若不得已进行一些不太雅观的编程,至少应该把那些代码置于一个类的内部。

(10) 任何时候只要发现类与类之间结合得非常紧密,就需要考虑是否采用内部类,从而改善编码及维护工作(参见第14章14.1.2小节的“用内部类改进代码”)。

(11) 尽可能细致地加上注释,并用javadoc注释文档语法生成自己的程序文档。

(12) 避免使用“魔术数字”,这些数字很难与代码很好地配合。如以后需要修改它,无疑会成为一场噩梦,因为根本不知道“100”到底是指“数组大小”还是“其他全然不同的东西”。所以,我们应创建一个常数,并为其使用具有说服力的描述性名称,并在整个程序中都采用常数标识符。这样可使程序更易理解以及更易维护。

(13) 涉及构造器和异常的时候,通常希望重新丢弃在构造器中捕获的任何异常——如果它造成了那个对象的创建失败。这样一来,调用者就不会以为那个对象已正确地创建,从而盲目地继续。

(14) 当客户程序员用完对象以后,若你的类要求进行任何清除工作,可考虑将清除代码置于一个良好定义的方法里,采用类似于cleanup()这样的名字,明确表明自己的用途。除此以外,可在类内放置一个boolean(布尔)标记,指出对象是否已被清除。在类的finalize()方法里,请确定对象已被清除,并已丢弃了从RuntimeException继承的一个类(如果还没有的话),从而指出一个编程错误。在采取象这样的方案之前,请确定finalize()能够在自己的系统中工作(可能需要调用System.runFinalizersOnExit(true),从而确保这一行为)。

(15) 在一个特定的作用域内,若一个对象必须清除(非由垃圾收集机制处理),请采用下述方法:初始化对象;若成功,则立即进入一个含有finally从句的try块,开始清除工作。

(16) 若在初始化过程中需要覆盖(取消)finalize(),请记住调用super.finalize()(若Object属于我们的直接超类,则无此必要)。在对finalize()进行覆盖的过程中,对super.finalize()的调用应属于最后一个行动,而不应是第一个行动,这样可确保在需要基类组件的时候它们依然有效。

(17) 创建大小固定的对象集合时,请将它们传输至一个数组(若准备从一个方法里返回这个集合,更应如此操作)。这样一来,我们就可享受到数组在编译期进行类型检查的好处。此外,为使用它们,数组的接收者也许并不需要将对象“转换”到数组里。

(18) 尽量使用interfaces,不要使用abstract类。若已知某样东西准备成为一个基类,那么第一个选择应是将其变成一个interface(接口)。只有在不得不使用方法定义或者成员变量的时候,才需要将其变成一个abstract(抽象)类。接口主要描述了客户希望做什么事情,而一个类则致力于(或允许)具体的实现细节。

(19) 在构造器内部,只进行那些将对象设为正确状态所需的工作。尽可能地避免调用其他方法,因为那些方法可能被其他人覆盖或取消,从而在构建过程中产生不可预知的结果(参见第7章的详细说明)。

(20) 对象不应只是简单地容纳一些数据;它们的行为也应得到良好的定义。

(21) 在现成类的基础上创建新类时,请首先选择“新建”或“创作”。只有自己的设计要求必须继承时,才应考虑这方面的问题。若在本来允许新建的场合使用了继承,则整个设计会变得没有必要地复杂。

(22) 用继承及方法覆盖来表示行为间的差异,而用字段表示状态间的区别。一个非常极端的例子是通过对不同类的继承来表示颜色,这是绝对应该避免的:应直接使用一个“颜色”字段。

(23) 为避免编程时遇到麻烦,请保证在自己类路径指到的任何地方,每个名字都仅对应一个类。否则,编译器可能先找到同名的另一个类,并报告出错消息。若怀疑自己碰到了类路径问题,请试试在类路径的每一个起点,搜索一下同名的.class文件。

(24) 在Java 1.1 AWT中使用事件“适配器”时,特别容易碰到一个陷阱。若覆盖了某个适配器方法,同时拼写方法没有特别讲究,最后的结果就是新添加一个方法,而不是覆盖现成方法。然而,由于这样做是完全合法的,所以不会从编译器或运行期系统获得任何出错提示——只不过代码的工作就变得不正常了。

(25) 用合理的设计模式消除“伪功能”。也就是说,假若只需要创建类的一个对象,就不要提前限制自己使用应用程序,并加上一条“只生成其中一个”注释。请考虑将其封装成一个“独生子”的形式。若在主程序里有大量散乱的代码,用于创建自己的对象,请考虑采纳一种创造性的方案,将些代码封装起来。

(26) 警惕“分析瘫痪”。请记住,无论如何都要提前了解整个项目的状况,再去考察其中的细节。由于把握了全局,可快速认识自己未知的一些因素,防止在考察细节的时候陷入“死逻辑”中。

(27) 警惕“过早优化”。首先让它运行起来,再考虑变得更快——但只有在自己必须这样做、而且经证实在某部分代码中的确存在一个性能瓶颈的时候,才应进行优化。除非用专门的工具分析瓶颈,否则很有可能是在浪费自己的时间。性能提升的隐含代价是自己的代码变得难于理解,而且难于维护。

(28) 请记住,阅读代码的时间比写代码的时间多得多。思路清晰的设计可获得易于理解的程序,但注释、细致的解释以及一些示例往往具有不可估量的价值。无论对你自己,还是对后来的人,它们都是相当重要的。如对此仍有怀疑,那么请试想自己试图从联机Java文档里找出有用信息时碰到的挫折,这样或许能将你说服。

(29) 如认为自己已进行了良好的分析、设计或者实现,那么请稍微更换一下思维角度。试试邀请一些外来人士——并不一定是专家,但可以是来自本公司其他部门的人。请他们用完全新鲜的眼光考察你的工作,看看是否能找出你一度熟视无睹的问题。采取这种方式,往往能在最适合修改的阶段找出一些关键性的问题,避免产品发行后再解决问题而造成的金钱及精力方面的损失。

(30) 良好的设计能带来最大的回报。简言之,对于一个特定的问题,通常会花较长的时间才能找到一种最恰当的解决方案。但一旦找到了正确的方法,以后的工作就轻松多了,再也不用经历数小时、数天或者数月的痛苦挣扎。我们的努力工作会带来最大的回报(甚至无可估量)。而且由于自己倾注了大量心血,最终获得一个出色的设计模式,成功的快感也是令人心动的。坚持抵制草草完工的诱惑——那样做往往得不偿失。

(31) 可在Web上找到大量的编程参考资源,甚至包括大量新闻组、讨论组、邮寄列表等。下面这个地方提供了大量有益的链接:

http://www.ulb.ac.be/esp/ip-Links/Java/joodcs/mm-WebBiblio.html

附录D 性能

“本附录由Joe Sharp投稿,并获得他的同意在这儿转载。请联系SharpJoe@aol.com

Java语言特别强调准确性,但可靠的行为要以性能作为代价。这一特点反映在自动收集垃圾、严格的运行期检查、完整的字节码检查以及保守的运行期同步等等方面。对一个解释型的虚拟机来说,由于目前有大量平台可供挑选,所以进一步阻碍了性能的发挥。
“先做完它,再逐步完善。幸好需要改进的地方通常不会太多。”(Steve McConnell的《About performance》[16])
本附录的宗旨就是指导大家寻找和优化“需要完善的那一部分”。

D.1 基本方法

只有正确和完整地检测了程序后,再可着手解决性能方面的问题:

(1) 在现实环境中检测程序的性能。若符合要求,则目标达到。若不符合,则转到下一步。

(2) 寻找最致命的性能瓶颈。这也许要求一定的技巧,但所有努力都不会白费。如简单地猜测瓶颈所在,并试图进行优化,那么可能是白花时间。

(3) 运用本附录介绍的提速技术,然后返回步骤1。

为使努力不至白费,瓶颈的定位是至关重要的一环。Donald Knuth[9]曾改进过一个程序,那个程序把50%的时间都花在约4%的代码量上。在仅一个工作小时里,他修改了几行代码,使程序的执行速度倍增。此时,若将时间继续投入到剩余代码的修改上,那么只会得不偿失。Knuth在编程界有一句名言:“过早的优化是万恶之源”(Premature optimization is the root of all evil)。最明智的做法是抑制过早优化的冲动,因为那样做可能遗漏多种有用的编程技术,造成代码更难理解和操控,并需更大的精力进行维护。

D.2 寻找瓶颈

为找出最影响程序性能的瓶颈,可采取下述几种方法:

D.2.1 安插自己的测试代码

插入下述“显式”计时代码,对程序进行评测:

long start = System.currentTimeMillis();
// 要计时的运算代码放在这儿
long time = System.currentTimeMillis() - start;

利用System.out.println(),让一种不常用到的方法将累积时间打印到控制台窗口。由于一旦出错,编译器会将其忽略,所以可用一个“静态最终布尔值”(Static final boolean)打开或关闭计时,使代码能放心留在最终发行的程序里,这样任何时候都可以拿来应急。尽管还可以选用更复杂的评测手段,但若仅仅为了量度一个特定任务的执行时间,这无疑是最简便的方法。

System.currentTimeMillis()返回的时间以千分之一秒(1毫秒)为单位。然而,有些系统的时间精度低于1毫秒(如Windows PC),所以需要重复n次,再将总时间除以n,获得准确的时间。

D.2.2 JDK性能评测[2]

JDK配套提供了一个内建的评测程序,能跟踪花在每个例程上的时间,并将评测结果写入一个文件。不幸的是,JDK评测器并不稳定。它在JDK 1.1.1中能正常工作,但在后续版本中却非常不稳定。

为运行评测程序,请在调用Java解释器的未优化版本时加上-prof选项。例如:

java_g -prof myClass

或加上一个程序片(Applet):

java_g -prof sun.applet.AppletViewer applet.html

理解评测程序的输出信息并不容易。事实上,在JDK 1.0中,它居然将方法名称截短为30字符。所以可能无法区分出某些方法。然而,若您用的平台确实能支持-prof选项,那么可试试Vladimir Bulatov的“HyperPorf”[3]或者Greg White的“ProfileViewer”来解释一下结果。

D.2.3 特殊工具

如果想随时跟上性能优化工具的潮流,最好的方法就是作一些Web站点的常客。比如由Jonathan Hardwick制作的“Tools for Optimizing Java”(Java优化工具)网站:

http://www.cs.cmu.edu/~jch/java/tools.html

D.2.4 性能评测的技巧

  • 由于评测时要用到系统时钟,所以当时不要运行其他任何进程或应用程序,以免影响测试结果。

  • 如对自己的程序进行了修改,并试图(至少在开发平台上)改善它的性能,那么在修改前后应分别测试一下代码的执行时间。

  • 尽量在完全一致的环境中进行每一次时间测试。

  • 如果可能,应设计一个不依赖任何用户输入的测试,避免用户的不同反应导致结果出现误差。

D.3 提速方法

现在,关键的性能瓶颈应已隔离出来。接下来,可对其应用两种类型的优化:常规手段以及依赖Java语言。

D.3.1 常规手段

通常,一个有效的提速方法是用更现实的方式重新定义程序。例如,在《Programming Pearls》(编程珠玑)一书中[14],Bentley利用了一段小说数据描写,它可以生成速度非常快、而且非常精简的拼写检查器,从而介绍了Doug McIlroy对英语语言的表述。除此以外,与其他方法相比,更好的算法也许能带来更大的性能提升——特别是在数据集的尺寸越来越大的时候。欲了解这些常规手段的详情,请参考本附录末尾的“一般书籍”清单。

D.3.2 依赖语言的方法

为进行客观的分析,最好明确掌握各种运算的执行时间。这样一来,得到的结果可独立于当前使用的计算机——通过除以花在本地赋值上的时间,最后得到的就是“标准时间”。

运算 示例 标准时间
本地赋值 i=n; 1.0
实例赋值 this.i=n; 1.2
int自增 i++; 1.5
byte自增 b++; 2.0
short自增 s++; 2.0
float自增 f++; 2.0
double自增 d++; 2.0
空循环 while(true) n++; 2.0
三元表达式 (x<0) ?-x : x 2.2
算术调用 Math.abs(x); 2.5
数组赋值 a[0] = n; 2.7
long自增 l++; 3.5
方法调用 funct(); 5.9
throwcatch异常 try{ throw e; }catch(e){} 320
同步方法调用 synchMehod(); 570
新建对象 new Object(); 980
新建数组 new int[10]; 3100

通过自己的系统(如我的Pentium 200 Pro,Netscape 3及JDK 1.1.5),这些相对时间向大家揭示出:新建对象和数组会造成最沉重的开销,同步会造成比较沉重的开销,而一次不同步的方法调用会造成适度的开销。参考资源[5]和[6]为大家总结了测量用程序片的Web地址,可到自己的机器上运行它们。

(1) 常规修改

下面是加快Java程序关键部分执行速度的一些常规操作建议(注意对比修改前后的测试结果)。

将... 修改成... 理由
接口 抽象类(只需一个父时) 接口的多个继承会妨碍性能的优化
非本地或数组循环变量 本地循环变量 根据前表的耗时比较,一次实例整数赋值的时间是本地整数赋值时间的1.2倍,但数组赋值的时间是本地整数赋值的2.7倍
链接列表(固定尺寸) 保存丢弃的链接项目,或将列表替换成一个循环数组(大致知道尺寸) 每新建一个对象,都相当于本地赋值980次。参考“重复利用对象”(下一节)、Van Wyk[12] p.87以及Bentley[15] p.81
x/2(或2的任意次幂) X>>2(或2的任意次幂) 使用更快的硬件指令

D.3.3 特殊情况

  • 字符串的开销:字符串连接运算符+看似简单,但实际需要消耗大量系统资源。编译器可高效地连接字符串,但变量字符串却要求可观的处理器时间。例如,假设st是字符串变量:
System.out.println("heading" + s + "trailer" + t);

上述语句要求新建一个StringBuffer(字符串缓冲),追加参数,然后用toString()将结果转换回一个字符串。因此,无论磁盘空间还是处理器时间,都会受到严重消耗。若准备追加多个字符串,则可考虑直接使用一个字符串缓冲——特别是能在一个循环里重复利用它的时候。通过在每次循环里禁止新建一个字符串缓冲,可节省980单位的对象创建时间(如前所述)。利用substring()以及其他字符串方法,可进一步地改善性能。如果可行,字符数组的速度甚至能够更快。也要注意由于同步的关系,所以StringTokenizer会造成较大的开销。

  • 同步:在JDK解释器中,调用同步方法通常会比调用不同步方法慢10倍。经JIT编译器处理后,这一性能上的差距提升到50到100倍(注意前表总结的时间显示出要慢97倍)。所以要尽可能避免使用同步方法——若不能避免,方法的同步也要比代码块的同步稍快一些。

  • 重复利用对象:要花很长的时间来新建一个对象(根据前表总结的时间,对象的新建时间是赋值时间的980倍,而新建一个小数组的时间是赋值时间的3100倍)。因此,最明智的做法是保存和更新老对象的字段,而不是创建一个新对象。例如,不要在自己的paint()方法中新建一个Font对象。相反,应将其声明成实例对象,再初始化一次。在这以后,可在paint()里需要的时候随时进行更新。参见Bentley编著的《编程珠玑》,p.81[15]。

  • 异常:只有在不正常的情况下,才应放弃异常处理模块。什么才叫“不正常”呢?这通常是指程序遇到了问题,而这一般是不愿见到的,所以性能不再成为优先考虑的目标。进行优化时,将小的try-catch块合并到一起。由于这些块将代码分割成小的、各自独立的片断,所以会妨碍编译器进行优化。另一方面,若过份热衷于删除异常处理模块,也可能造成代码健壮程度的下降。

  • 散列处理:首先,Java 1.0和1.1的标准“散列表”(Hashtable)类需要转换以及特别消耗系统资源的同步处理(570单位的赋值时间)。其次,早期的JDK库不能自动决定最佳的表格尺寸。最后,散列函数应针对实际使用项(Key)的特征设计。考虑到所有这些原因,我们可特别设计一个散列类,令其与特定的应用程序配合,从而改善常规散列表的性能。注意Java 1.2集合库的散列映射(HashMap)具有更大的灵活性,而且不会自动同步。

  • 方法内嵌:只有在方法属于final(最终)、private(专用)或static(静态)的情况下,Java编译器才能内嵌这个方法。而且某些情况下,还要求它绝对不可以有局部变量。若代码花大量时间调用一个不含上述任何属性的方法,那么请考虑为其编写一个final版本。

  • I/O:应尽可能使用缓冲。否则,最终也许就是一次仅输入/输出一个字节的恶果。注意JDK 1.0的I/O类采用了大量同步措施,所以若使用象readFully()这样的一个“大批量”调用,然后由自己解释数据,就可获得更佳的性能。也要注意Java 1.1的readerwriter类已针对性能进行了优化。

  • 转换和实例:转换会耗去2到200个单位的赋值时间。开销更大的甚至要求上溯继承(遗传)结构。其他高代价的操作会损失和恢复更低层结构的能力。

  • 图形:利用剪切技术,减少在repaint()中的工作量;倍增缓冲区,提高接收速度;同时利用图形压缩技术,缩短下载时间。来自JavaWorld的“Java Applets”以及来自Sun的“Performing Animation”是两个很好的教程。请记着使用最贴切的命令。例如,为根据一系列点画一个多边形,和drawLine()相比,drawPolygon()的速度要快得多。如必须画一条单像素粗细的直线,drawLine(x,y,x,y)的速度比fillRect(x,y,1,1)快。

  • 使用API类:尽量使用来自Java API的类,因为它们本身已针对机器的性能进行了优化。这是用Java难于达到的。比如在复制任意长度的一个数组时,arraryCopy()比使用循环的速度快得多。

  • 替换API类:有些时候,API类提供了比我们希望更多的功能,相应的执行时间也会增加。因此,可定做特别的版本,让它做更少的事情,但可更快地运行。例如,假定一个应用程序需要一个容器来保存大量数组。为加快执行速度,可将原来的Vector(向量)替换成更快的动态对象数组。

(1) 其他建议

  • 将重复的常数计算移至关键循环之外——比如计算固定长度缓冲区的buffer.length

  • static final(静态最终)常数有助于编译器优化程序。

  • 实现固定长度的循环。

  • 使用javac的优化选项:-O。它通过内嵌staticfinal以及private方法,从而优化编译过的代码。注意类的长度可能会增加(只对JDK 1.1而言——更早的版本也许不能执行字节查证)。新型的“Just-in-time”(JIT)编译器会动态加速代码。

  • 尽可能地将计数减至0——这使用了一个特殊的JVM字节码。

D.4 参考资源

D.4.1 性能工具

[1] 运行于Pentium Pro 200,Netscape 3.0,JDK 1.1.4的MicroBenchmark(参见下面的参考资源[5])

[2] Sun的Java文档页——JDK Java解释器主题:

http://java.sun.com/products/JDK/tools/win32/java.html

[3] Vladimir Bulatov的HyperProf

http://www.physics.orst.edu/~bulatov/HyperProf

[4] Greg White的ProfileViewer

http://www.inetmi.com/~gwhi/ProfileViewer/ProfileViewer.html

D.4.2 Web站点

[5] 对于Java代码的优化主题,最出色的在线参考资源是Jonathan Hardwick的“Java Optimization”网站:

http://www.cs.cmu.edu/~jch/java/optimization.html

“Java优化工具”主页:

http://www.cs.cmu.edu/~jch/java/tools.html

以及“Java Microbenchmarks”(有一个45秒钟的评测过程):

http://www.cs.cmu.edu/~jch/java/benchmarks.html

D.4.3 文章

[6] “Make Java fast:Optimize! How to get the greatest performanceout of your code through low-level optimizations in Java”(让Java更快:优化!如何通过在Java中的低级优化,使代码发挥最出色的性能)。作者:Doug Bell。网址:

http://www.javaworld.com/javaworld/jw-04-1997/jw-04-optimize.html

(含一个全面的性能评测程序片,有详尽注释)

[7] “Java Optimization Resources”(Java优化资源)

http://www.cs.cmu.edu/~jch/java/resources.html

[8] “Optimizing Java for Speed”(优化Java,提高速度):

http://www.cs.cmu.edu/~jch/java/speed.html

[9] “An Empirical Study of FORTRAN Programs”(FORTRAN程序实战解析)。作者:Donald Knuth。1971年出版。第1卷,p.105-33,“软件——实践和练习”。

[10] “Building High-Performance Applications and Servers in Java:An Experiential Study”。作者:Jimmy Nguyen,Michael Fraenkel,RichardRedpath,Binh Q. Nguyen以及Sandeep K. Singhal。IBM T.J. Watson ResearchCenter,IBM Software Solutions。

http://www.ibm.com/java/education/javahipr.html

D.4.4 Java专业书籍

[11] 《Advanced Java,Idioms,Pitfalls,Styles, and Programming Tips》。作者:Chris Laffra。Prentice Hall 1997年出版(Java 1.0)。第11章第20小节。

D.4.5 一般书籍

[12] 《Data Structures and C Programs》(数据结构和C程序)。作者:J.Van Wyk。Addison-Wesly 1998年出版。

[13] 《Writing Efficient Programs》(编写有效的程序)。作者:Jon Bentley。Prentice Hall 1982年出版。特别参考p.110和p.145-151。

[14] 《More Programming Pearls》(编程珠玑第二版)。作者:JonBentley。“Association for Computing Machinery”,1998年2月。

[15] 《Programming Pearls》(编程珠玑)。作者:Jone Bentley。Addison-Wesley 1989年出版。第2部分强调了常规的性能改善问题。 [16] 《Code Complete:A Practical Handbook of Software Construction》(完整代码索引:实用软件开发手册)。作者:Steve McConnell。Microsoft出版社1993年出版,第9章。

[17] 《Object-Oriented System Development》(面向对象系统的开发)。作者:Champeaux,Lea和Faure。第25章。

[18] 《The Art of Programming》(编程艺术)。作者:Donald Knuth。第1卷“基本算法第3版”;第3卷“排序和搜索第2版”。Addison-Wesley出版。这是有关程序算法的一本百科全书。

[19] 《Algorithms in C:Fundammentals,Data Structures, Sorting,Searching》(C算法:基础、数据结构、排序、搜索)第3版。作者:RobertSedgewick。Addison-Wesley 1997年出版。作者是Knuth的学生。这是专门讨论几种语言的七个版本之一。对算法进行了深入浅出的解释。

附录E 关于垃圾收集的一些话

“很难相信Java居然能和C++一样快,甚至还能更快一些。”

据我自己的实践,这种说法确实成立。然而,我也发现许多关于速度的怀疑都来自一些早期的实现方式。由于这些方式并非特别有效,所以没有一个模型可供参考,不能解释Java速度快的原因。

我之所以想到速度,部分原因是由于C++模型。C++将自己的主要精力放在编译期间“静态”发生的所有事情上,所以程序的运行期版本非常短小和快速。C++也直接建立在C模型的基础上(主要为了向后兼容),但有时仅仅由于它在C中能按特定的方式工作,所以也是C++中最方便的一种方法。最重要的一种情况是C和C++对内存的管理方式,它是某些人觉得Java速度肯定慢的重要依据:在Java中,所有对象都必须在内存“堆”里创建。

而在C++中,对象是在栈中创建的。这样可达到更快的速度,因为当我们进入一个特定的作用域时,栈指针会向下移动一个单位,为那个作用域内创建的、以栈为基础的所有对象分配存储空间。而当我们离开作用域的时候(调用完毕所有局部构造器后),栈指针会向上移动一个单位。然而,在C++里创建“内存堆”(Heap)对象通常会慢得多,因为它建立在C的内存堆基础上。这种内存堆实际是一个大的内存池,要求必须进行再循环(复用)。在C++里调用delete以后,释放的内存会在堆里留下一个洞,所以再调用new的时候,存储分配机制必须进行某种形式的搜索,使对象的存储与堆内任何现成的洞相配,否则就会很快用光堆的存储空间。之所以内存堆的分配会在C++里对性能造成如此重大的性能影响,对可用内存的搜索正是一个重要的原因。所以创建基于栈的对象要快得多。

同样地,由于C++如此多的工作都在编译期间进行,所以必须考虑这方面的因素。但在Java的某些地方,事情的发生却要显得“动态”得多,它会改变模型。创建对象的时候,垃圾收集器的使用对于提高对象创建的速度产生了显著的影响。从表面上看,这种说法似乎有些奇怪——存储空间的释放会对存储空间的分配造成影响,但它正是JVM采取的重要手段之一,这意味着在Java中为堆对象分配存储空间几乎能达到与C++中在栈里创建存储空间一样快的速度。

可将C++的堆(以及更慢的Java堆)想象成一个庭院,每个对象都拥有自己的一块地皮。在以后的某个时间,这种“不动产”会被抛弃,而且必须复用。但在某些JVM里,Java堆的工作方式却是颇有不同的。它更象一条传送带:每次分配了一个新对象后,都会朝前移动。这意味着对象存储空间的分配可以达到非常快的速度。“堆指针”简单地向前移至处女地,所以它与C++的栈分配方式几乎是完全相同的(当然,在数据记录上会多花一些开销,但要比搜索存储空间快多了)。

现在,大家可能注意到了堆事实并非一条传送带。如按那种方式对待它,最终就要求进行大量的页交换(这对性能的发挥会产生巨大干扰),这样终究会用光内存,出现内存分页错误。所以这儿必须采取一个技巧,那就是著名的“垃圾收集器”。它在收集“垃圾”的同时,也负责压缩堆里的所有对象,将“堆指针”移至尽可能靠近传送带开头的地方,远离发生(内存)分页错误的地点。垃圾收集器会重新安排所有东西,使其成为一个高速、无限自由的堆模型,同时游刃有余地分配存储空间。

为真正掌握它的工作原理,我们首先需要理解不同垃圾收集器(GC)采取的工作方案。一种简单、但速度较慢的GC技术是引用计数。这意味着每个对象都包含了一个引用计数器。每当一个引用同一个对象连接起来时,引用计数器就会自增。每当一个引用超出自己的作用域,或者设为null时,引用计数就会自减。这样一来,只要程序处于运行状态,就需要连续进行引用计数管理——尽管这种管理本身的开销比较少。垃圾收集器会在整个对象列表中移动巡视,一旦它发现其中一个引用计数成为0,就释放它占据的存储空间。但这样做也有一个缺点:若对象相互之间进行循环引用,那么即使引用计数不是0,仍有可能属于应收掉的“垃圾”。为了找出这种自引用的组,要求垃圾收集器进行大量额外的工作。引用计数属于垃圾收集的一种类型,但它看起来并不适合在所有JVM方案中采用。

在速度更快的方案里,垃圾收集并不建立在引用计数的基础上。相反,它们基于这样一个原理:所有非死锁的对象最终都肯定能回溯至一个引用,该引用要么存在于栈中,要么存在于静态存储空间。这个回溯链可能经历了几层对象。所以,如果从栈和静态存储区域开始,并经历所有引用,就能找出所有活动的对象。对于自己找到的每个引用,都必须跟踪到它指向的那个对象,然后跟随那个对象中的所有引用,“跟踪追击”到它们指向的对象……等等,直到遍历了从栈或静态存储区域中的引用发起的整个链接网路为止。中途移经的每个对象都必须仍处于活动状态。注意对于那些特殊的自引用组,并不会出现前述的问题。由于它们根本找不到,所以会自动当作垃圾处理。

在这里阐述的方法中,JVM采用一种“自适应”的垃圾收集方案。对于它找到的那些活动对象,具体采取的操作取决于当前正在使用的是什么变体。其中一个变体是“停止和复制”。这意味着由于一些不久之后就会非常明显的原因,程序首先会停止运行(并非一种后台收集方案)。随后,已找到的每个活动对象都会从一个内存堆复制到另一个,留下所有的垃圾。除此以外,随着对象复制到新堆,它们会一个接一个地聚焦在一起。这样可使新堆显得更加紧凑(并使新的存储区域可以简单地抽离末尾,就象前面讲述的那样)。

当然,将一个对象从一处挪到另一处时,指向那个对象的所有引用(引用)都必须改变。对于那些通过跟踪内存堆的对象而获得的引用,以及那些静态存储区域,都可以立即改变。但在“遍历”过程中,还有可能遇到指向这个对象的其他引用。一旦发现这个问题,就当即进行修正(可想象一个散列表将老地址映射成新地址)。

有两方面的问题使复制收集器显得效率低下。第一个问题是我们拥有两个堆,所有内存都在这两个独立的堆内来回移动,要求付出的管理量是实际需要的两倍。为解决这个问题,有些JVM根据需要分配内存堆,并将一个堆简单地复制到另一个。

第二个问题是复制。随着程序变得越来越“健壮”,它几乎不产生或产生很少的垃圾。尽管如此,一个副本收集器仍会将所有内存从一处复制到另一处,这显得非常浪费。为避免这个问题,有些JVM能侦测是否没有产生新的垃圾,并随即改换另一种方案(这便是“自适应”的缘由)。另一种方案叫作“标记和清除”,Sun公司的JVM一直采用的都是这种方案。对于常规性的应用,标记和清除显得非常慢,但一旦知道自己不产生垃圾,或者只产生很少的垃圾,它的速度就会非常快。

标记和清除采用相同的逻辑:从栈和静态存储区域开始,并跟踪所有引用,寻找活动对象。然而,每次发现一个活动对象的时候,就会设置一个标记,为那个对象作上“记号”。但此时尚不收集那个对象。只有在标记过程结束,清除过程才正式开始。在清除过程中,死锁的对象会被释放然而,不会进行任何形式的复制,所以假若收集器决定压缩一个断续的内存堆,它通过移动周围的对象来实现。

“停止和复制”向我们表明这种类型的垃圾收集并不是在后台进行的;相反,一旦发生垃圾收集,程序就会停止运行。在Sun公司的文档库中,可发现许多地方都将垃圾收集定义成一种低优先级的后台进程,但它只是一种理论上的实验,实际根本不能工作。在实际应用中,Sun的垃圾收集器会在内存减少时运行。除此以外,“标记和清除”也要求程序停止运行。

正如早先指出的那样,在这里介绍的JVM中,内存是按大块分配的。若分配一个大块头对象,它会获得自己的内存块。严格的“停止和复制”要求在释放旧堆之前,将每个活动的对象从源堆复制到一个新堆,此时会涉及大量的内存转换工作。通过内存块,垃圾收集器通常可利用死块复制对象,就象它进行收集时那样。每个块都有一个生成计数,用于跟踪它是否依然“存活”。通常,只有自上次垃圾收集以来创建的块才会得到压缩;对于其他所有块,如果已从其他某些地方进行了引用,那么生成计数都会溢出。这是许多短期的、临时的对象经常遇到的情况。会周期性地进行一次完整清除工作——大块头的对象仍未复制(只是让它们的生成计数溢出),而那些包含了小对象的块会进行复制和压缩。JVM会监视垃圾收集器的效率,如果由于所有对象都属于长期对象,造成垃圾收集成为浪费时间的一个过程,就会切换到“标记和清除”方案。类似地,JVM会跟踪监视成功的“标记与清除”工作,若内存堆变得越来越“散乱”,就会换回“停止和复制”方案。“自定义”的说法就是从这种行为来的,我们将其最后总结为:“根据情况,自动转换停止和复制/标记和清除这两种模式”。

JVM还采用了其他许多加速方案。其中一个特别重要的涉及装载器以及JIT编译器。若必须装载一个类(通常是我们首次想创建那个类的一个对象时),会找到.class文件,并将那个类的字节码送入内存。此时,一个方法是用JIT编译所有代码,但这样做有两方面的缺点:它会花更多的时间,若与程序的运行时间综合考虑,编译时间还有可能更长;而且它增大了执行文件的长度(字节码比扩展过的JIT代码精简得多),这有可能造成内存页交换,从而显著放慢一个程序的执行速度。另一种替代办法是:除非确有必要,否则不经JIT编译。这样一来,那些根本不会执行的代码就可能永远得不到JIT的编译。

由于JVM对浏览器来说是外置的,大家可能希望在使用浏览器的时候从一些JVM的速度提高中获得好处。但非常不幸,JVM目前不能与不同的浏览器进行沟通。为发挥一种特定JVM的潜力,要么使用内建了那种JVM的浏览器,要么只有运行独立的Java应用程序。

附录F 推荐读物

《Java in a Nutshell:A Desktop Quick Reference,第2版》

作者:David Flanagan

出版社:O'Reilly & Assoc

出版时间:1997

简介:对Java 1.1联机文档的一个简要总结。就个人来说,我更喜欢在线阅览文档,特别是在它们变化得如此快的时候。然而,许多人仍然喜欢印刷出来的文档,这样可以省一些上网费。而且这本书也提供了比联机文档更多的讨论。

《The Java Class Libraries:An Annotated Reference》

作者:Patrick Chan和Rosanna Lee

出版社:Addison-Wesley

出版时间:1997

简介:作为一种联机参考资源,应向读者提供足够多的说明,使其简单易用。《Thinking in Java》的一名技术审定员说道:“如果我只能有一本Java书,那么肯定选它。”不过我可没有他那么激动。它太大、太贵,而且示例的质量并不能令我满意。但在遇到麻烦的时候,该书还是很有参考价值的。而且与《Java in a Nutshell》相比,它看起来有更大的深度(当然也有更多的文字)。

《Java Network Programming》

作者:Elliote Rusty Harold

David Flanagan

出版社:O'Reilly

出版时间:1997

简介:在阅读本书前,我可以说根本不理解Java有关网络的问题。后来,我也发现他的Web站点“Cafe au Lait”是个令人激动的、很人个性的以及经常更新的去处,涉及大量有价值的Java开发资源。由于几乎每天更新,所以在这里能看到与Java有关的大量新闻。站点地址是:http://sunsite.unc.edu/javafaq/

《Core Java,第3版》

作者:Cornel和Horstmann

出版社:Prentice-Hall

出版时间:1997

简介:对于自己碰到的问题,若在《Thinking in Java》里找不到答案,这就是一个很好的参考地点。注意:Java 1.1的版本是《Core Java 1.1 Volume 1-Fundamentals & Core Java 1.1 Volume 2-Advanced Features》

《JDBC Database Access with Java》

作者:Hamilton,Cattell和Fisher

出版社:Addison-Wesley

出版时间:1997

简介:如果对SQL和数据库一无所知,这本书就可以作为一个相当好的起点。它也对API进行了详尽的解释,并提供一个“注释参考。与“Java系列”(由JavaSoft授权的唯一一套丛书)的其他所有书籍一样,这本书的缺点也是进行了过份的渲染,只说Java的好话——在这一系列书籍里找不到任何不利于Java的地方。

《Java Programming with CORBA》

作者:Andreas Vogel和Keith Duddy

出版社:Jonh Wiley & Sons

出版时间:1997

简介:针对三种主要的Java ORB(Visbroker,Orbix,Joe),本书分别用大量代码实例进行了详尽的阐述。

《设计模式》

作者:Gamma,Helm,Johnson和Vlissides

出版社:Addison-Wesley

出版时间:1995

简介:这是一本发起了编程领域方案革命的经典书籍。

《UML Toolkit》

作者:Hans-Erik Eriksson和Magnus Penker

出版社:Jonh Wiley & Sons

出版时间:1997

简介:解释UML以及如何使用它,并提供Java的实际案例供参考。配套CD-ROM包含了Java代码以及Rational Rose的一个删减版本。本书对UML进行了非常出色的描述,并解释了如何用它构建实际的系统。

《Practical Algorithms for Programmers》

作者:Binstock和Rex

出版社:Addison-Wesley

出版时间:1995

简介:算法是用C描述的,所以它们很容易就能转换到Java里面。每种算法都有详尽的解释。

posted @ 2024-11-03 11:41  绝不原创的飞龙  阅读(14)  评论(0编辑  收藏  举报