Categories
程式開發

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 Streetn" +
                 "Anytown, USA, 12345n";                 

String address = "25 Main StreetnAnytown, USA, 12345n";

每个Java开发人员都知道,这些写法都非常麻烦。但是,更重要的是,它们更容易出错(很容易忘记n并且发现不了),也更难阅读(因为语言语法与字符串的内容混在一起)。由于文本块通常没有转义字符和其他语法干扰,读者更容易看明白字符串的内容。

字符串字面量中最常见的转义字符是换行符(n),文本块支持直接表示多行字符串而不需要换行符。除了换行符之外,另一个最常用的转义字符是双引号(”),这个必须转义,因为它与字符串字面量分隔符冲突。文本块不需要这样,因为单引号与三引号文本块分隔符并不冲突。

为什么起了这样一个名字?

有人可能会说,这个特性应该叫“多行字符串字面量”(很多人可能会这样称呼它)。但是,我们选择了一个不同的名称文本块,为的是强调:文本块不是不相关行的集合,而是嵌入到Java程序中的二维文本块。为了说明这里所说的“二维”是什么意思,我们举一个稍微结构化一些的例子,在这个例子中,文本块是一个XML片段。(同样的考量也适用于其他“语言”的“程序”片段,如SQL、HTML、JSON,甚至Java,作为字面量嵌入到Java程序中。)

void m() {
    System.out.println("""
                       
                           Bob
                           Jones
                       
                       """);
}

作者希望这段代码打印什么?虽然我们无法读取他们的想法,但似乎不太可能是想让XML块缩进21个空格;更可能是,这21个空格只是用来将文本块与周围的代码对齐。另一方面,几乎可以肯定,作者的意图是输出的第二行应该比第一行多缩进四个空格。此外,即使作者确实需要缩进21个空格,当程序修改、周围代码的缩进发生变化时,又会发生什么呢?我们不希望输出的缩进仅仅因为源代码被重新格式化而改变,也不希望文本块因为没有合理对齐而看起来“格格不入”。

从这个例子中,我们可以看到,嵌入在程序源代码中的多行文本块的自然缩进来自于文本块各行行之间预期的相对缩进,以及文本块与周围代码之间的相对缩进。我们希望字符串字面量与代码对齐(因为如果不对齐就会显得格格不入),我们希望字符串的行可以反映出行之间的相对缩进,但是这两个缩进来源(我们可以称之为附带的和必需的)在源程序的表示中必然混杂在一起。(传统的字符串字面量没有这个问题,因为它们不能跨行,所以不用为了使内容对齐而在文本中添加额外的前导空格。)

解决这个问题的一种方法是使用库方法,我们可以将它应用于多行字符串字面量,比如Kotlin的trimIndent方法,而Java确实也提供了这样一个方法:String::stripIndent。但是,因为这是很常见的问题,所以Java更进一步,会在编译时自动去除附带的缩进。

为了理顺附带的和必需的缩进,我们可以想象下,在包含整个代码段的XML代码段周围绘制一个最小的矩形,并将这个矩形里的内容视为一个二维文本块。这个“魔法矩形”是文本块的内容,它反映了文本块行之间的相对缩进,但是忽略了它所在的程序是如何缩进的。

这个“魔法矩形”的类比或许可以帮助我们理解文本块的工作方式,但是细节要更微妙一些,因为我们可能会希望更好地控制哪些缩进是附带的,哪些是必需的。可以使用结尾分隔符相对于内容的位置来平衡附带缩进和必需缩进。

详情

文本块使用三引号(”””)作为其开始和结束分隔符,开始分隔符所在行的其余部分必须为空白。文本块的内容从下一行开始,一直延续到结束分隔符。块内容的编译时处理分为三个阶段:

  • 标准化行终止符。用LF(u000A)字符替换所有行终止符。这可以防止文本块的值受最后编辑它的平台的换行约定所影响。(Windows使用CR + LF作为结束行;Unix系统只使用LF,甚至还有其他使用中的方案。)
  • 从每行中删除附带的前导空格和所有的尾随空格。附带空格通过以下方式确定:
    • 计算出一组确定行,这些行是上个步骤得出的所有非空行,以及最后一行(包含结束分隔符的行),即使它是空的;
    • 计算出所有确定行的公共空格前缀
    • 从每个确定行中删除公共空格前缀。
  • 解释内容中的转义序列。文本块使用与字符串和字符字面量相同的转义序列集。执行上面这些操作意味着像n、t、s和这样的转义字符不会影响空格处理。(JEP 368中新增了两个转义序列:显式空格s,续行标记。)

在我们的XML示例中,第一行和最后一行中的所有空格都将被删除,而中间两行会缩进四个空格,因为在这个例子中有五个确定行(包括四行XML代码和结束分隔符所在的行),行的缩进都至少和第一行内容的缩进相同。通常情况下,这种缩进就是我们期望的,但有时我们可能不希望去掉所有的前导缩进。例如,如果我们想让整个块缩进4个空格,那么我们可以通过将结束分隔符向左移动4个空格来实现:

void m() {
    System.out.println("""
                       
                           Bob
                           Jones
                       
                   """);
}

因为最后一行也是一个确定行,所以公共空格前缀现在是块的最后一行中结束分隔符之前的空格数量,每一行都按这个数量删除空格,整个块缩进4个空格。我们也可以通过编程方式管理缩进,通过实例方法String::indent,它接受一个多行字符串(不管它是否来自文本块),并按固定数量的空格缩进每一行:

void m() {
    System.out.println("""
                       
                           Bob
                           Jones
                       
                       """.indent(4));
}

在极端情况下,如果不需要删除空格,则可以将结束分隔符一直移动到左边界:

void m() {
    System.out.println("""
                       
                           Bob
                           Jones
                       
""");
}

或者,我们可以通过将整个文本块移到左边界来达到同样的效果:

void m() {
    System.out.println("""

    Bob
    Jones

""");
}

乍听起来,这些规则可能有些复杂,但选用这种规则是为了平衡各种竞争性问题(既希望能够相对于周围的程序缩进文本块,又不会产生可变数量的附带前导空格),同时,如果默认的算法不是想要的结果,还可以提供一个简单的方法来调整或选择不删除空格。

嵌入式表达式

Java的字符串不支持表达式插值,而其他一些语言则支持;文本块也不支持。(在某种程度上,我们将来可能会考虑这个特性,不限于文本块,而是同样适用于字符串。)过去,参数化的字符串表达式是通过普通的字符串连接(+)构建的;Java 5添加了String::format以支持“printf”样式的字符串格式。

由于需要对周围的空格进行全面分析,当通过字符串连接组合成文本块时,要想正确地缩进可不是一件简单的事。但是,文本块的处理结果是一个普通的字符串,所以我们仍然可以使用String::format来参数化字符串表达式。此外,我们可以使用新的String::formatted方法,它是String::format的实例版本。

String person = """
                
                    %s
                    %s
                
                """.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("nn"));

如果我们把文本块放入它自己的变量中,读者就更容易理解计算流程:

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("nn"));

避免在文本块缩进中混合使用空格和制表符。删除附加缩进的算法会计算公共空格前缀,因此,如果混合使用空格和制表符进行缩进,该算法仍然有效。不过,这显然更容易出错,所以最好避免混合使用它们,而只使用其中的一种。
将文本块与相邻的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(空格字面量)和(续行指示符)。如果文本的行非常长,可以使用在源代码中放一个换行符,它会在字符串编译时转义处理期间被删除。

小结

Java程序中的字符串字面量并不局限于像“yes”和“no”这样的短字符串;它们通常对应于结构化语言(如HTML、SQL、XML、JSON甚至Java)的整段“程序”。保留嵌入的那段程序的二维结构,而又不引入转义字符和其他语言上的干扰,这样程序更不容易出错,而且更易于阅读。

作者介绍

Brian Goetz 是Oracle的Java语言架构师,他负责JSR-335 (Java编程语言的Lambda表达式)规范。他是畅销书《Java并发编程实践》的作者,自吉米·卡特担任总统以来,就一直痴迷于编程。

原文链接:

Java Feature Spotlight: Text Blocks