Java特性專題報道:文本塊

本文要點

  • 作爲一項預覽特性,Java SE 13(2019年9月)引入了文本塊,旨在減輕在Java中聲明和使用多行字符串字面量的痛苦。隨後,第二個預覽版本對它做了一些細微的改進,並計劃在Java SE 15(2020年9月)中成爲Java語言的一個永久特性。
  • Java程序中的字符串字面量不限於“yes”和“no”這樣的短字符串;它們經常對應於結構化語言(如HTML、SQL、XML、JSON,甚至Java)中的整個“程序”。
  • 文本塊是可以包含多行文本的字符串字面量,使用三重引號(""")作爲開始和結束分隔符。
  • 文本塊可以看作是嵌入在Java程序中的二維文本塊。
  • 如果能夠保留嵌入的那個程序的二維結構,又不必使用轉義字符和其他會造成干擾的語法,那就可以降低出錯的可能,開發出可讀性更好的程序。

在QCon紐約大會的演講“Java的未來”中,Java語言架構師Brian Goetz帶我們快速瀏覽了Java語言近期和未來的一些特性。在本文中,他深入探討了文本塊。

作爲一項預覽特性,Java SE 13(2019年9月)引入了文本塊,旨在減輕在Java中聲明和使用多行字符串字面量的痛苦。

隨後,第二個預覽版本對它做了一些細微的改進,並計劃在Java SE 15(2020年9月)中成爲Java語言的一個永久特性

文本塊是可以包含多行文本的字符串字面量,如下所示:

String address = """
                 25 Main Street
                 Anytown, USA, 12345
                 """;

在下面這個簡單的示例中,變量address是一個兩行的字符串,每行後面都有行終止符。如果沒有文本塊,我們必須這樣寫:

String address = "25 Main Street\n" +
                 "Anytown, USA, 12345\n";                 

String address = "25 Main Street\nAnytown, USA, 12345\n";

每個Java開發人員都知道,這些寫法都非常麻煩。但是,更重要的是,它們更容易出錯(很容易忘記\n並且發現不了),也更難閱讀(因爲語言語法與字符串的內容混在一起)。由於文本塊通常沒有轉義字符和其他語法干擾,讀者更容易看明白字符串的內容。

字符串字面量中最常見的轉義字符是換行符(\n),文本塊支持直接表示多行字符串而不需要換行符。除了換行符之外,另一個最常用的轉義字符是雙引號("),這個必須轉義,因爲它與字符串字面量分隔符衝突。文本塊不需要這樣,因爲單引號與三引號文本塊分隔符並不衝突。

爲什麼起了這樣一個名字?

有人可能會說,這個特性應該叫“多行字符串字面量”(很多人可能會這樣稱呼它)。但是,我們選擇了一個不同的名稱文本塊,爲的是強調:文本塊不是不相關行的集合,而是嵌入到Java程序中的二維文本塊。爲了說明這裏所說的“二維”是什麼意思,我們舉一個稍微結構化一些的例子,在這個例子中,文本塊是一個XML片段。(同樣的考量也適用於其他“語言”的“程序”片段,如SQL、HTML、JSON,甚至Java,作爲字面量嵌入到Java程序中。)

void m() {
    System.out.println("""
                       <person>
                           <firstName>Bob</firstName>
                           <lastName>Jones</lastName>
                       </person>
                       """);
}

作者希望這段代碼打印什麼?雖然我們無法讀取他們的想法,但似乎不太可能是想讓XML塊縮進21個空格;更可能是,這21個空格只是用來將文本塊與周圍的代碼對齊。另一方面,幾乎可以肯定,作者的意圖是輸出的第二行應該比第一行多縮進四個空格。此外,即使作者確實需要縮進21個空格,當程序修改、周圍代碼的縮進發生變化時,又會發生什麼呢?我們不希望輸出的縮進僅僅因爲源代碼被重新格式化而改變,也不希望文本塊因爲沒有合理對齊而看起來“格格不入”。

從這個例子中,我們可以看到,嵌入在程序源代碼中的多行文本塊的自然縮進來自於文本塊各行行之間預期的相對縮進,以及文本塊與周圍代碼之間的相對縮進。我們希望字符串字面量與代碼對齊(因爲如果不對齊就會顯得格格不入),我們希望字符串的行可以反映出行之間的相對縮進,但是這兩個縮進來源(我們可以稱之爲附帶的和必需的)在源程序的表示中必然混雜在一起。(傳統的字符串字面量沒有這個問題,因爲它們不能跨行,所以不用爲了使內容對齊而在文本中添加額外的前導空格。)

解決這個問題的一種方法是使用庫方法,我們可以將它應用於多行字符串字面量,比如Kotlin的trimIndent方法,而Java確實也提供了這樣一個方法:String::stripIndent。但是,因爲這是很常見的問題,所以Java更進一步,會在編譯時自動去除附帶的縮進。

爲了理順附帶的和必需的縮進,我們可以想象下,在包含整個代碼段的XML代碼段周圍繪製一個最小的矩形,並將這個矩形裏的內容視爲一個二維文本塊。這個“魔法矩形”是文本塊的內容,它反映了文本塊行之間的相對縮進,但是忽略了它所在的程序是如何縮進的。

這個“魔法矩形”的類比或許可以幫助我們理解文本塊的工作方式,但是細節要更微妙一些,因爲我們可能會希望更好地控制哪些縮進是附帶的,哪些是必需的。可以使用結尾分隔符相對於內容的位置來平衡附帶縮進和必需縮進。

詳情

文本塊使用三引號(""")作爲其開始和結束分隔符,開始分隔符所在行的其餘部分必須爲空白。文本塊的內容從下一行開始,一直延續到結束分隔符。塊內容的編譯時處理分爲三個階段:

  • 標準化行終止符。用LF(\u000A)字符替換所有行終止符。這可以防止文本塊的值受最後編輯它的平臺的換行約定所影響。(Windows使用CR + LF作爲結束行;Unix系統只使用LF,甚至還有其他使用中的方案。)
  • 從每行中刪除附帶的前導空格和所有的尾隨空格。附帶空格通過以下方式確定:
    • 計算出一組確定行,這些行是上個步驟得出的所有非空行,以及最後一行(包含結束分隔符的行),即使它是空的;
    • 計算出所有確定行的公共空格前綴
    • 從每個確定行中刪除公共空格前綴。
  • 解釋內容中的轉義序列。文本塊使用與字符串和字符字面量相同的轉義序列集。執行上面這些操作意味着像\n、\t、\s和<eol>這樣的轉義字符不會影響空格處理。(JEP 368中新增了兩個轉義序列:顯式空格\s,續行標記<eol>。)

在我們的XML示例中,第一行和最後一行中的所有空格都將被刪除,而中間兩行會縮進四個空格,因爲在這個例子中有五個確定行(包括四行XML代碼和結束分隔符所在的行),行的縮進都至少和第一行內容的縮進相同。通常情況下,這種縮進就是我們期望的,但有時我們可能不希望去掉所有的前導縮進。例如,如果我們想讓整個塊縮進4個空格,那麼我們可以通過將結束分隔符向左移動4個空格來實現:

void m() {
    System.out.println("""
                       <person>
                           <firstName>Bob</firstName>
                           <lastName>Jones</lastName>
                       </person>
                   """);
}

因爲最後一行也是一個確定行,所以公共空格前綴現在是塊的最後一行中結束分隔符之前的空格數量,每一行都按這個數量刪除空格,整個塊縮進4個空格。我們也可以通過編程方式管理縮進,通過實例方法String::indent,它接受一個多行字符串(不管它是否來自文本塊),並按固定數量的空格縮進每一行:

void m() {
    System.out.println("""
                       <person>
                           <firstName>Bob</firstName>
                           <lastName>Jones</lastName>
                       </person>
                       """.indent(4));
}

在極端情況下,如果不需要刪除空格,則可以將結束分隔符一直移動到左邊界:

void m() {
    System.out.println("""
                       <person>
                           <firstName>Bob</firstName>
                           <lastName>Jones</lastName>
                       </person>
""");
}

或者,我們可以通過將整個文本塊移到左邊界來達到同樣的效果:

void m() {
    System.out.println("""
<person>
    <firstName>Bob</firstName>
    <lastName>Jones</lastName>
</person>
""");
}

乍聽起來,這些規則可能有些複雜,但選用這種規則是爲了平衡各種競爭性問題(既希望能夠相對於周圍的程序縮進文本塊,又不會產生可變數量的附帶前導空格),同時,如果默認的算法不是想要的結果,還可以提供一個簡單的方法來調整或選擇不刪除空格。

嵌入式表達式

Java的字符串不支持表達式插值,而其他一些語言則支持;文本塊也不支持。(在某種程度上,我們將來可能會考慮這個特性,不限於文本塊,而是同樣適用於字符串。)過去,參數化的字符串表達式是通過普通的字符串連接(+)構建的;Java 5添加了String::format以支持“printf”樣式的字符串格式。

由於需要對周圍的空格進行全面分析,當通過字符串連接組合成文本塊時,要想正確地縮進可不是一件簡單的事。但是,文本塊的處理結果是一個普通的字符串,所以我們仍然可以使用String::format來參數化字符串表達式。此外,我們可以使用新的String::formatted方法,它是String::format的實例版本。

String person = """
                <person>
                    <firstName>%s</firstName>
                    <lastName>%s</lastName>
                </person>
                """.formatted(first, last));

遺憾的是,這個方法也不能命名爲format,因爲我們不能重載具有相同名稱和參數列表的靜態實例方法。

先例與歷史

雖然從某種意義上說,字符串字面量是一個“微不足道”的特性,但它們的使用頻率如此之高,足以讓小煩惱累積成大煩惱。因此也就不奇怪,爲什麼缺乏多行字符串是近年來對Java最常見的抱怨之一,而許多其他語言都有多種形式的字符串字面量來支持不同的用例。

這或許令人驚訝,在各種流行的語言中,這一特性有許多不同的表示方式。說“想要多行字符串”很容易,但是在研究其他語言時,我們發現,它們的語法和目標各不相同。(當然,對於哪一種纔是“正確”的方法,開發人員的觀點也很不一樣。)雖然沒有任何兩種語言是相同的,但是對於大多數許多語言都具有的特性(例如for循環),通常有一些通用的方法可供選擇;在15種語言中找到同一特性的15種不同解釋很罕見,但是,我們發現,多行原始字符串字面量就是這樣。

下表顯示了各種語言中字符串字面量的部分選項。其中,…是字符串字面量的內容,對於轉義序列和嵌入式插值,可能會處理,也可能不會處理,xxx表示一個分隔符,用戶可以任意定義,但不能與字符串的內容衝突,而##表示數量可變的#號(可能是零個)。

語言 語法 說明
Bash ‘…’ [span]
Bash $’…’ [esc] [span]
Bash “…” [esc] [interp] [span]
C “…” [esc]
C++ “…” [esc]
C++ R"xxx(…)xxx" [span] [delim]
C# “…” [esc]
C# $"…" [esc] [interp]
C# @"…"
Dart ‘…’ [esc] [interp]
Dart “…” [esc] [interp]
Dart ‘’’…’’’ [esc] [interp] [span]
Dart “”"…""" [esc] [interp] [span]
Dart r’…’ [prefix]
Go “…” [esc]
Go [span]
Groovy ‘…’ [esc]
Groovy “…” [esc] [interp]
Groovy ‘’’…’’’ [esc] [span]
Groovy “”"…""" [esc] [interp] [span]
Haskell “…” [esc]
Java “…” [esc]
Javascript ‘…’ [esc] [span]
Javascript “…” [esc] [span]
Javascript [esc] [interp] [span]
Kotlin “…” [esc] [interp]
Kotlin “”"…""" [interp] [span]
Perl ‘…’
Perl “…” [esc] [interp]
Perl <<‘xxx’ [here]
Perl <<“xxx” [esc] [interp] [here]
Perl q{…} [span]
Perl qq{…} [esc] [interp] [span]
Python ‘…’ [esc]
Python “…” [esc]
Python ‘’’…’’’ [esc] [span]
Python “”"…""" [esc] [span]
Python r’…’ [esc] [prefix]
Python f’…’ [esc] [interp] [prefix]
Ruby ‘…’ [span]
Ruby “…” [esc] [interp] [span]
Ruby %q{…} [span] [delim]
Ruby %Q{…} [esc] [interp] [span] [delim]
Ruby <<-xxx [here] [interp]
Ruby <<~xxx [here] [interp] [strip]
Rust “…” [esc] [span]
Rust r##"…"## [span] [delim]
Scala “…” [esc]
Scala “”"…""" [span]
Scala s"…" [esc] [interp]
Scala f"…" [esc] [interp]
Scala raw"…" [interp]
Swift ##"…"## [esc] [interp] [delim]
Swift ##"""…"""## [esc] [interp] [delim] [span]

說明:

  • esc:某種程度的轉義序列處理,轉義符通常由C語言樣式衍生而來(例如,\n);
  • interp:變量或任意表達式插值的部分支持;
  • span:多行字符串可以通過簡單地跨越多個源代碼行來表示;
  • here:在"here-doc"樣式中,後面的行,一直到用戶定義的分隔符所在的行,都被視爲字符串字面量;
  • prefix:前綴形式對所有其他形式的字符串字面量也都是有效的,只是爲了簡潔起見而省略了;
  • delim:在某種程度上可定製分隔符,比如通過包含nonce(C++)、不同數量的#字符(Rust、Swift),或者將花括號替換爲其他匹配的括號(Ruby)。
  • strip:在某種程度上支持刪除附帶縮進。

雖然這個表說明了字符串字面量方法的多樣性,但它實際上只觸及了表面,因爲語言解釋字符串字面量的方法有各種細節的差異,不可能通過這樣一個簡單的表格完全說明。雖然大多數語言受C語言啓發,使用一個轉義字符,但它們支持的轉義字符不同,是否支持以及如何支持Unicode轉義(例如,\unnnn),不完全支持轉義語言的形式是否仍然支持一些有限形式的分隔符字符轉義(比如,對於嵌入式引用,使用兩個引號而不是結束字符串。)簡單起見,該表還省略了一些其他形式(如C++中用於控制字符編碼的各種前綴)。

語言之間最明顯的差別是分隔符的選擇,而不同的分隔符意味着不同形式的字符串字面量(有或沒有轉義字符,單行或多行,有或沒有插值,字符編碼選擇,等等),但從中我們可以看到,這些語法的選擇常常反映了該語言在設計哲學上的差異——如何平衡等各種目標,如簡潔性、表現力和用戶便利性。

毫不奇怪,腳本語言(bash、Perl、Ruby、Python)已經將“用戶選擇權”放在最高優先級,它們提供多種形式的字面量,可以用多種方式表示相同的東西。但是,一般來說,在鼓勵用戶如何看待字符串字面量、提供多少種形式以及這些形式的正交性方面,各種語言都不相同。我們還看到了一些關於多行字符串的理論。有一些(比如JavaScript和Go)只是將行結束符看作另一個字符,允許所有形式的字符串字面量跨越多個行,有一些(比如C++)把它們作爲一種特殊的“原始”字符串,其他語言(比如Kotlin)則將字符串分成“簡單”和“複雜”兩類,並將多行字符串歸爲“複雜”這一類,還有一些提供的選項太多,甚至都無法這樣簡單的分類。同樣,它們對“原始字符串”的解釋也各不相同。真正的原始需要某種形式的用戶可控制的分隔符(如C++、Swift和Rust所具有的形式),儘管其他語言也號稱其字符串是“原始的”,但它們仍然爲其結束(固定)分隔符保留着某種形式的轉義。

儘管有各種各樣的方法和觀點,但是從平衡原則性設計和表現力的角度來看,這項調查中有一個明顯的“贏家”:Swift。它通過一種靈活的機制(單行變體和多行變體)提供轉義、插值和真正的原始字符串。在這些語言中,最新的語言擁有最動聽的故事,這並不奇怪,因爲它有後見之明,可以從其他人的成功和錯誤中汲取經驗教訓。(這裏,關鍵的創新是,雖然轉義分隔符各不相同,但都與字符串分隔符保持一致,可以避免在“熟(cooked)”和“生(raw)”模式之間進行選擇,同時仍然跨所有形式的字符串字面量共享轉義語言——這種方法“事後看來”是很值得讚揚的。)由於現有的語言限制,Java無法大規模地採用Swift的方法,但是,Java已儘可能地從Swift社區所做的優秀工作中汲取靈感——併爲將來開展進一步的工作預留了空間。

差點採用的做法

文本塊並不是該特性的第一次迭代;第一次迭代是原始字符串字面量。與Rust的原始字符串一樣,它使用了一個大小可變的分隔符(任意數量的反單引號字符),並且完全不解釋其內容。在完成了全部設計和原型之後,這個提議被撤回了。當時的判斷是,這種做法雖然足夠合理,但總讓人覺得太過於死板——與傳統字符串字面量沒什麼共同之處,因此,如果我們將來想擴展這個特性,就沒法把它們一起擴展。(由於現在的發佈節奏很快,這只是讓該特性推遲了6個月,但我們會得到一個更好的特性。)

JEP 326方法的一個主要缺點是,原始字符串的工作方式與傳統的字符串字面量完全不同:不同的分隔符字符、變長分隔符 vs 固定分隔符、單行 vs 多行、轉義 vs 非轉義。總會有人想要一些不同選項的組合,想要更多不同的形式,因此,我們走了Bash所走的路。最重要的是,它沒有解決“附加縮進”的問題,這顯然會成爲Java程序脆弱性的根源。基於此,文本塊與傳統的字符串字面量(分隔符語法、轉義語言)有非常多的相似之處,只在一個關鍵方面有所不同——字符串是一維字符序列還是二維文本塊。

風格指南

Oracle Java團隊的Jim Laskey和Stuart Marks發佈了一份程序員指南,詳細介紹了文本塊,並提供了樣式建議。

在可以提高代碼清晰度時使用文本塊。連接、轉義換行和轉義引號分隔符使字符串字面量的內容變得混亂;文本塊解決了這個問題,內容更清晰,但在語法上,它們比傳統的字符串字面量更重量級。務必要在好處大於額外成本的地方使用文本塊;如果一個字符串可以放在一行中,沒有轉義換行,那麼最好還是使用傳統的字符串字面量。

避免複雜表達式中的內聯文本塊。文本塊是字符串值表達式,因此,可以在任何需要字符串的地方使用,但將文本塊嵌套在複雜的表達式中有時候並不好,把它放到一個單獨的變量中會更好。當閱讀下面的代碼時,其中的文本塊會打斷代碼流,迫使讀者轉換思維:

String poem = new String(Files.readAllBytes(Paths.get("jabberwocky.txt")));
String middleVerses = Pattern.compile("\\n\\n")
                             .splitAsStream(poem)
                             .match(verse -> !"""
                                   ’Twas brillig, and the slithy toves
                                   Did gyre and gimble in the wabe;
                                   All mimsy were the borogoves,
                                   And the mome raths outgrabe.
                                   """.equals(verse))
                             .collect(Collectors.joining("\n\n"));

如果我們把文本塊放入它自己的變量中,讀者就更容易理解計算流程:

String firstLastVerse = """
    ’Twas brillig, and the slithy toves
    Did gyre and gimble in the wabe;
    All mimsy were the borogoves,
    And the mome raths outgrabe.
    """;
String poem = new String(Files.readAllBytes(Paths.get("jabberwocky.txt")));
String middleVerses = Pattern.compile("\\n\\n")
                             .splitAsStream(poem)
                             .match(verse -> !firstLastVerse.equals(verse))
                             .collect(Collectors.joining("\n\n"));

避免在文本塊縮進中混合使用空格和製表符。刪除附加縮進的算法會計算公共空格前綴,因此,如果混合使用空格和製表符進行縮進,該算法仍然有效。不過,這顯然更容易出錯,所以最好避免混合使用它們,而只使用其中的一種。
將文本塊與相鄰的Java代碼對齊。由於附加空格會被自動刪除,所以我們應該利用這一點使代碼更易於閱讀。雖然我們可能會忍不住寫成下面這樣:

void printPoem() {
    String poem = """
’Twas brillig, and the slithy toves
Did gyre and gimble in the wabe;
All mimsy were the borogoves,
And the mome raths outgrabe.
""";
    System.out.print(poem);

因爲我們不想字符串中有任何前導縮進,但大多數時候,我們應該寫成下面這樣:

void printPoem() {
    String poem = """
        ’Twas brillig, and the slithy toves
        Did gyre and gimble in the wabe;
        All mimsy were the borogoves,
        And the mome raths outgrabe.
        """;
    System.out.print(poem);
}

因爲這樣讀者更容易理解。

不是一定要將文本與開始分隔符對齊。不過,我們可以選擇將文本塊內容與開始分隔符對齊:

String poem = """
              ’Twas brillig, and the slithy toves
              Did gyre and gimble in the wabe;
              All mimsy were the borogoves,
              And the mome raths outgrabe.
              """;

這樣看起來很漂亮,但是如果行很長,或者分隔符從距離左邊距很遠的地方開始,就會很麻煩,因爲現在文本會一直插入到右邊距。但是,這樣的縮進並不是必需的;我們可以使用任何縮進方式,只要前後一致:

String poem = """
    ’Twas brillig, and the slithy toves
    Did gyre and gimble in the wabe;
    All mimsy were the borogoves,
    And the mome raths outgrabe.
    """;

當文本塊中嵌入了三重引號時,只轉義第一個引號。雖然可以每個引號都轉義,但這沒必要,並且會降低可讀性;只需要轉義第一個引號:

String code = """
    String source = \"""
        String message = "Hello, World!";
        System.out.println(message);
        \""";
    """;

使用\分割非常長的行。除了文本塊之外,我們還獲得了兩個新的轉義序列,\s(空格字面量)和<newline>(續行指示符)。如果文本的行非常長,可以使用<newline>在源代碼中放一個換行符,它會在字符串編譯時轉義處理期間被刪除。

小結

Java程序中的字符串字面量並不侷限於像“yes”和“no”這樣的短字符串;它們通常對應於結構化語言(如HTML、SQL、XML、JSON甚至Java)的整段“程序”。保留嵌入的那段程序的二維結構,而又不引入轉義字符和其他語言上的干擾,這樣程序更不容易出錯,而且更易於閱讀。

作者介紹

Brian Goetz 是Oracle的Java語言架構師,他負責JSR-335 (Java編程語言的Lambda表達式)規範。他是暢銷書《Java併發編程實踐》的作者,自吉米·卡特擔任總統以來,就一直癡迷於編程。

原文鏈接:

Java Feature Spotlight: Text Blocks

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章