Skip to content

JEP 378: Text Blocks | 文本块

摘要

向 Java 语言添加 文本块。文本块是一种多行字符串字面量,它避免了大多数转义序列的需要,以可预测的方式自动格式化字符串,并在需要时给予开发者对格式的控制。

历史

文本块在 2019 年初由 JEP 355 提出,作为 JEP 326(原始字符串字面量)中开始的探索的后续工作,后者最初是面向 JDK 12 的,但最终 被撤回并未出现在该版本中。JEP 355 在 2019 年 6 月被指定为 JDK 13 的预览功能,作为 预览功能。关于 JDK 13 的反馈表明,文本块应该在 JDK 14 中再次预览,并添加了 两个新的转义序列。因此,JEP 3682019 年 11 月被指定为 JDK 14 的预览功能。关于 JDK 14 的反馈表明,文本块已经准备好在 JDK 15 中成为最终和永久的功能,无需进一步更改。

目标

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

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

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

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

非目标

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

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

  • 文本块不直接支持字符串插值。未来可能会在 JEP 中考虑插值。在此期间,新的实例方法 String::formatted 有助于在可能需要插值的场景中提供帮助。

  • 文本块不支持原始字符串,即字符不会被处理的字符串。

动机

在 Java 中,将 HTML、XML、SQL 或 JSON 代码片段嵌入到字符串字面量 "..." 中通常需要在代码片段所在的代码编译之前进行大量的编辑,包括转义和拼接。这样的代码片段通常难以阅读且维护起来很费劲。

更普遍地说,在 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 的相同部分完全相同,除了增加了关于 新转义序列 的小节外。

文本块 是 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(制表符)和 \s(空格)这些转义序列 不会被 算法解释;转义处理发生在之后。类似地,\<line-terminator> 转义序列不会阻止在行终止符处拆分行,因为该序列在转义处理之前被视为两个独立的字符。

重新缩进算法将在《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>

然而,三个连续的 " 字符至少需要有一个 " 进行转义,以避免模仿结束定界符。(n 个连续的 " 字符至少需要 Math.floorDiv(n,3) 个进行转义。)在结束定界符之前立即使用 " 也需要进行转义。例如:

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

String tutorial1 =
    """
    A common character
    in Java programs
    is \"""";

String tutorial2 =
    """
    The empty string literal
    is formed from " characters
    as follows: \"\"""";

System.out.println("""
     1 "
     2 ""
     3 ""\"
     4 ""\""
     5 ""\"""
     6 ""\"""\"
     7 ""\"""\""
     8 ""\"""\"""
     9 ""\"""\"""\"
    10 ""\"""\"""\""
    11 ""\"""\"""\"""
    12 ""\"""\"""\"""\"
""");

新的转义序列

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

首先,\<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(原始字符串字面量),我们调查了许多现代编程语言以及它们对多行字符串字面量的支持。这些调查的结果影响了当前的提案,例如选择三个 " 字符作为分隔符(尽管选择此选项还有其他原因),并认识到需要自动缩进管理。