说地道的Java语言

--使非Java程序员能流畅地使用Java程序设计语言

使用一种程序设计语言,就应该专业地使用它。本文是IBM developerWorks中的一篇文章,它描述的都是Java编程中的细节问题,尽管如此,还是值得大家玩味一番,至少我作为一名老鸟还是从中受益了。(2010.02.09最后更新)

学习一种新的程序设计语言比学习一种新的口头语言要容易。但是,在这两种努力中,都需要付出额外的工夫去学着能地道地说这种新的语言。当你已会C或 C++,那么学习Java程序设计语言将不会很困难;这就类似于,当你已会说瑞典语时又去学习丹麦语。语言是不同的,但能相互理解。但如果你不注意,你的口音每次都会暴露出你不是一个本地人。

C++程序员经常会写变了味的Java代码,他们错误地将自己当作语言的转换者,而非说该种语言的本地人。这些代码仍能工作,但对于地道的Java程序员,它们看起来有些问题。结果,地道的Java程序员可能看不起非地道的Java程序员。当从C/C++(或Basic或Fortran或Scheme) 转向Java时,你需要去除某些风格并纠正一些发音,以便你能讲得流畅。

在本文中,我探索了一些Java编程方面的细节,这些细节经常会被忽视,因为它们不是什么大事情,如果有的话。这些都是编程风格和规范上的问题。其中较少的一些有真实可信的理由,有一些甚至还没有这样的理由。但是所有的问题在此时所写的Java程序中都是真实存在的。

这是什么语言?

让我们以一段将华氏温度转化为摄氏温度的程序开始,如清单1所示:

清单1 一点儿C语言代码

floatF, C;floatmin_tmp, max_tmp, x;
min_tmp=0;
max_tmp=300;
x=20;
F=min_tmp;while(F<=max_tmp) {
C=5*(F-32)/9;
printf("%f"t%f"n", F, C);
F=F+x;
}

清单1使用的是什么语言?很显示是C语言--但很等等。看看清单2中的完整应用程序:

清单2 Java程序

classTest  {publicstaticvoidmain(String argv[]) {floatF, C;floatmin_tmp, max_tmp, x;
min_tmp=0;
max_tmp=300;
x=20;
F=min_tmp;while(F<=max_tmp) {
C=5*(F-32)/9;
printf("%f"t%f"n", F, C);
F=F+x;
}
}privatestaticvoidprintf(String format, Object
 args) {
System.out.printf(format, args);
}
}

不管是否相信,清单1和清单2都是由Java语言写成的。只不过它们是用C语言的风格写的Java代码(公平来说,清单1也能是真正的C代码)。然而这些Java代码看起来比较有趣。此处的一些编程风格在抱有C语言思维的人看来只不过是将C代码翻译成了Java代码罢了。

变量为float型,而非double型。

所有的变量都声明在方法的顶部。

初始化在声明之后。

使用while循环,而非for循环。

使用printf,而非println。

main方法的参数声明为argv。

数组的中括号紧跟变量名,而不在类型之后。

从编写的代码能够通过编译或不产生错误答案的意义来看,这些编码风格都没错。单独来看,这些风格没有一个是重要的。然而,把它们都放到一些奇怪的代码中,这就让Java程序员难以阅读,就这如同让美国人去理解基尼(Geordie)。你越少的使用C语言风格,你的代码就越清晰。基于这种思维,我将分析一些 C程序员暴露自己的最常方式,并将展示如何使他们的代码更能适应Java程序员的视角。

命名规范

依你是否来自于C/C++或C#,你们内部可能有不同的命名规范。例如,在C#中类名以小写字母开头,方法和字段的名称以大写字母开头。Java的风格则正好相反。我没有任何明智的理由证明哪一种规范是合理的,但我肯定知道混合使用命名规范将使代码的感觉十分糟糕。它还会导致Bug。当你看到所有的名称都是大写,那就是常量,你对待它的方式就会不同。仅通过查找不匹配被声明类型所使用命名规范的地方,我就已经发现了程序中的很多BUG。

Java程序设计中关于名称的基本原则十分简单,值得去记忆:

类和接口的名称以大写字母开头,如Frame。

方法,字段和局部变量的名称以小写字母开头,如read()。

类,方法和字段名称都要使用驼峰字(camel casing),如InputStream和readFully。

常量--final静态字段,以及某些final局部变量--全部使用大写字母书写,并且各单词之间用下划线分隔,如MAX_CONNECTIONS。

不要使用缩写

像printf和nmtkns这样的名称都是超级计算机都只有32K内存的时代的遗产了。编译器通过将标识符限制为不多与8个字符来节约内存。然而,在过去30年中,这已经不是一个问题了。现在,没有任何理由不为变量和方法的名称使用全拼。非明智的,缺少元音字符的变量名,没有比这更能使你的产品被认为是由C语言程序转变过去的了,如清单3所示:

清单3 Abbrvtd nms r hrd 2 rd

for(inti=0; i
t[i][j]=s[i][j];
}
}

基于驼峰字的非缩写名称要清晰得多,如你在清单4中所见的那样:

清单4 不使用缩写的名称方便阅读

for(introw=0; row
target[row][column]=source[row][column];
}
}

代码读得比写得多,Java语言就为阅读而被改进的。C语言程序员具有一种几乎无可抗拒的诱惑力去弄乱代码;Java程序员则不会。Java语言会把易读性放在优先于简洁性的位置。

有一些缩写十分的通用,你使用它而无需感到愧疚:

针对最大化的max

针对最小化的min

针对InputStream的in

针对OutStream的out

在catch语句块中(但不是在所有地方),针对一个异常的e或ex。

针对数字的num,但只能当用于前缀时,如numTokens或numHits。

针对用于局部的临时变更的tmp--例如,当交换两个值时。

除了上述缩写和其它的一些可能之外,你应该完整地拼写出名称中所有的单词。

变量的声明,初始化和(重复)使用

C语言的早期版本要求所有的变量要声明在方法的开始之处。这使得编译器能够进行特定的优化,这些优化使程序能够运行在RAM很小的环境中。因此,C语言的方法倾向于由几行变量声明开始:

inti, j, k;doublex, y, z;floatcf[], gh[], jk[];

然而,这一风格有一些消极作用。它将变量的声明与其使用分隔开了,使代码有点儿难以为继。此外,这也使它看起来像是一个局部变量会被不同的程序重复使用,而这可能并非程序员的本意。当一个变量引用一个多余的值,而这段代码并非所期望的,那么就这会引起预期之外的Bug。这一风格再与C语言对简短,隐晦变量名的爱好相结合,你就会导致一场灾难了。

在Java语言(以及最新版的C语言)中,变量可以声明在(或靠近)它第一次使用的地方。当你编写Java代码时,就这么做。这能使你的代码安全,更少地出现Bug,并易于阅读。

与此相关的,Java代码常常在每个变量声明的时候就进行初始化。有时候,C程序员编写的代码却像这样:

inti;

i=7;

Java程序员几乎不会这样写代码,尽管这些代码在语法上是正确的。Java程序员会像下面这样编写代码:

inti=7;

这就帮助避免Bug,这样的Bug会导致无意地使用未被初始化的变量。一般地,唯一的例外是,当一个变量要被包含在try-catch/finally块中时。最常出现的情况就是,当代码要在finally语句块中关闭输入流和输出流时,如清单5所示:

清单5 异常处理使得难以恰当地控制变量的作用域

InputStream in;try{
in=newFileInputStream("data.txt");//read from InputStream}finally{if(in!=null) {
in.close();
}
}

然而,这几乎是这一例外唯一能发生的时刻。

终了,该风格的最后一个连锁效应就是Java程序员常常每行只定义一个变量。例如,他们像下面那样初始化三个变量:

inti=3;intj=8;intk=9;

他们不会编写这样的代码:

inti=3, j=8, k=9;

这条语句在语法上是正确的,但在任何时候Java程序员都不会这么做,除非出现一种特别的情况,我下面将会涉及到。

一个老派的C程序员可能会写成四行代码:

inti, j, k;

i=3;

j=8;

k=9;

因此,通常的Java风格会更简洁些,它只需三行代码,因为它将声明与初始化结合在了一起。

将变量置于循环内

经常出现的一种情景就是在循环外声明变量。例如,考虑清单6所示的简单for循环,该循环将计算Fibonacci数列的前20个值:

清单6 C程序员喜欢在循环外声明变量

inthigh=1;intlow=1;inttmp;inti;for(i=1; i<20; i++) {
System.out.println(high);
tmp=high;
high=high+low;
low=tmp;
}

所有4个变量都声明在了循环之外,因此它们就有了过大的作用范围,尽管它们只是用在循环内。这就可能产生Bug,因为变量可用在它们期望之外的范围中。当变量使用例如i和tmp这样通用的名称时,尤其会产生Bug。由一个变量引用的对象会一直存在,并会被随后的代码以意外的方式干扰。

第一个改进(C语言的现代版本也支持这一改进)就是把循环变量i置于循环的内部,如清单7所示:

清单7 将循环变量移入循环内

inthigh=1;intlow=1;inttmp;for(inti=1; i<20; i++) {
System.out.println(high);
tmp=high;
high=high+low;
low=tmp;
}

但不能就此止步。有经验的Java程序还会将把tmp变量置于循环的内部,如清单8所示:

清单8 在循环内声明临时变量

inthigh=1;intlow=1;for(inti=1; i<20; i++) {
System.out.println(high);inttmp=high;
high=high+low;
low=tmp;
}

对程序速度有着狂热崇拜的大学生们有时候会反对道在循环中做无谓的工作会降低代码的运行效率。然而,在运行时,变量的声明完全不做任何实际的工作。在Java平台上,无论怎样,将声明置入循环内都不会产生性能上的损失。

许多程序员,包括许多经验丰富的Java程序员,也会止步与此。然而,还有一点儿有用的技术能将所有的变量都转移到循环中。你可在for循环的初始化语句中声明超过一个变量,只需通过逗号进行分隔,如清单9所示:

清单9 所有的变量都置入循环内

for(inti=1, high=1, low=1; i<20; i++) {
System.out.println(high);inttmp=high;
high=high+low;
low=tmp;
}

现在才是把仅仅语法上通顺的代码转化成真正的专家级代码。这种紧紧地束缚局部变量作用域的能力就是你为什么看到Java语言代码中的for循环比C语言代码多得多而while循环少得多的一个重要原因。

不要重用变量

由上述可得出的推论就是Java程序员很少为不同的值和对象重用局部变量。例如,清单10为一些按钮设置与之关联的侦听器:

清单10 重用局部变量

Button b=newButton("Play");
b.addActionListener(newPlayAction());
b=newButton("Pause");
b.addActionListener(newPauseAction());
b=newButton("Rewind");
b.addActionListener(newRewindAction());
b=newButton("FastForward");
b.addActionListener(newFastForwardAction());
b=newButton("Stop");
b.addActionListener(newStopAction());

有经验的Java程序员会使用5个不同的局部变量来重写这段代码,如清单11所示:

清单11 未被重用的变量

Button play=newButton("Play");
play.addActionListener(newPlayAction());
Button pause=newButton("Pause");
pause.addActionListener(newPauseAction());
Button rewind=newButton("Rewind");
rewind.addActionListener(newRewindAction());
Button fastForward=newButton("FastForward");
fastForward.addActionListener(newFastForwardAction());
Button stop=newButton("Stop");
stop.addActionListener(newStopAction());

为多个逻辑上不同的值或对象重用一个局部变量可能产生Bug。实质上,局部变量(尽管它们并不总是指向对象)在对内存和时间都很敏感的环境中都是适用的。只要你需要,不要惧怕使用众多不同的局部变量。

最好使用基本数据类型

Java语言有8种基本数据类型,但只使用了其中的6种。在Java代码中,float远不如在C代码中用得多。在Java代码中你几乎看不到float 型变量或常量;而double型的则很多。float变量仅仅被用于处理多维浮点型数组,这能在存储空间意义重大的环境中限制数据的精度。否则,使每个变量都为double型。

比float型还不常见的就是short型。我很少在Java代码中看到short型变量。曾经唯一出现过的情况--我要警告你,这是一种极端罕见的情况 --就是当要读取的外部定义的数据格式中包含16位符号整数类型时。在这种情况下,大部分程序员都会把这些数据当作int型数据去读取。

控制私有作用域

你见过像清单2中示例那样的equals方法吗?

清单12 由C++程序员编写的一个eqauls()方法

publicclassFoo {privatedoublex;publicdoublegetX() {returnthis.x;
}publicbooleanequals(Object o) {if(oinstanceofFoo) {
Foo f=(Foo) o;returnthis.x==f.getX();
}returnfalse;
}
}

就技术上而言,该方法是正确的,但我能向你保证这个类是由一位还没改造好的C++程序员写成的。对私有域x的应用并在同一方法甚至同一行中使用公有的 getter方法getX()泄露了这一点。在C++中,这样做是必须的,因为私有性是限定在对象而不是类中的。即,在C++中,同一个类的对象看不到其它对象的私有成员变量,它们必须使用访问器方法。在Java语言中,私有性是限定在类而不是对象中,类型Foo的两个对象中的一个能直接访问到另一个的私有域。

有些细微的--但往往是不相关的--思考会建议你更应直接访问字段而不是使用访问器方法,或者在Java代码中使用相反的方式。访问字段可能稍快些,但很少见。有时候,通过访问器进行访问相比于直接访问字段可以提供一点儿不同的值,特别是当使用子类时。但是,在Java语言中,没有任何理由在同一个类的同一行中既使用字段访问又使用访问器访问。

标点和语法风格

此处有一些不同于C语言的Java语言风格,其中一些例子应用了特定的Java语言特性。

在类型处放置数组的括号

Java语言可以如C语言那样声明数组:

intk[];doubletemperature[];

String names[];

然而,Java语言也提供了另一种语法,将数组括号置于类型而不是变量之后:

int[] k;double[] temperatures;

String[] names;

大部分Java程序员已经采用了第二种风格。我们就可以说,k是int的数组类型,temperatures是double的数组类型,names是String类型的数组。

与其它局部变量一样,Java程序员也倾向于在数组的声明处对其进行初始化:

int[] k=newint[10];double[] temperatures=newdouble[75];

String[] names=newString[32];

Use s == null, not null == s

谨慎的C程序员已经学会了将常量放置在比较符的左边。例如:

if(7==x) doSomething();

在此处,其目的在于避免无意地使用单等于号的赋值操作符,而不是双等于号的比较操作符。

if(7=x) doSomething();

将常量置于左边会产生一个编译时错误。这项技术是C语言提倡的编程实践。它能帮助防止实际中的Bug,因为将常量置于右边将总是会返回true。

但不同于C语言,Java语言将int与boolean类型分隔开了。赋值操作符返回一个int值,然而比较操作符返回boolean值。结果,if (x = 7)已是一个编译时错误,所以没有任何理由在比较语句中使用不自然的格式if (7 == x),熟练的Java程序员就不会这么做。

连接字符串而不要格式化它们

多年来,Java语言一直没有printf函数。最后是在Java 5中加上了这个方法,但它不太常用。特别地,格式化字符串是针对少数情况的特定领域语言,即当你想将数字格式化成特定的宽度,或在小数点之后有一定数量的空格。然而,C程序员会倾向于在他们的Java代码中过度使用printf方法。一般而言,不要把它用作简单字符串连接的替代器。例如:

System.out.println("There were"+numErrors+"errors reported.");

这强于:

System.out.printf("There were %d errors reported."n", numErrors);

使用字符串连接的版本易于阅读,特别是对于简单情况,并且潜在的Bug会更少,因为不存在格式化字符串中的占位符与参数变量的数量或类型不匹配的风险。

后递增强于前递增

在有些地方,i++与++i之间的区别是有特殊意义的。Java程序员对这样的地方有一个特别的名称,他们称其为"Bug"。

你编写的代码决不应依赖于前递增与后递增的区别(对于C语言,也是如此)。很难遵循这一规则,也很容易出错。如果你发现自己编写的代码在应用前递增与后递增的区别时产生了错误,你就要重构代码,把这些代码分割成不同的语句以便不再出现这些错误。

在前递增与后递增的区别无关紧要的地方--例如,for循环中的递增量--相较于先递增,Java程序员更喜欢后递增,大约为4比1。i++要比++i普遍多了。我不能评判其中的原因,但实际情况即如此。如果你写了++i,其他人在读你的代码时就会浪费时间去想为什么你会这样写。所以,你应该总是使用后递增,除非你有一个使用前递增的特殊理由(而你就不应该有使用前递增的理由)。

错误处理

错误处理是Java编程中最困惑的问题之一,也是把程序设计语言的大师级设计者同泛泛之辈区分开的因素之一。实际上,错误处理本身就是一件程序作品的基础。简言之,恰当地使用异常,不要返回错误的代码。

非地道的Java程序员范的第一个错误就是对于程序错误是返回一个值,而不是抛出一个异常。事实上,回溯到最初的Java 1.0时代,在Sun的所有程序员完全熟悉这种新语言之前,你甚至可以在Java语言自己的一些API中看到这种情况。例如,想想 java.io.File中的delete()方法。

publicbooleandelete()

如果文件或目录被成功删除了,该方法返回true;否则,它返回false。该方法应该做的是,当成功删除时什么都不返回,如果文件因故不能被删除时抛出一个异常:

publicvoiddelete()throwsIOException

当方法返回程序错误的值时,对该方法的每一个调用都会围绕着对该错误的处理代码。这就使得在通常情况下,当没有任何问题且一切运行良好时,难以遵循和理解该方法的正常执行流程。相反,当由异常来指定错误条件时,则可不使用这种方法,而是将错误处理程序放到文件后面的一个独立代码块中。如果有更合适的地方来处理该问题,甚至可以把它放到其它类和方法中。

这也带给我在错误处理方面的第二个反模式。来自于C或C++背景的程序员有时候会尝试着在尽可能靠近异常抛出的地方去处理异常。极端的做法,它会产生如清单13那样代码:

清单13 过早的处理异常

publicvoidreadNumberFromFile(String name) {
FileInputStream in;try{
in=newFileInputStream(name);
}catch(FileNotFoundException e) {
System.err.println(e.getMessage());return;
}
InputStreamReader reader;try{
reader=newInputStreamReader(in,"UTF-8");
}catch(UnsupportedEncodingException e) {
System.err.println("This can't happen!");return;
}
BufferedReader buffer=newBufferedReader(reader);
String line;try{
line=buffer.readLine();
}catch(IOException e) {
System.err.println(e.getMessage());return;
}doublex;try{
x=Double.parseDouble(line);
}catch(NumberFormatException e) {
System.err.println(e.getMessage());return;
}
System.out.println("Read:"+x);
}

这些代码难以阅读,甚至比if (errorCondition)测试更让人费解,而异常处理就是被设计来替代这种测试的。流畅的Java代码将错误处理从失败发生的点转移开。它不会把错误处理代码与正常的执行流程混在一起。由清单14所示的新版本代码就易于学习和理解:

清单14 将程序执行主流程的代码保持在一起

publicvoidreadNumberFromFile(String name) {try{
FileInputStream in=newFileInputStream(name);
InputStreamReader reader=newInputStreamReader(in,"UTF-8");
BufferedReader buffer=newBufferedReader(reader);
String line=buffer.readLine();doublex=Double.parseDouble(line);
System.out.println("Read:"+x);
in.close();
}catch(NumberFormatException e) {
System.err.println("Data format error");
}catch(IOException e) {
System.err.println("Error reading from file:"+name);
}
}

偶尔,你可能需要嵌套try-catch语句块以将会产生相同异常的不同失败模式分隔开,但这种情况并不常见。一般的经验是,如果在一个方法内有多个有价值的try-catch块,那么该方法就是太大了,无论如何都应该把它恰当地分解成更小的方法。

最后,来自于其它语言的新接触Java编程的程序员常范地错误就是他们一定要在受检的异常抛出的地方捕获它们。通常,抛出异常的方法不应该捕获该异常。例如,考虑一个复制I/O流的方法,如清单15所示:

清单15 过早的处理异常

publicstaticvoidcopy(InputStream in, OutputStream out) {try{while(true) {intdatum=in.read();if(datum==-1)break;
out.write(datum);
}
out.flush();
}catch(IOException ex) {
System.err.println(ex.getMessage());
}
}

该方法没有足够的信息来正确地处理可能发生的IOException。它不知道是谁调用了它自己,不知道失败发生的结果。该方法唯一能做的合理的事情就是将IOException抛给它的调用者。该方法的正确写法如清单16所示:

清单16 不是所有的异常都需要在它第一次可能发生的地方被捕获

publicstaticvoidcopy(InputStream in, OutputStream out)throwsIOException {while(true) {intdatum=in.read();if(datum==-1)break;
out.write(datum);
}
out.flush();
}

这段代码更短,更简单,也更聪明,它将错误信息传递给了最适合处理该异常的代码。

这真的是问题吗?

这些都不是严重的问题。其中一些是出于便利的原因:在第一次使用的地方声明变量;当你不知道该如何应对异常时,就把它们抛出。另一个则纯粹出于风格上的规范(使用args,而非argv;使用i++,而非++i)。我不想说遵循这些规则会使你的代码运行得更快,而且只有其中的少数规则才会帮助你避免 Bug。然而,在帮助你成为一名地道的Java程序员时,所有这些规则都是有必要的。

不管怎样,说话(或写代码)时没有外地口音会使其他人更尊重你,更注意你的话,甚至向你说得更多。另外,地道使用Java程序设计语言确实比说地道的法语、汉语或英语要容易得多了。一旦你学会了这种语言,花费额外的力气把它讲得地道些是值得的。

祝大家新春愉快 :-)