Java戲法
我們經常遇到這樣的情況,有些代碼的行為出乎意料。Java語言有很多奇怪的地方,即使有經驗的開發者也可能會感到意外。
老實說,經常有資歷較淺的同事來問,“執行這段代碼有什么樣的結果?”,讓人措手不及。“我可以告訴你,但是如果你自己找出答案,學到的會更多”, 這是很常見的答復。現在可別這么說了,可以先吸引一下他的注意力(哦……我想我看到安吉麗娜·朱莉了,藏在我們的構建服務器后面呢,你可以快去看一下 嗎?),利用這個時間,快速過一下這篇文章吧。
本文將介紹一些Java的奇怪之處,以幫助開發者做好更充分的準備,使他們再遇到結果令人意外的代碼時,能夠很好地應對。
不可理喻的標識符
我們很熟悉定義合法的Java標識符的規則:
- 一個標識符是由一個或多個字符(可以是字母、數字、$或下劃線)組成的集合。
- 標識符必須以字母、$或下劃線開頭。
- Java關鍵字不能用作標識符。
- 標識符中的字符沒有數量限制。
- 也可以使用從\u00c0到\ud7a3之間的Unicode字符。
規則非常簡單,但有些有趣的例子會讓人驚訝。比如,開發者可以將類名用作標識符,這是沒有限制的:
<pre class="brush:java; toolbar: true; auto-links: false;">//類名可以用作標識符
String String = "String"; Object Object = null; Integer Integer = new Integer(1); //讓代碼難以理解怎么樣? Float Double = 1.0f; Double Float = 2.0d; if (String instanceof String) { if (Float instanceof Double) { if (Double instanceof Float) { System.out.print("Can anyone read this code???"); } } }</pre>
下面的標識符也都是合法的:
int $ =1; int € = 2; int £ = 3; int _ = 4; long $€£ = 5; long €_£_$ = 6; long $€£$€£$€£$€£$€£$€£$€_________$€£$€£$€£$€£$€£$€£$€£$€£$€£_____ = 7;
此外,請記住,同樣的名字可以同時用于變量和標簽。通過分析上下文,編譯器知道引用的是哪一個。
int £ = 1;
£: for (int € = 0; € < £; €++) {
if (€ == £) {
break £;
}
} 當然,不要忘了標識符的規則可以應用于變量名、方法名、標簽和類名:
class $ {}
interface _ {}
class € extends $ implements _ {} 所以我們學到了很厲害的一招,那就是可以編寫沒有人能理解的代碼,包括我們自己!
NullPointerException從何而來?
自動裝箱是在Java 5中引入的,給我們帶來了很多方便,我們不用在基本類型和其包裝器類型之間跳來跳去了:
int primitiveA = 1; Integer wrapperA = primitiveA; wrapperA++; primitiveA = wrapperA;
運行時并沒有為了支持這種變化而做修改,大部分工作都是編譯時完成的。對于前面這段代碼,編譯器會生成類似下面這樣的代碼:
int primitiveA = 1; Integer wrapperA = new Integer(primitiveA); int tmpPrimitiveA = wrapperA.intValue(); tmpPrimitiveA++; wrapperA = new Integer(tmpPrimitiveA); primitiveA = wrapperA.intValue();
前面的自動裝箱也可以應用于方法調用:
public static int calculate(int a) {
int result = a + 3;
return result;
}
public static void main(String args[]) {
int i1 = 1;
Integer i2 = new Integer(1);
System.out.println(calculate(i1));
System.out.println(calculate(i2));
} 真棒,對于以基本類型為參數的方法,我們可以向其傳遞相應的包裝器類型,讓編譯器來執行變換:
public static void main(String args[]) {
int i1 = 1;
Integer i2 = new Integer(1);
System.out.println(calculate(i1));
int i2Tmp = i2.intValue();
System.out.println(calculate(i2Tmp));
} 稍作修改,再來試試:
public static void main(String args[]) {
int i1 = 1;
Integer i2 = new Integer(1);
Integer i3 = null;
System.out.println(calculate(i1));
System.out.println(calculate(i2));
System.out.println(calculate(i3));
} 和前面一樣,這段代碼會被翻譯成:
public static void main(String args[]) {
int i1 = 1;
Integer i2 = new Integer(1);
Integer i3 = null;
System.out.println(calculate(i1));
int i2Tmp = i2.intValue();
System.out.println(calculate(i2Tmp));
int i3Tmp = i3.intValue();
System.out.println(calculate(i3Tmp));
} 當然,這段代碼會讓我們看到老朋友NullPointerException。像下面這種更簡單的情況,同樣如此:
public static void main(String args[]) {
Integer iW = null;
int iP = iW;
} 所以在使用自動拆箱時一定要非常小心,它可能導致NullPointerException;而在該特性引入之前,是不可能遇到此類異常的。更糟糕 的是,識別這些代碼模式有時并不容易。如果必須將一個包裝器類型的變量轉換成基本類型變量,而且不確定其是否可能為null,那就要為代碼做好保護措施。
包裝器類型遭遇同一性危機
繼續自動裝箱這個話題,看一下下面的代碼:
Short s1 = 1; Short s2 = s1; System.out.println(s1 == s2);
當然打印true了。現在來點有趣的:
Short s1 = 1; Short s2 = s1; s1++; System.out.println(s1 == s2);
輸出成了false。等等,什么情況?難道s1和s2引用的不是同一個對象嗎?JVM真是瘋了!還是用前面提到的代碼翻譯機制來看看吧:
Short s1 = new Short((short)1); Short s2 = s1; short tempS1 = s1.shortValue(); tempS1++; s1 = new Short(tempS1); System.out.println(s1 == s2);
哦……這么看是更合理了,不是嗎?使用自動裝箱的時候總得小心!
媽媽快看,沒有異常!
下面這個非常簡單,但是很多有經驗的Java開發者都會中招。閑話少說,看代碼:
NullTest myNullTest = null; System.out.println(myNullTest.getInt());
當看到這段代碼時,很多人會以為會出現NullPointerException。果真如此嗎?看看其余代碼再說:
class NullTest {
public static int getInt() {
return 1;
}
} 永遠記住,類變量和類方法的使用,僅僅依賴引用的類型。即使引用為null,仍然可以調用。從良好實踐的角度來看,明智的做法是使用NullTest.getInt()來代替myNullTest.getInt(),但鬼知道什么時候會碰上這樣的代碼。
變長參數和數組,必要的變通
變長參數特性帶來了一個強大的概念,可以幫助開發者簡化代碼。不過變長參數的背后是什么呢?不多不少,就是一個數組。
public void calc(int... myInts) {}
calc(1, 2, 3); 編譯器會將前面的代碼翻譯成類似這樣:
int[] ints = {1, 2, 3};
calc(ints); 當心空調用語句,這相當于傳遞了一個null作為參數。
calc(); 等價于 int[] ints = null; calc(ints);
當然,下面的代碼會導致編譯錯誤,因為兩條語句是等價的:
public void m1(int[] myInts) { ... }
public void m1(int... myInts) { ... } 可變的常量
大部分開發者認為,當變量定義中出現final關鍵字時,指示的就是一個常量,也就是說,這個變量的值不可改變。這并不完全正確,當final關鍵字應用于變量時,只是說明該變量只能賦值一次。
class MyClass {
private final int myVar;
private int myOtherVar = getMyVar();
public MyClass() {
myVar = 10;
}
public int getMyVar() {
return myVar;
}
public int getMyOtherVar() {
return myOtherVar;
}
public static void main(String args[]) {
MyClass mc = new MyClass();
System.out.println(mc.getMyVar());
System.out.println(mc.getMyOtherVar());
}
} 前面的代碼將打印10 0。因此,在處理final變量時,必須區分兩種情況:一種是在編譯時就賦了默認值的,這種就是常量;另一種是在運行時初始化的。
覆蓋的特色
請記住,從Java 5開始,覆蓋方法的返回類型可以與被覆蓋方法不同。唯一的規則是,覆蓋方法的返回類型是被覆蓋方法的返回類型的子類型。所以在Java 5中下面的代碼成了合法的:
class A {
public A m() {
return new A();
}
}
class B extends A {
public B m() {
return new B();
}
}</pre>
重載操作符
就操作符重載而言,Java不是特別強,但它確實支持+操作符的重載。該操作符可以用于算術加法和字符串連接,具體取決于上下文。
int val = 1 + 2;
String txt = "1" + "2";
當字符串中混入了數值類型,事情就復雜了。但是規則很簡單,在遇到字符串操作數之前,會一直執行算術加法。一出現字符串,兩個操作數都會被轉為字符串(如果需要的話),并執行一次字符串連接。下面例子說明了不同的組合:
System.out.println(1 + 2); //執行加法,打印3
System.out.println("1" + "2"); //執行字符串連接,打印12
System.out.println(1 + 2 + 3 + "4" + 5); //執行加法,直到發現"4",然后執行字符串連接,打印645
System.out.println("1" + "2" + "3" + 4 + 5); //執行字符串連接,打印12345</pre>
奇怪的日期格式
這個花招與DateFormat的實現有關,其使用方式有一定的誤導性,而且有的時候,代碼到了產品中,問題才會暴露出來。
DateFormat 的parse方法會解析一個字符串,并生成一個日期。解析過程是根據定義的日期格式掩碼來工作的。根據JavaDoc,如果指定的字符串的開頭部分無法解析,會拋出一個ParseException。這個定義很模糊,可以有不同的解釋。大部分開發者認為,如果字符串參數與定義的格式不匹配,會拋出ParseException。但情況并非總是如此。
對于SimpleDateFormat,大家應該非常小心。當面對下面的代碼時,大部分開發者認為會拋出ParseException。
String date = "16-07-2009";
SimpleDateFormat sdf = new SimpleDateFormat("ddmmyyyy");
try {
Date d = sdf.parse(date);
System.out.println(DateFormat.getDateInstance(DateFormat.MEDIUM,
new Locale("US")).format(d));
} catch (ParseException pe) {
System.out.println("Exception: " + pe.getMessage());
}</pre>
運行這段代碼,會產生下列輸出:Jan 16,
0007。真是奇怪,編譯器竟然沒有指出字符串與預期的格式不匹配,而是繼續處理,而且盡其最大努力來解析文本。請注意,這里有兩個隱藏的花招。其一,月
份的掩碼是MM,而mm用于分鐘,這就解釋了為什么月份被設置成了一月。其二,DecimalFormat類的parse方
法將一直解析文本,直到遇到無法解析的字符,返回的是到目前這個位置已經處理過的數字。所以“7-20”將翻譯成7年。這種差異很容易看出來,但如果使用
的是“yyyymmdd”,情況就更復雜了,輸出將是“Jan 7,
0016”。解析“16-0”,直到遇到第一個不可解析的字符,所以16會被當成年份。“-0”不會影響結果,它會被理解為0分鐘。之后“7-”就被映射
到天數了。
譯者注:文中關于自動裝箱的說明不夠準確,像“Integer wrapperA =
primitiveA;”這條語句,編譯器的處理策略是將其映射為“Integer wrapperA =
Integer.valueOf(primitive);”,Short的處理類似。有興趣的讀者可以自行測試。
另外,對Java謎題感興趣的讀者可以閱讀Joshua Bloch的《Java解惑》一書,其中列出了很多容易出錯的地方。
關于作者
Paulo Moreira是葡萄牙的一位自由軟件工程師,目前在盧森堡的財政部門工作。他畢業于米尼奧大學,獲得了計算機科學和系統工程的碩士學位。從2001年起,他一直使用Java,從事服務器端的開發工作,涉及電信、零售、軟件和金融市場等領域。
查看英文原文:Java Sleight of Hand
來自:http://www.infoq.com/cn/articles/Java-Sleight-of-Hand