Skip to content

JEP 368: Text Blocks (Second Preview) | 文本块(第二次预览版)

摘要

在 Java 语言中增加 文本块。文本块是一个多行字符串字面量,它避免了大多数转义序列的需要,以可预测的方式自动格式化字符串,并在需要时给开发者提供格式控制。这是 JDK 14 中的一个 预览语言特性

历史

文本块在 2019 年初由 JEP 355 提出,作为 JEP 326(原始字符串字面量)探索的后续工作,而 JEP 326 已被 撤回 并未出现在 JDK 12 中。JEP 355 在 2019 年中 被定为 JDK 13 的预览特性,作为 预览特性。JDK 13 的反馈建议文本块应在 JDK 14 中再次预览,并增加了 两个新的转义序列

目标

  • 通过简化跨多行源代码表达字符串的任务,同时在常见情况下避免转义序列,来简化编写 Java 程序的任务。

  • 增强 Java 程序中表示非 Java 语言编写的代码的字符串的可读性。

  • 通过规定任何新构造都能表达与字符串字面量相同的字符串集合,解释相同的转义序列,并以与字符串字面量相同的方式进行操作,来支持从字符串字面量的迁移。

  • 添加用于管理显式空白和换行控制的转义序列。

非目标

  • 不定义与 java.lang.String 不同的新引用类型来表示任何新构造表达的字符串。

  • 不定义与 + 不同的新运算符,这些运算符以 String 为操作数。

  • 文本块不直接支持字符串插值。字符串插值可能在未来的 JEP 中考虑。

  • 文本块不支持原始字符串,即其字符不会被以任何方式处理的字符串。

动机

在 Java 中,将 HTML、XML、SQL 或 JSON 的片段嵌入到字符串字面量 "..." 中,通常需要大量的编辑工作,包括转义和连接,然后包含该片段的代码才能编译。这样的片段通常难以阅读和维护。

更普遍地说,在 Java 程序中表示短、中、长文本块的需求几乎是普遍存在的,无论这些文本是其他编程语言的代码、表示标准文件的结构化文本,还是自然语言中的消息。一方面,Java 语言通过允许具有无限大小和内容的字符串来满足这种需求;另一方面,它又遵循一种默认设计,即字符串应该足够小,以便在源代码文件的单行中表示(用 " 字符包围),并且足够简单以便轻松转义。然而,这种默认设计与大量 Java 程序中字符串太长而无法舒适地放在单行上的情况相矛盾。

因此,如果 Java 语言能够提供一种比字符串字面量更直观地表示字符串的机制——跨越多行且没有转义字符的视觉干扰——那么就可以提高广泛类别的 Java 程序的可读性和可写性。本质上,这是一种二维文本块,而不是一维字符序列。

然而,无法预测 Java 程序中每个字符串的角色。仅仅因为一个字符串跨越多行源代码,并不意味着在字符串中需要换行符。程序的一部分可能在将字符串分布在多行时更具可读性,但嵌入的换行符可能会改变程序另一部分的行为。因此,如果开发者能够精确控制换行符出现的位置,以及“文本块”左右两侧有多少空白字符,那么将是非常有帮助的。

HTML 示例

使用“一维”字符串字面量

java
String html = "<html>\n" +
              "    <body>\n" +
              "        <p>Hello, world</p>\n" +
              "    </body>\n" +
              "</html>\n";

使用“二维”文本块

java
String html = """
              <html>
                  <body>
                      <p>Hello, world</p>
                  </body>
              </html>
              """;

SQL 示例

使用“一维”字符串字面量

java
String query = "SELECT `EMP_ID`, `LAST_NAME` FROM `EMPLOYEE_TB`\n" +
               "WHERE `CITY` = 'INDIANAPOLIS'\n" +
               "ORDER BY `EMP_ID`, `LAST_NAME`;\n";

使用“二维”文本块

java
String query = """
               SELECT `EMP_ID`, `LAST_NAME` FROM `EMPLOYEE_TB`
               WHERE `CITY` = 'INDIANAPOLIS'
               ORDER BY `EMP_ID`, `LAST_NAME`;
               """;

多语言示例

使用“一维”字符串字面量

java
ScriptEngine engine = new ScriptEngineManager().getEngineByName("js");
Object obj = engine.eval("function hello() {\n" +
                         "    print('\"Hello, world\"');\n" +
                         "}\n" +
                         "\n" +
                         "hello();\n");

使用“二维”文本块

java
ScriptEngine engine = new ScriptEngineManager().getEngineByName("js");
Object obj = engine.eval("""
                         function hello() {
                             print('"Hello, world"');
                         }

                         hello();
                         """);

描述

本节与 JEP 355(JEP 355)中的相应部分相同,但增加了关于 新转义序列 的子节。

文本块 是 Java 语言中的一种新型字面量。它可以在任何可以出现 字符串字面量 的地方用来表示字符串,但提供了更高的表达性和更少的意外复杂性。

文本块由零个或多个内容字符组成,用开始和结束定界符括起来。

开始定界符 是三个双引号字符(""")后跟零个或多个空白字符,然后是行终止符。内容 从开始定界符的行终止符后的第一个字符开始。

结束定界符 是三个双引号字符的序列。内容在结束定界符的第一个双引号之前的最后一个字符处结束。

与字符串字面量中的字符不同,内容可以直接包括双引号字符。在文本块中使用 \" 是允许的,但不是必需的,也不推荐。选择胖定界符(""")是为了让 " 字符可以不转义地出现,并在视觉上区分文本块和字符串字面量。

与字符串字面量中的字符不同,文本块的内容可以直接包含行终止符。在文本块中使用 \n 是允许的,但不是必需的或推荐的。例如,以下文本块:

java
"""
line 1
line 2
line 3
"""

等价于以下字符串字面量:

java
"line 1\nline 2\nline 3\n"

或者字符串字面量的连接:

java
"line 1\n" +
"line 2\n" +
"line 3\n"

如果字符串末尾不需要行终止符,则可以在内容的最后一行放置结束定界符。例如,以下文本块:

java
"""
line 1
line 2
line 3"""

等价于以下字符串字面量:

java
"line 1\nline 2\nline 3"

文本块可以表示空字符串,但不建议这样做,因为它需要两行源代码:

java
String empty = """
""";

以下是一些格式不正确的文本块示例:

java
String a = """""";   // 开头定界符后没有行终止符
String b = """ """;  // 开头定界符后没有行终止符
String c = """
           ";        // 没有结尾定界符(文本块一直持续到文件末尾)
String d = """
           abc \ def
           """;      // 反斜杠未转义(关于转义处理的说明见下文)

编译时处理

文本块是一个类型为 String常量表达式,与字符串字面量相同。但是,与字符串字面量不同,文本块的内容由 Java 编译器以三个不同的步骤进行处理:

  1. 内容中的行终止符被转换为 LF (\u000A)。此转换的目的是在跨平台移动 Java 源代码时遵循最小惊讶原则。

  2. 匹配 Java 源代码缩进的意外空白字符(围绕内容的)将被删除。

  3. 内容中的转义序列将被解释。将解释作为最后一步意味着开发人员可以编写转义序列(如 \n),而不会被前面的步骤修改或删除。

处理后的内容被记录为常量池中的 CONSTANT_String_info 条目,与字符串字面量的字符一样。class 文件不会记录 CONSTANT_String_info 条目是否来源于文本块或字符串字面量。

在运行时,文本块被评估为 String 的一个实例,与字符串字面量一样。从文本块派生的 String 实例与从字符串字面量派生的实例是无法区分的。由于 字符串驻留,具有相同处理内容的两个文本块将引用相同的 String 实例,这与 字符串字面量 相同。

以下部分将更详细地讨论编译时处理。

1. 行终止符

Java 编译器将内容中的行终止符从 CR (\u000D) 和 CRLF (\u000D\u000A) 标准化为 LF (\u000A)。这确保了从内容派生的字符串在跨平台时等效,即使源代码已被转换为平台编码(见 javac -encoding)。

例如,如果在 Unix 平台上创建的 Java 源代码(其中行终止符为 LF)在 Windows 平台上编辑(其中行终止符为 CRLF),那么如果不进行标准化,则每行的内容将增加一个字符。任何依赖于 LF 作为行终止符的算法可能会失败,任何需要使用 String::equals 验证字符串相等性的测试也会失败。

在标准化过程中,不会解释转义序列 \n(LF)、\f(FF)和 \r(CR);转义处理稍后进行。

2. 意外空白字符

上面展示 的文本块比其对应的拼接字符串字面量更易读,但文本块内容的直观解释会包括为嵌入字符串添加的空格,以便使其与开头定界符整齐地对齐。以下是使用点号来表示开发人员为缩进而添加的空格的 HTML 示例:

java
String html = """
..............<html>
..............    <body>
..............        <p>Hello, world</p>
..............    </body>
..............</html>
..............""";

由于开头定界符通常定位在与使用文本块的语句或表达式相同的行上,因此每行开头的 14 个可视化空格并没有实际意义。将这些空格包含在内容中意味着文本块表示一个与由拼接字符串字面量表示的字符串不同的字符串。这会影响迁移,并反复造成意外:开发人员极不可能希望这些空格出现在字符串中。此外,结尾定界符通常定位以与内容对齐,这进一步表明 14 个可视化空格并不重要。

在每行的末尾也可能出现空格,特别是当文本块通过从其他文件(这些文件本身可能也是通过从更多文件中复制粘贴形成的)复制粘贴片段来填充时。以下是带有一些尾部空格的 HTML 示例的重新想象,同样使用点号来表示空格:

java
String html = """
..............<html>...
..............    <body>
..............        <p>Hello, world</p>....
..............    </body>.
..............</html>...
..............""";

尾部空格通常是无意的、独特的且不重要的。开发人员极不可能关心它。尾部空格字符与行终止符类似,两者都是源代码编辑环境的不可见产物。在没有视觉指导来指示尾部空格字符的存在的情况下,将它们包含在内容中将成为反复出现的意外来源,因为它会影响字符串的长度、哈希码等。

因此,对文本块内容的适当解释是区分每行开头和结尾的 意外空格必要空格。Java 编译器通过删除意外空格来处理内容,以产生开发人员想要的结果。如果需要,可以使用 String::indent 来管理缩进。使用 | 来可视化边距:

html
|<html>|
|    <body>|
|        <p>Hello, world</p>|
|    </body>|
|</html>|

重新缩进算法 对行终止符已标准化为 LF 的文本块内容进行处理。它从每行内容中删除相同数量的空白字符,直到至少有一行的最左侧位置出现非空白字符。开头 """ 字符的位置对算法没有影响,但如果关闭 """ 字符单独成行,则其位置会产生影响。算法如下:

  1. 在每个 LF 处拆分文本块的内容,生成一个 单行 列表。请注意,内容中的任何只是 LF 的行都将成为单行列表中的空行。

  2. 将单行列表中的所有 非空行 添加到 确定行 集合中。(空行——即完全为空或仅由空白字符组成的行——对缩进没有可见影响。将空行从确定行集合中排除可以避免影响算法的第四步。)

  3. 如果单行列表中的最后一行(即包含关闭定界符的行)是 空行,则将其添加到确定行集合中。(关闭定界符的缩进应影响整体内容的缩进——重要的尾部行 策略。)

  4. 计算确定行集合的 公共空白前缀,通过计算每行前导空白字符的数量并取最小值。

  5. 从单行列表中的每个 非空行 中删除公共空白前缀。

  6. 从步骤 5 中修改后的单行列表中删除所有行的尾部空白字符。此步骤将修改后列表中的完全空白行折叠为空行,但不会丢弃它们。

  7. 使用 LF 作为行之间的分隔符,将步骤 6 中修改后的单行列表中的所有行连接起来,以构建结果字符串。如果步骤 6 中的列表的最后一行是空行,则前一行连接的 LF 将成为结果字符串中的最后一个字符。

算法不会解释转义序列 \b(退格)和 \t(制表符);转义处理将在后续进行。

重新缩进算法将在《Java 语言规范》中作为规范。开发人员将通过新的实例方法 String::stripIndent 访问它。

重要的尾部行策略

通常,人们会以两种方式格式化文本块:首先,将内容的左边缘放在开放定界符的第一个 " 下方,其次,将关闭定界符放在其自己的行上,使其正好位于开放定界符下方。结果字符串将不会在任何行的开头包含空白字符,并且不会包含关闭定界符的尾部空行。

但是,由于尾部空行被视为 确定行,将其向左移动会减少公共空白前缀,从而减少从每行开头剥离的空白字符数量。在极端情况下,如果关闭定界符完全移动到左侧,那么公共空白前缀将减少到零,从而有效地选择不剥离空白字符。

例如,当关闭定界符完全移动到左侧时,没有偶然的空白字符可以用点来表示:

java
String html = """
              <html>
                  <body>
                      <p>Hello, world</p>
                  </body>
              </html>
""";

如果包括带有关闭定界符的尾部空行,公共空白前缀就是零,因此从每行的开头不会删除任何空白字符。因此,算法将产生以下结果:(使用 | 来表示左边距)

html
|              <html>
|                  <body>
|                      <p>Hello, world</p>
|                  </body>
|              </html>

另外,假设关闭定界符没有完全移动到左侧,而是位于 htmlt 下方,因此它比变量声明深八个空格:

java
String html = """
              <html>
                  <body>
                      <p>Hello, world</p>
                  </body>
              </html>
        """;

用点表示的空格被认为是偶然的:

java
String html = """
........      <html>
........          <body>
........              <p>Hello, world</p>
........          </body>
........      </html>
........""";

包括带有关闭定界符的尾部空行,公共空白前缀是八个空格,因此从每行的开头删除八个空格。因此,该算法保留了内容相对于关闭定界符的基本缩进:

html
|      <html>
|          <body>
|              <p>Hello, world</p>
|          </body>
|      </html>

最后,假设关闭定界符稍微向右移动到内容的 右侧

java
String html = """
              <html>
                  <body>
                      <p>Hello, world</p>
                  </body>
              </html>
                  """;

用点表示的空格被认为是偶然的:

java
String html = """
..............<html>
..............    <body>
..............        <p>Hello, world</p>
..............    </body>
..............</html>
..............    """;

公共空白前缀是 14,因此从每行的开头删除了 14 个空格。尾部的空行被删除以留下一个空行,而由于它是最后一行,所以该空行也被丢弃。换句话说,将关闭定界符移动到内容的右侧没有任何效果,算法再次保留了内容的基本缩进:

html
|<html>
|    <body>
|        <p>Hello, world</p>
|    </body>
|</html>

3. 转义序列

在内容重新缩进后,内容中的任何 转义序列 都将被解释。文本块支持字符串字面量中支持的所有转义序列,包括 \n\t\'\"\\。完整的列表请参见《Java 语言规范》的 第3.10.6 节。开发人员将通过 String::translateEscapes 这一新的实例方法来访问转义处理。

将解释转义序列作为最后一步,允许开发人员使用 \n\f\r 对字符串进行垂直格式化,而不会影响步骤 1 中对行终止符的翻译,以及使用 \b\t 对字符串进行水平格式化,而不会影响步骤 2 中删除偶然的空白字符。例如,考虑这个包含 \r 转义序列(回车)的文本块:

java
String html = """
              <html>\r
                  <body>\r
                      <p>Hello, world</p>\r
                  </body>\r
              </html>\r
              """;

直到行终止符被标准化为 LF 后,CR 转义序列才会被处理。使用 Unicode 转义序列来表示 LF(\u000A)和 CR(\u000D),结果如下:

java
|<html>\u000D\u000A
|    <body>\u000D\u000A
|        <p>Hello, world</p>\u000D\u000A
|    </body>\u000D\u000A
|</html>\u000D\u000A

请注意,在文本块内部自由使用 " 是合法的,即使它紧挨着开始或关闭定界符。例如:

java
String story = """
    "When I use a word," Humpty Dumpty said,
    in rather a scornful tone, "it means just what I
    choose it to mean - neither more nor less."
    "The question is," said Alice, "whether you
    can make words mean so many different things."
    "The question is," said Humpty Dumpty,
    "which is to be master - that's all."
    """;

然而,连续三个 " 字符至少需要转义一个 " 以避免模仿关闭定界符:

java
String code =
    """
    String text = \"""
        A text block inside a text block
    \""";
    """;

新的转义序列

为了更精细地控制换行符和空白字符的处理,我们引入了两个新的转义序列。

首先,\<line-terminator> 转义序列明确禁止插入换行符。

例如,常见的做法是将非常长的字符串字面量拆分为较小的子字符串的连接,然后将得到的字符串表达式硬编码到多行上:

java
String literal = "Lorem ipsum dolor sit amet, consectetur adipiscing " +
                 "elit, sed do eiusmod tempor incididunt ut labore " +
                 "et dolore magna aliqua.";

使用 \<line-terminator> 转义序列,这可以表达为:

java
String text = """
                Lorem ipsum dolor sit amet, consectetur adipiscing \
                elit, sed do eiusmod tempor incididunt ut labore \
                et dolore magna aliqua.\
                """;

由于字符字面量和传统字符串字面量不允许嵌入换行符,因此 \<line-terminator> 转义序列仅适用于文本块。

其次,新的 \s 转义序列简单地转换为单个空格(\u0020)。

转义序列在删除空格事件之后才被翻译,因此 \s 可以作为围栏来防止删除尾随的空白字符。在这个示例中,在每行的末尾使用 \s 可以确保每行恰好有六个字符长:

java
String colors = """
    red  \s
    green\s
    blue \s
    """;

\s 转义序列可以在文本块和传统字符串字面量中使用。

文本块的拼接

文本块可以在任何可以使用字符串字面量的地方使用。例如,文本块和字符串字面量可以互换地进行拼接:

java
String code = "public void print(Object o) {" +
              """
                  System.out.println(Objects.toString(o));
              }
              """;

但是,涉及文本块的拼接可能会变得相当笨拙。以下面的文本块作为起点:

java
String code = """
              public void print(Object o) {
                  System.out.println(Objects.toString(o));
              }
              """;

假设需要更改,使得 o 的类型来自一个变量。使用拼接,包含后续代码的文本块需要在新的一行开始。不幸的是,直接在程序中插入换行符,如下所示,会导致类型与以 o 开头的文本之间出现大量空白:

java
String code = """
              public void print(""" + type + """
                                                 o) {
                  System.out.println(Objects.toString(o));
              }
              """;

这些空白可以手动删除,但这会损害引用代码的可读性:

java
String code = """
              public void print(""" + type + """
               o) {
                  System.out.println(Objects.toString(o));
              }
              """;

一个更干净的选择是使用 String::replaceString::format,如下所示:

java
String code = """
              public void print($type o) {
                  System.out.println(Objects.toString(o));
              }
              """.replace("$type", type);
java
String code = String.format("""
              public void print(%s o) {
                  System.out.println(Objects.toString(o));
              }
              """, type);

另一种选择是引入一个新的实例方法 String::formatted,它可以这样使用:

java
String source = """
                public void print(%s object) {
                    System.out.println(Objects.toString(object));
                }
                """.formatted(type);

附加方法

以下方法将被添加以支持文本块;

  • String::stripIndent():用于从文本块内容中去除偶然的空白
  • String::translateEscapes():用于翻译转义序列
  • String::formatted(Object... args):简化文本块中的值替换

替代方案

不做任何改变

Java 已经有 20 多年的历史,其字符串字面量要求新行需要转义。集成开发环境(IDE)通过支持自动格式化和跨越多行源代码的字符串连接来减轻维护负担。String 类也发展出了简化处理和格式化长字符串的方法,比如将一个字符串呈现为 行流 的方法。但是,字符串是 Java 语言如此基础的一部分,以至于字符串字面量的缺点对 大量开发者 来说都显而易见。其他 JVM 语言也在如何表示长和复杂的字符串方面取得了进步。因此,不出所料的是,多行字符串字面量一直是 Java 最受请求的特性之一。引入一个低到中等复杂度的多行构造将具有很高的回报。

允许字符串字面量跨越多行

在 Java 中引入多行字符串字面量可以简单地通过在现有的字符串字面量中允许行终止符来实现。但是,这样做并不能解决转义 " 字符的痛苦。由于代码片段的频繁出现,\"\n 之后最常见的转义序列。在字符串字面量中避免转义 " 的唯一方法是为字符串字面量提供另一种分隔符方案。在 JEP 326(原始字符串字面量)中,对分隔符进行了大量讨论,并且从中学到的经验被用于指导文本块的设计,因此破坏字符串字面量的稳定性是不明智的。

采用另一种语言的多行字符串字面量

根据 Brian Goetz 的说法:

很多人建议 Java 应该采用 Swift 或 Rust 的多行字符串字面量。但是,“只是模仿语言 X 的做法”这种方法本质上是不负责任的;几乎每种语言的每个特性都受到该语言其他特性的制约。相反,我们应该学习其他语言如何处理问题,评估它们所选择的权衡(明确和隐含的),并询问哪些可以应用于我们现有语言的约束和社区内的用户期望。

对于 JEP 326(原始字符串字面量),我们调查了许多现代编程语言及其对多行字符串字面量的支持。这些调查的结果影响了当前的提案,例如选择三个 " 字符作为分隔符(尽管还有其他原因)以及认识到需要自动缩进管理。

不要移除意外的空白字符

如果 Java 引入了多行字符串字面量但不支持自动移除意外的空白字符,那么许多开发人员可能会编写一个方法来自己移除它们,或者呼吁 String 类包含一个移除方法。但是,这意味着每次在运行时实例化字符串时都可能会进行昂贵的计算,这会降低字符串池化的好处。由 Java 语言强制移除前导和尾随位置的意外空白字符似乎是最合适的解决方案。开发人员可以通过仔细放置结束分隔符来选择不移除前导空白字符。

原始字符串字面量

对于 JEP 326(原始字符串字面量),我们采取了不同的方法来处理无需转义换行符和引号的字符串问题,重点是字符串的原始性。我们现在认为这种重点是不正确的,因为虽然原始字符串字面量可以很容易地跨越多行源代码,但在其内容中支持未转义的分隔符的成本是极高的。这限制了该特性在多行用例中的有效性,这是一个关键用例,因为 Java 程序中经常嵌入多行(但不是真正的原始)代码片段。从原始性转向多行性的转变带来的一个好结果是,重新关注在字符串字面量、文本块和可能在未来添加的相关特性之间拥有一致的转义语言。

测试

对于使用字符串字面量创建、池化和操作 String 实例的测试,应该复制为也使用文本块的测试。应该为涉及行终止符和 EOF 的边缘情况添加负测试。

应该添加测试以确保文本块可以嵌入 Java-in-Java、Markdown-in-Java、SQL-in-Java,以及至少一种 JVM 语言 -in-Java。