讀古今文學網 > Java程序員修煉之道 > 1.3 Coin項目中的修改 >

1.3 Coin項目中的修改

Coin項目主要給Java 7引入了6個新特性,它們分別是switch語句中的String、數字常量的新形式、改進的異常處理、try-with-resources、鑽石語法,還有變參警告位置的修改。

我們會詳細講解Coin項目中的這些變化,討論這些新特性的語法和含義,並盡可能解釋推出這些特性背後的動機。當然,我們也不是要把提案全部照搬過來,coin-dev郵件列表的歸檔裡有完整的提案,如果你是一個好奇的語言設計師,可以去那裡看看,還可以和大家討論你的想法。

閒言少敘,開始介紹第一個Java 7新特性——switch語句中的String值。

1.3.1 switch語句中的String

switch語句是一種高效的多路語句,可以省掉很多繁雜的嵌套if判斷,比如像這樣:

public void printDay(int dayOfWeek) {
  switch (dayOfWeek) {
    case 0: System.out.println("Sunday"); break;
    case 1: System.out.println("Monday"); break;
    case 2: System.out.println("Tuesday"); break;
    case 3: System.out.println("Wednesday"); break;
    case 4: System.out.println("Thursday"); break;
    case 5: System.out.println("Friday"); break;
    case 6: System.out.println("Saturday"); break;
    default: System.err.println("Error!"); break;
  }
}
  

在Java 6及之前,case語句中的常量只能是bytecharshortint(也可以是對應的封裝類型 ByteCharacterShortInteger)或枚舉常量。Java 7規範中增加了String,畢竟它也是常量類型。

public void printDay(String dayOfWeek) {
  switch (dayOfWeek) {
    case "Sunday": System.out.println("Dimanche"); break;
    case "Monday": System.out.println("Lundi"); break;
    case "Tuesday": System.out.println("Mardi"); break;
    case "Wednesday": System.out.println("Mercredi"); break;
    case "Thursday": System.out.println("Jeudi"); break;
    case "Friday": System.out.println("Vendredi"); break;
    case "Saturday": System.out.println("Samedi"); break;
    default: System.out.println("Error: '"+ dayOfWeek +"' is not a day of the week"); break;
  }
}
  

除此之外,switch語句和以前完全一樣。像Coin項目中的許多新特性一樣,這不過是一個讓你更輕鬆的小小改進。

1.3.2 更強的數值文本表示法

當時有幾個與整型語法相關的提案,最終被選中的是下面這兩個:

  • 數字常量(如基本類型中的integer)可以用二進制文本表示;
  • 在整型常量中可以使用下劃線來提高可讀性。

這兩個改變乍看起來都不起眼,但它們確實解決了一直困擾著Java程序員的一些小麻煩。

這兩個新特性對系統底層程序員,就是那些整天處理原始網絡協議、加密或沉迷於擺弄比特的人們特別有用。先來看一下二進制文本。

1.二進制文本

在Java 7之前,如果要處理二進制值,就必須借助棘手(又容易出錯)的基礎轉換,或者調用parseX方法。比如說,如果想讓int x用位模式表示十進制值102,你可以這樣寫:

int x = Integer.parseInt("1100110", 2);  
  

為了確保x是正確的位模式,你需要敲許多代碼。這種方式儘管看起來還行,但實際上存在很多問題:

  • 十分繁瑣;
  • 方法調用對性能有影響;
  • 需要知道parseInt的雙參形式;
  • 需要記住雙參的parseInt的處理細節;
  • JIT編譯器更難實現;
  • 用運行時的表達式表示應該在編譯時確定的常量,導致x不能用在switch語句中;
  • 如果在位模式中有拼寫錯誤(能通過編譯),會在運行時拋出RuntimeException

現在好了,用Java 7可以寫成:

int x = 0b1100110;
  

我們沒說這種方法無所不能,但它確實解決了上面提到的那些問題。

你在跟二進制打交道時,這個小特性會是你的得力助手。比如在處理字節時,可以在switch語句中使用由位模式定義的二進制常量。

另外一個新特性雖然小,但卻很實用——可以在表示一組二進制位或其他長數值的數字中加入下劃線。

2.數字中的下劃線

眾所周知,人腦和電腦有很多不同的地方,對於數字的處理方式就是其中之一。通常人們都不太喜歡面對一大串數字。這也是我們發明十進制的原因之一——因為人腦更擅於處理信息量大的短字串,而不是每個字符信息量都不太多的長字串。

也就是說,我們覺得1c372ba3要比00011100001101110010101110100011更容易處理,但電腦只認第二種。人們在處理長串數字時會採用分隔法,比如用404-555-0122表示電話號碼。

注意 如果你跟作者(歐洲人)一樣,想知道為什麼美國電影或書裡的電話號碼總是以555開頭,我可以告訴你。555-01xx是保留號段,用於虛構的情境。這是為了避免現實生活中的人接到那些對好萊塢電影過分投入的人打來的電話。

其他帶有分隔符的一長串數字:

  • 100 000 000美元(一大筆錢);
  • 08-92-96(英國銀行的排序代碼)。

可在代碼中處理數字時不能用逗號(,)和連字符(-)作分隔符,因為它們可能會引發歧義。Coin項目中的提案借用了Ruby的創意,用下劃線(_)作分隔符。注意,這只是為了讓你閱讀數字時更容易理解而做的一個小修改,編譯器會在編譯時把這些下劃線去掉,只保留原始數字。

也就是說,為了不把100 000 000 和10 000 000搞混,你可以在代碼中將100 000 000寫成100_000_000,以便很容易區分它和10_000_000的差別。來看下面兩個例子,至少你應該對其中一個比較熟悉:

long anotherLong = 2_147_483_648L;  
int bitPattern = 0b0001_1100__0011_0111__0010_1011__1010_0011;
  

注意:賦給anotherLong的數值現在看起來清楚多了。

警告 在Java中可以用小寫字母l表示長整型數值,比如1010100l。但最好還是用大寫字母L,以免維護代碼的人把數字1和字母l搞混:1010100L看起來要清楚得多。

現在你應該清楚這些變化給整數處理帶來的好處了!讓我們繼續前進,去看看Java 7中的異常處理。

1.3.3 改善後的異常處理

異常處理有兩處改進——multicatch和final重拋。要知道它們對我們有什麼幫助,請先看一段Java 6代碼。下面這段代碼試圖查找、打開、分析配置文件並處理此過程中可能出現的各種異常:

代碼清單1-1 在Java 6中處理不同的異常

public Configuration getConfig(String fileName) {
  Configuration cfg = null;
  try {
    String fileText = getFile(fileName);
    cfg = verifyConfig(parseConfig(fileText));
  } catch (FileNotFoundException fnfx) {
    System.err.println("Config file '" + fileName + "' is missing");
  } catch (IOException e) {
    System.err.println("Error while processing file '" + fileName + "'");
  } catch (ConfigurationException e) {
    System.err.println("Config file '" + fileName + "' is not consistent");
  } catch (ParseException e) {
    System.err.println("Config file '" + fileName + "' is malformed");
  }
  return cfg;
}
  

這個方法會遇到的下面幾種異常:

  • 配置文件不存在;
  • 配置文件在正要讀取時消失了;
  • 配置文件中有語法錯誤;
  • 配置文件中可能包含無效信息。

這些異常可以分為兩大類。一類是文件以某種方式丟失或損壞,另一類是雖然文件理論上存在並且是正確的,卻無法正常讀取(可能是因為網絡或硬件故障)。

如果能把這些異常情況簡化為這兩類,並且把所有「文件以某種方式丟失或損壞」的異常放在一個catch語句中處理會更好。在Java 7中就可以做到:

代碼清單1-2 在Java 7中處理不同的異常

public Configuration getConfig(String fileName) {
  Configuration cfg = null;
  try {
    String fileText = getFile(fileName);
    cfg = verifyConfig(parseConfig(fileText));
  } catch (FileNotFoundException|ParseException|ConfigurationException e) {
    System.err.println("Config file '" + fileName +
                       "' is missing or malformed");
  } catch (IOException iox) {
    System.err.println("Error while processing file '" + fileName + "'");
  }
  return cfg;
}
  

異常e的確切類型在編譯時還無法得知。這意味著在catch塊中只能把它當做可能異常的共同父類(在實際編碼時經常用ExceptionThrowable)來處理。

另外一個新語法可以為重新拋出異常提供幫助。開發人員經常要在重新拋出異常之前對它進行處理。在前幾個版本的Java中,經常可以看到下面這種代碼:

try {
  doSomethingWhichMightThrowIOException;
  doSomethingElseWhichMightThrowSQLException;
} catch (Exception e) {
  ...
  throw e;
}
  

這會強迫你把新拋出的異常聲明為Exception類型——異常的真實類型卻被覆蓋了。

不管怎樣,很容易看出來異常只能是IOExceptionSQLException。既然你能看出來,編譯器當然也能。下面的代碼中用了Java 7的語法,只改了一個單詞:

try {
  doSomethingWhichMightThrowIOException;
  doSomethingElseWhichMightThrowSQLException;
} catch (final Exception e) {
  ...
  throw e;
}
  

關鍵字final表明實際拋出的異常就是運行時遇到的異常——在上面的代碼中就是IOExceptionSQLException。這被稱為final重拋,這樣就不會拋出籠統的異常類型,從而避免在上層只能用籠統的catch捕獲。

上例中的關鍵字final不是必需的,但實際上,在向catch和重拋語義調整的過渡階段,留著它可以給你提個醒。

Java 7對異常處理的改進不僅限於這些通用問題,對於特定的資源管理也有所提升,我們馬上就會講到。

1.3.4 Try-with-resources(TWR)

這個修改說起來容易,但其實暗藏玄機,最終證明做起來比最初預想的要難。其基本設想是把資源(比如文件或類似的東西)的作用域限定在代碼塊內,當程序離開這個代碼塊時,資源會被自動關閉。

這是一項非常重要的改進,因為沒人能在手動關閉資源時做到100%正確,甚至不久前Sun提供的操作指南都是錯的。在向Coin項目提交這一提案時,提交者宣稱JDK中有三分之二的close用法都有bug,真是不可思議!

好在編譯器可以生成這種學究化、公式化且手工編寫易犯錯的代碼,所以Java 7借助了編譯器來實現這項改進。

這可以減少我們編寫錯誤代碼的幾率。相比之下,想想你用Java 6寫段代碼,要從一個URL(url)中讀取字節流,並把讀取到的內容寫入到文件(out)中,這麼做很容易產生錯誤。代碼清單1-3是可行方案之一。

代碼清單1-3 Java 6中的資源管理語法

InputStream is = null;
try {
  is = url.openStream;
  OutputStream out = new FileOutputStream(file);
  try {
    byte buf = new byte[4096];
    int len;
    while ((len = is.read(buf)) >= 0)
      out.write(buf, 0, len);
   } catch (IOException iox) {               // 處理異常(能讀或寫)
   } finally {
     try {
       out.close;
      } catch (IOException closeOutx) {      // 遇到異常也做不了什麼
      }
   }
 } catch (FileNotFoundException fnfx) {      // 處理異常
 } catch (IOException openx) {               // 處理異常
 } finally {
    try {
      if (is != null) is.close;
    } catch (IOException closeInx) {         // 遇到異常也做不了什麼
    }
 }
  

看明白了嗎?重點是在處理外部資源時,墨菲定律(任何事都可能出錯)一定會生效,比如:

  • URL中的InputStream無法打開,不能讀取或無法正常關閉;
  • OutputStream對應的File無法打開,無法寫入或不能正常關閉;
  • 上面的問題同時出現。

最後一種情況是最讓人頭疼的——異常的各種組合拳打出來令人難以招架。

新語法能大大減少錯誤發生的可能性,這正是它受歡迎的主要原因。編譯器不會犯開發人員編寫代碼時易犯的錯誤。

讓我們看看代碼清單1-3中的代碼用Java 7寫出來什麼樣。和前面一樣,url是一個指向下載目標文件的URL對象,file是一個保存下載數據的File對象。

代碼清單1-4 Java 7中的資源管理語法

try (OutputStream out = new FileOutputStream(file);
     InputStream is = url.openStream ) {
  byte buf = new byte[4096];
  int  len;
  while ((len = is.read(buf)) > 0) {
    out.write(buf, 0, len);
  }
}
  

這是資源自動化管理代碼塊的基本形式——把資源放在try的圓括號內。C#程序員看到這個也許會覺得有點眼熟,是的,它的確很像C#中的從句,帶著這種理解使用這個新特性是個不錯的起點。在這段代碼塊中使用的資源在處理完成後會自動關閉。

但使用try-with-resources特性時還是要小心,因為在某些情況下資源可能無法關閉。比如在下面的代碼中,如果從文件(someFile.bin)創建ObjectInputStream時出錯,FileInputStream可能就無法正確關閉。

try ( ObjectInputStream in = new ObjectInputStream(new
      FileInputStream("someFile.bin")) ) {
  ...
}
  

假定文件(someFile.bin)存在,但可能不是ObjectInput類型的文件,所以文件無法正確打開。因此不能構建ObjectInputStream,所以FileInputStream也沒辦法關閉。

要確保try-with-resources生效,正確的用法是為各個資源聲明獨立變量。

try ( FileInputStream fin = new FileInputStream("someFile.bin");
          ObjectInputStream in = new ObjectInputStream(fin) ) {
    ...
}
  

TWR的另一個好處是改善了錯誤跟蹤的能力,能夠更準確地跟蹤堆棧中的異常。在Java 7之前,處理資源時拋出的異常信息經常會被覆蓋。TWR中可能也會出現這種情況,因此Java 7對跟蹤堆棧進行了改進,現在開發人員能看到可能會丟失的異常類型信息。

比如在下面這段代碼中,有一個返回InputStream的值為null的方法:

 try(InputStream i = getNullStream) {
   i.available;
}
  

在改進後的跟蹤堆棧中能看到提示,注意其中被抑制的NullPointerException(簡稱NPE):

 Exception in thread "main" java.lang.NullPointerException 
  at wgjd.ch01.ScratchSuprExcep.run(ScratchSuprExcep.java:23)
  at wgjd.ch01.ScratchSuprExcep.main(ScratchSuprExcep.java:39)
  Suppressed:java.lang.NullPointerException 
  at wgjd.ch01.ScratchSuprExcep.run(ScratchSuprExcep.java:24)   
    1 more
  

TWR與AutoCloseable

目前TWR特性依靠一個新定義的接口實現AutoCloseable。TWR的try從句中出現的資源類都必須實現這個接口。Java 7平台中的大多數資源類都被修改過,已經實現了AutoCloseable(Java 7中還定義了其父接口Closeable),但並不是全部資源相關的類都採用了這項新技術。不過,JDBC 4.1已經具備了這個特性。

然而在你自己的代碼裡,在需要處理資源時一定要用TWR,從而避免在異常處理時出現bug。

希望你能盡快使用try-with-resources,把那些多餘的bug從代碼庫中趕走。

1.3.5 鑽石語法

針對創建泛型定義和實例太過繁瑣的問題,Java 7做了一項改進,以減少處理泛型時敲鍵盤的次數。比如你用userid(整型值)標識一些user對象,每個user都對應一個或多個查找表1。這用代碼應該如何表示呢?

1 一種為提高處理速度而用查詢取代計算的處理機制。一般是將事先計算好的結果存在數組或映射中,然後在需要該結果時直接讀取,比如用三角表查某一角度的正弦值。——譯者注

Map<Integer, Map<String, String>> usersLists = 
       new HashMap<Integer, Map<String, String>>;  
  

這簡直太長了,並且幾乎一半字符都是重複的。如果能寫成

Map<Integer, Map<String, String>> usersLists = new HashMap<>;
  

讓編譯器推斷出右側的類型信息是不是更好?神奇的Coin項目滿足了你這個心願。在Java 7中,像這樣的聲明縮寫完全合法,還可以向後兼容,所以當你需要處理以前的代碼時,可以把過去比較繁瑣的聲明去掉,使用新的類型推斷語法,這樣可以省出點兒空間來。

編譯器為這個特性採用了新的類型推斷形式。它能推斷出表達式右側的正確類型,而不是僅僅替換成定義完整類型的文本。

為什麼叫「鑽石語法」

把它稱為」鑽石語法」是因為這種類型信息看起來像鑽石。原來提案中的名字是「為泛型實例創建而做的類型推斷改進」(Improved Type Inference for Generic Instance Creation)。這個名字太長,可縮寫ITIGIC聽上去又很傻,所以乾脆就叫鑽石語法了。

新的鑽石語法肯定會讓你少寫些代碼。我們最後還要探討Coin項目中的一個特性——使用變參時的警告信息。

1.3.6 簡化變參方法調用

這是所有修改裡最簡單的一個,只是去掉了方法簽名中同時出現變參和泛型時才會出現的類型警告信息。

換句話說,除非你寫代碼時習慣使用類型為T的不定數量參數,並且要用它們創建集合,否則你就可以進入下一節了。如果你想要寫下面這種代碼,那就繼續閱讀本節:

public static <T> Collection<T> doSomething(T... entries) {
  ...
}
  

還在?很好。這到底是怎麼回事?

變參方法是指參數列表末尾是數量不定但類型相同的參數方法。但你可能還不知道變參方法是如何實現的。基本上,所有出現在末尾的變參都會被放到一個數組中(由編譯器自動創建),並作為一個參數傳入。

這是個好主意,但是存在一個公認的Java泛型缺陷——不允許創建已知類型的泛型數組。比如下面這段代碼,編譯就無法通過:

HashMap<String, String> arrayHm = new HashMap<>[2];  
  

不可以創建特定泛型的數組,只能這樣寫:

HashMap<String, String> warnHm = new HashMap[2];
  

可這樣編譯器會給出一個只能忽略的警告。你可以將warnHm的類型定義為HashMap<String,String>數組,但不能創建這個類型的實例,所以你不得不硬著頭皮(或至少忘掉警告)硬生生地把原始類型(HashMap數組)的實例塞給warnHm

這兩個特性(編譯時生成數組的變參方法和已知泛型數組不能是可實例化類型)碰到一起時,會令人有點頭疼。看看下面這段代碼:

HashMap<String, String> hm1 = new HashMap<>;
HashMap<String, String> hm2 = new HashMap
Collection<HashMap<String, String>> coll = doSomething(hm1,hm2);
  

編譯器會嘗試創建一個包含hm1hm2的數組,但這種類型的數組應該是被嚴格禁止使用的。面對這種進退兩難的局面,編譯器只好違心地創建一個本來不應出現的泛型數組實例,但它又覺得自己不能保持沉默,所以還得嘟囔著警告你這是「未經檢查或不安全的操作」。

從類型系統的角度看,這非常合理。但可憐的開發人員本想使用一個十分靠譜的API,一看到這些嚇人的警告,卻得不到任何解釋,不免會內心忐忑。

1.Java 7中的警告去了哪裡

Java 7的這個新特性改變了警告的對象。構建這些類型畢竟有破壞類型安全的風險,這總得有人知道。但 API 的用戶對此是無能為力的,不管doSomething是不是干了壞事,破壞了類型安全,都不在API用戶的控制範圍之內。

真正需要看到這個警告信息的是寫doSomething的人,即API的創建者,而不是使用者。所以Java 7把警告信息從使用API的地方挪到了定義API的地方。

過去是在編譯使用API的代碼時觸發警告,而現在是在編譯這種可能會破壞類型安全的API時觸發。編譯器會警告創建這種API的程序員,讓他注意類型系統的安全。

為了減輕API開發人員的負擔,Java 7還提供了一個新註解java.lang.SafeVarargs。把這個註解應用到API方法或構造方法之中,則會產生類型警告。通過用@SafeVarargs對這種方法進行註解,開發人員就不會在裡面進行任何危險的操作,在這種情況下,編譯器就不會再發出警告了。

2.類型系統的修改

雖然把警告信息從一個地方挪到另一個地方不是改變遊戲規則的語言特性,但也證明了我們之前提到的觀點——Coin項目曾奉勸諸位貢獻者遠離類型系統,因為把這麼一個小變化講清楚要大費周章。這個例子表明搞清楚類型系統不同特性之間如何交互是多麼費心費力,而且對語言的修改被實現後又會怎麼影響這種交互。這還不是特別複雜的修改,更大的變動所涉及的內容還會更多,其中還包括大量微妙的分支。

最後這個例子闡明了由小變化引發的錯綜複雜的影響。我們對Coin項目中改進的討論也結束了。儘管它們幾乎全都是語法上的小變化,但跟實現它們的代碼量相比,它們所帶來的正面影響還是很可觀的。一旦開始使用,你就會發現這些特性對程序真的很有幫助!