Java戲法

jopen 10年前發布 | 18K 次閱讀 Java 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的實現有關,其使用方式有一定的誤導性,而且有的時候,代碼到了產品中,問題才會暴露出來。

DateFormatparse方法會解析一個字符串,并生成一個日期。解析過程是根據定義的日期格式掩碼來工作的。根據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

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!