說地道的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語言代碼
float F, C;
float min_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程序
class Test {
public static void main(String argv[]) {
float F, C;
float min_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;
}
}
private static void printf(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年中,這已經不是一個問題了?,F在,沒有任何理由不為變量和方法的名稱使用全拼。非明智的,缺少元音字符的變量名,沒有比這更能使你的產品被認為是由C語言程序轉變過去的了,如清單3所示:
清單3 Abbrvtd nms r hrd 2 rd
for (int i = 0; i < nr; i++) {
for (int j = 0; j < nc; j++) {
t[i][j] = s[i][j];
}
}
基于駝峰字的非縮寫名稱要清晰得多,如你在清單4中所見的那樣:
清單4 不使用縮寫的名稱方便閱讀
for (int row = 0; row < numRows; row++) {
for (int column = 0; column < numColumns; column++) {
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語言的方法傾向于由幾行變量聲明開始:
int i, j, k;
double x, y, z;
float cf[], gh[], jk[];
然而,這一風格有一些消極作用。它將變量的聲明與其使用分隔開了,使代碼有點兒難以為繼。此外,這也使它看起來像是一個局部變量會被不同的程序重復使用,而這可能并非程序員的本意。當一個變量引用一個多余的值,而這段代碼并非所期望的,那么就這會引起預期之外的Bug。這一風格再與C語言對簡短,隱晦變量名的愛好相結合,你就會導致一場災難了。
在Java語言(以及最新版的C語言)中,變量可以聲明在(或靠近)它第一次使用的地方。當你編寫Java代碼時,就這么做。這能使你的代碼安全,更少地出現Bug,并易于閱讀。
與此相關的,Java代碼常常在每個變量聲明的時候就進行初始化。有時候,C程序員編寫的代碼卻像這樣:
int i;
i = 7;
Java程序員幾乎不會這樣寫代碼,盡管這些代碼在語法上是正確的。Java程序員會像下面這樣編寫代碼:
int i = 7;
這就幫助避免Bug,這樣的Bug會導致無意地使用未被初始化的變量。一般地,唯一的例外是,當一個變量要被包含在try-catch/finally塊中時。最常出現的情況就是,當代碼要在finally語句塊中關閉輸入流和輸出流時,如清單5所示:
清單5 異常處理使得難以恰當地控制變量的作用域
InputStream in;
try {
in = new FileInputStream("data.txt");
// read from InputStream
}
finally {
if (in != null) {
in.close();
}
}
然而,這幾乎是這一例外唯一能發生的時刻。
終了,該風格的最后一個連鎖效應就是Java程序員常常每行只定義一個變量。例如,他們像下面那樣初始化三個變量:
int i = 3;
int j = 8;
int k = 9;
他們不會編寫這樣的代碼:
int i=3, j=8, k=9;
這條語句在語法上是正確的,但在任何時候Java程序員都不會這么做,除非出現一種特別的情況,我下面將會涉及到。
一個老派的C程序員可能會寫成四行代碼:
int i, j, k;
i = 3;
j = 8;
k = 9;
因此,通常的Java風格會更簡潔些,它只需三行代碼,因為它將聲明與初始化結合在了一起。
將變量置于循環內
經常出現的一種情景就是在循環外聲明變量。例如,考慮清單6所示的簡單for循環,該循環將計算Fibonacci數列的前20個值:
清單6 C程序員喜歡在循環外聲明變量
int high = 1;
int low = 1;
int tmp;
int i;
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 將循環變量移入循環內
int high = 1;
int low = 1;
int tmp;
for (int i = 1; i < 20; i++) {
System.out.println(high);
tmp = high;
high = high+ low;
low = tmp;
}
但不能就此止步。有經驗的Java程序還會將把tmp變量置于循環的內部,如清單8所示:
清單8 在循環內聲明臨時變量
int high = 1;
int low = 1;
for (int i = 1; i < 20; i++) {
System.out.println(high);
int tmp = high;
high = high+ low;
low = tmp;
}
對程序速度有著狂熱崇拜的大學生們有時候會反對道在循環中做無謂的工作會降低代碼的運行效率。然而,在運行時,變量的聲明完全不做任何實際的工作。在Java平臺上,無論怎樣,將聲明置入循環內都不會產生性能上的損失。
許多程序員,包括許多經驗豐富的Java程序員,也會止步與此。然而,還有一點兒有用的技術能將所有的變量都轉移到循環中。你可在for循環的初始化語句中聲明超過一個變量,只需通過逗號進行分隔,如清單9所示:
清單9 所有的變量都置入循環內
for (int i = 1, high = 1, low = 1; i < 20; i++) {
System.out.println(high);
int tmp = high;
high = high+ low;
low = tmp;
}
現在才是把僅僅語法上通順的代碼轉化成真正的專家級代碼。這種緊緊地束縛局部變量作用域的能力就是你為什么看到Java語言代碼中的for循環比C語言代碼多得多而while循環少得多的一個重要原因。
不要重用變量
由上述可得出的推論就是Java程序員很少為不同的值和對象重用局部變量。例如,清單10為一些按鈕設置與之關聯的偵聽器:
清單10 重用局部變量
Button b = new Button("Play");
b.addActionListener(new PlayAction());
b = new Button("Pause");
b.addActionListener(new PauseAction());
b = new Button("Rewind");
b.addActionListener(new RewindAction());
b = new Button("FastForward");
b.addActionListener(new FastForwardAction());
b = new Button("Stop");
b.addActionListener(new StopAction());
有經驗的Java程序員會使用5個不同的局部變量來重寫這段代碼,如清單11所示:
清單11 未被重用的變量
Button play = new Button("Play");
play.addActionListener(new PlayAction());
Button pause = new Button("Pause");
pause.addActionListener(new PauseAction());
Button rewind = new Button("Rewind");
rewind.addActionListener(new RewindAction());
Button fastForward = new Button("FastForward");
fastForward.addActionListener(new FastForwardAction());
Button stop = new Button("Stop");
stop.addActionListener(new StopAction());
為多個邏輯上不同的值或對象重用一個局部變量可能產生Bug。實質上,局部變量(盡管它們并不總是指向對象)在對內存和時間都很敏感的環境中都是適用的。只要你需要,不要懼怕使用眾多不同的局部變量。
最好使用基本數據類型
Java語言有8種基本數據類型,但只使用了其中的6種。在Java代碼中,float遠不如在C代碼中用得多。在Java代碼中你幾乎看不到float 型變量或常量;而double型的則很多。float變量僅僅被用于處理多維浮點型數組,這能在存儲空間意義重大的環境中限制數據的精度。否則,使每個變量都為double型。
比float型還不常見的就是short型。我很少在Java代碼中看到short型變量。曾經唯一出現過的情況--我要警告你,這是一種極端罕見的情況 --就是當要讀取的外部定義的數據格式中包含16位符號整數類型時。在這種情況下,大部分程序員都會把這些數據當作int型數據去讀取。
控制私有作用域
你見過像清單2中示例那樣的equals方法嗎?
清單12 由C++程序員編寫的一個eqauls()方法
public class Foo {
private double x;
public double getX() {
return this.x;
}
public boolean equals(Object o) {
if (o instanceof Foo) {
Foo f = (Foo) o;
return this.x == f.getX();
}
return false;
}
}
就技術上而言,該方法是正確的,但我能向你保證這個類是由一位還沒改造好的C++程序員寫成的。對私有域x的應用并在同一方法甚至同一行中使用公有的 getter方法getX()泄露了這一點。在C++中,這樣做是必須的,因為私有性是限定在對象而不是類中的。即,在C++中,同一個類的對象看不到其它對象的私有成員變量,它們必須使用訪問器方法。在Java語言中,私有性是限定在類而不是對象中,類型Foo的兩個對象中的一個能直接訪問到另一個的私有域。
有些細微的--但往往是不相關的--思考會建議你更應直接訪問字段而不是使用訪問器方法,或者在Java代碼中使用相反的方式。訪問字段可能稍快些,但很少見。有時候,通過訪問器進行訪問相比于直接訪問字段可以提供一點兒不同的值,特別是當使用子類時。但是,在Java語言中,沒有任何理由在同一個類的同一行中既使用字段訪問又使用訪問器訪問。
標點和語法風格
此處有一些不同于C語言的Java語言風格,其中一些例子應用了特定的Java語言特性。
在類型處放置數組的括號
Java語言可以如C語言那樣聲明數組:
int k[];
double temperature[];
String names[];
然而,Java語言也提供了另一種語法,將數組括號置于類型而不是變量之后:
int[] k;
double[] temperatures;
String[] names;
大部分Java程序員已經采用了第二種風格。我們就可以說,k是int的數組類型,temperatures是double的數組類型,names是String類型的數組。
與其它局部變量一樣,Java程序員也傾向于在數組的聲明處對其進行初始化:
int[] k = new int[10];
double[] temperatures = new double[75];
String[] names = new String[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()方法。
public boolean delete()
如果文件或目錄被成功刪除了,該方法返回true;否則,它返回false。該方法應該做的是,當成功刪除時什么都不返回,如果文件因故不能被刪除時拋出一個異常:
public void delete() throws IOException
當方法返回程序錯誤的值時,對該方法的每一個調用都會圍繞著對該錯誤的處理代碼。這就使得在通常情況下,當沒有任何問題且一切運行良好時,難以遵循和理解該方法的正常執行流程。相反,當由異常來指定錯誤條件時,則可不使用這種方法,而是將錯誤處理程序放到文件后面的一個獨立代碼塊中。如果有更合適的地方來處理該問題,甚至可以把它放到其它類和方法中。
這也帶給我在錯誤處理方面的第二個反模式。來自于C或C++背景的程序員有時候會嘗試著在盡可能靠近異常拋出的地方去處理異常。極端的做法,它會產生如清單13那樣代碼:
清單13 過早的處理異常
public void readNumberFromFile(String name) {
FileInputStream in;
try {
in = new FileInputStream(name);
} catch (FileNotFoundException e) {
System.err.println(e.getMessage());
return;
}
InputStreamReader reader;
try {
reader = new InputStreamReader(in, "UTF-8");
} catch (UnsupportedEncodingException e) {
System.err.println("This can't happen!");
return;
}
BufferedReader buffer = new BufferedReader(reader);
String line;
try {
line = buffer.readLine();
} catch (IOException e) {
System.err.println(e.getMessage());
return;
}
double x;
try {
x = Double.parseDouble(line);
}
catch (NumberFormatException e) {
System.err.println(e.getMessage());
return;
}
System.out.println("Read: " + x);
}
這些代碼難以閱讀,甚至比if (errorCondition)測試更讓人費解,而異常處理就是被設計來替代這種測試的。流暢的Java代碼將錯誤處理從失敗發生的點轉移開。它不會把錯誤處理代碼與正常的執行流程混在一起。由清單14所示的新版本代碼就易于學習和理解:
清單14 將程序執行主流程的代碼保持在一起
public void readNumberFromFile(String name) {
try {
FileInputStream in = new FileInputStream(name);
InputStreamReader reader = new InputStreamReader(in, "UTF-8");
BufferedReader buffer = new BufferedReader(reader);
String line = buffer.readLine();
double x = 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 過早的處理異常
public static void copy(InputStream in, OutputStream out) {
try {
while (true) {
int datum = in.read();
if (datum == -1) break;
out.write(datum);
}
out.flush();
} catch (IOException ex) {
System.err.println(ex.getMessage());
}
}
該方法沒有足夠的信息來正確地處理可能發生的IOException。它不知道是誰調用了它自己,不知道失敗發生的結果。該方法唯一能做的合理的事情就是將IOException拋給它的調用者。該方法的正確寫法如清單16所示:
清單16 不是所有的異常都需要在它第一次可能發生的地方被捕獲
public static void copy(InputStream in, OutputStream out) throws IOException {
while (true) {
int datum = in.read();
if (datum == -1) break;
out.write(datum);
}
out.flush();
}
這段代碼更短,更簡單,也更聰明,它將錯誤信息傳遞給了最適合處理該異常的代碼。
這真的是問題嗎?
這些都不是嚴重的問題。其中一些是出于便利的原因:在第一次使用的地方聲明變量;當你不知道該如何應對異常時,就把它們拋出。另一個則純粹出于風格上的規范(使用args,而非argv;使用i++,而非++i)。我不想說遵循這些規則會使你的代碼運行得更快,而且只有其中的少數規則才會幫助你避免 Bug。然而,在幫助你成為一名地道的Java程序員時,所有這些規則都是有必要的。
不管怎樣,說話(或寫代碼)時沒有外地口音會使其他人更尊重你,更注意你的話,甚至向你說得更多。另外,地道使用Java程序設計語言確實比說地道的法語、漢語或英語要容易得多了。一旦你學會了這種語言,花費額外的力氣把它講得地道些是值得的。
祝大家新春愉快 :-)