細數Java的語法糖(一): 用於字串拼接的 “+” 運算子

JavaDog發表於2019-03-04

前言

語法糖(Syntactic Sugar),又稱糖衣語法,是由英國計算機學家 Peter.J.Landin 發明的一個術語,指在計算機語言中新增的某種語法。這種語法對語言的功能並沒有影響,但往往能讓程式更加簡潔,並有更高的可讀性,從而方便程式設計師使用,減少程式碼出錯的機會,並提升開發效率。簡單來說,語法糖就是對現有語法的一種包裝。

很多程式語言中都有語法糖,Java 也是如此。要明白,語法糖僅存在於編譯時,JVM 並不認識這些語法糖。因此,在編譯階段,Java 編譯器會將語法糖還原為基礎的語法結構,有些文章將這個過程稱為 “脫糖”,也有稱 “解語法糖”(Reference 2)。在 com.sun.tools.javac.main.JavaCompiler 的 compile 方法中,有一個步驟(compile2)會呼叫它的 desugar 方法,這個方法就是用來實現脫糖處理的。

順便提下,從 Java 6 開始,Java 提供了編譯器API,使得開發者們可以更靈活地使用編譯。如果你有興趣,它的入口在 javax.tools.JavaCompiler 介面(從父介面繼承來)的 run 方法。com.sun.tools.javac.api.JavacTool 是它的一個實現,它最終會將編譯委託給 com.sun.tools.javac.main.JavaCompiler 的 compile 方法。

從本文開始,筆者將試圖總結和介紹 Java 語言中的常見語法糖,並將儘量按照由簡到繁、並將相似或相關內容放在一起的順序來組織。對於涉及到的關聯知識,也會稍做介紹,但可能不會深究太多細節。歡迎有興趣的同學一起探討,如有不足,還請指正。
這個系列的目錄如下(為了不劇透,先只列出部分):

注: 如果沒有特別指明,所有的介紹及示例均基於 Java 8。

用於字串拼接的 “+” 運算子

在Java中,字串是最常使用的型別,字串的拼接又是字串最常用的操作。有些語言(如: C++) 允許程式設計師對運算子做過載,從而實現定製化的操作。Java 不支援運算子過載,但它為程式設計師簡化了字串拼接操作,允許通過二元操作符 “+” 完成字串拼接。

更進一步地,根據 Java 語言規範,”+” 操作符的運算規則如下:

  1. 如果有一個運算元是 String,則將另一個運算元也轉為 String。
  2. 否則,(如有必要,插入拆箱轉換),如果任意一個運算元是 double,則將另一個轉為 double。
  3. 否則,如果任意一個運算元是 float,則將另一個轉為 float。
  4. 否則,如果任意一個運算元是 long,則將另一個轉為 long。
  5. 否則,將兩個運算元都轉為 int。

這條規則的最後一句也就是兩個 byte 相加,結果是 int 的原因。

回到正題,先看一個字串拼接的例子:

       String t = "b";
       String s = "a" + t + "c";
複製程式碼

反編譯這段程式碼,得到:

       0: ldc           #2                  // String b
       2: astore_1
       3: new           #3                  // class java/lang/StringBuilder
       6: dup
       7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      10: ldc           #5                  // String a
      12: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      15: aload_1
      16: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: ldc           #7                  // String c
      21: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      24: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      27: astore_2
複製程式碼

簡單解釋,首先將字串 “b” 存入第一個變數(即: t) 中。接著,構造一個 StringBuilder,並通過其 append 方法存入字串 “a”。然後,將第一個變數(t)中的字串(“b”)取出,並再次通過在剛才的 StringBuilder 物件上呼叫 append 方法存入。接著,以類似的方法再存入字串 “c”。最後,通過在 StringBuilder 物件上呼叫 toString 方法得到最後的結果,存入第二個變數(即: s)中。

可見,在編譯過後,Java 的字串拼接其實是通過構造 StringBuilder 物件,並不斷呼叫其 append 方法將字串放入,再通過 toString 得到最終結果。這樣的做的好處在於可以避免產生很多無用的中間字串物件。

事實上,Java 編譯器做的還不止於此,如果字串是常量值,那麼在編譯期,Java 會直接將常量字串替換為其字面值。考慮如下程式碼:

       final String t = "b";
       String s = "a" + t + "c";
複製程式碼

與前述例子相比,這裡僅僅是將 t 宣告為了 final。反編譯後的程式碼如下:

       0: ldc           #2                  // String b
       2: astore_1
       3: ldc           #3                  // String abc
       5: astore_2
複製程式碼

看,由於所有的字串都是常量值,Java 可以在編譯階段就直接計算出結果。

不過,需要指出,Java 編譯器也聰明得有限。考慮下面字串連續拼接的場景:

        String s = "a";
        s += "b";
        s += "c";
複製程式碼

反編譯後得到如下程式碼:

       0: ldc           #2                  // String a
       2: astore_1
       3: new           #3                  // class java/lang/StringBuilder
       6: dup
       7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      10: aload_1
      11: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      14: ldc           #6                  // String b
      16: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      22: astore_1
      23: new           #3                  // class java/lang/StringBuilder
      26: dup
      27: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      30: aload_1
      31: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      34: ldc           #8                  // String c
      36: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      39: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      42: astore_1
複製程式碼

可以看出,相比之前的例子,這個過程多了很多中間計算。首先將字串 “a” 存入變數1(即: s)中。接著,在拼接字串 “b” 時,構造了一個 StringBuilder,依次放入變數1中的值(“a”)、字串 “b”。然後通過 StringBuilder#toString 得到結果並再次存入變數1中。此時變數1中存的值是 “ab”。接著,再次構造一個 StringBuilder,依次放入變數1的值(“ab”)、字串 “c”。然後又一次呼叫 StringBuilder#toString,得到最終結果 “abc”。

也就是說,每執行一條拼接語句,就會構造一個 StringBuilder,並對之前的結果和當前要拼入的後續字串依次呼叫 append 放入,然後再用 toString 得到該語句的結果。如果你有類似於下面這樣的拼接,那麼可能會產生極大的浪費:

        String result = ...;
        List<String> strsToAppend = ...;
        for (String s : strsToAppend) {
            result += s;
        }
複製程式碼

總結一下,如果我們要在一條語句中進行字串拼接,那麼可以直接使用 “+” 運算子,這樣做的程式碼十分簡潔,同時也具有很高的可讀性。但如果拼接涉及多條語句,那麼就需要考慮使用類似於 StringBuilder 的技術來避免或減少先建立然後又丟棄中間物件的事情發生,以提高拼接的效能。

References

  1. baike.baidu.com/item/%E8%AF…
  2. blog.csdn.net/GitChat/art…
  3. Java語言規範(Java SE 8)

細數Java的語法糖(一): 用於字串拼接的 “+” 運算子

相關文章