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); //執行加法,打印3System.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