Skip to content

JEP 355: Text Blocks (Preview) | 文本块(预览版)

摘要

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

历史

这是对 JEP 326(原始字符串字面量)探索的后续努力,该提案被 撤回 并未在 JDK 12 中出现。

目标

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

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

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

非目标

  • 定义一个与 java.lang.String 不同的新引用类型,用于任何新构造表达的字符串,不是本提案的目标。

  • 定义一个与 + 不同的新运算符,该运算符接受 String 操作数,不是本提案的目标。

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

  • 文本块不支持原始字符串,即不打算对其字符进行任何处理的字符串。

动机

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

描述

文本块 是 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 文件的常量池中,就像字符串字面量的字符一样。class 文件不会记录 CONSTANT_String_info 条目是源自文本块还是字符串字面量。

在运行时,文本块被评估为 String 的实例,就像字符串字面量一样。源自文本块的 String 实例与源自字符串字面量的实例无法区分。由于 字符串常量池 的作用,两个具有相同处理内容的文本块将引用相同的 String 实例,这与 字符串字面量 的情况相同。

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

1. 行终止符

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

例如,如果在 Unix 平台上创建(行终止符为 LF)的 Java 源代码在 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),结果是:

html
|<html>\u000A
|    <body>\u000A
|        <p>Hello, world</p>\u000A
|    </body>\u000A
|</html>\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
    \""";
    """;

文本块的拼接

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

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 语言强制去除前导和尾部的偶然空白字符,似乎是最合适的解决方案。开发者可以通过仔细放置结束分隔符来选择不去除前导空白字符。

无法选择不去除尾部空白字符,因此,在极少数尾部空白字符重要的上下文中(例如在 Markdown 中使用两个空格表示 <br /> 标签),开发者必须采取非传统措施来强制包含它们,如使用 八进制转义序列 \040(ASCII 字符 32,空白字符):

java
"""
The quick brown fox\040\040
jumps over the lazy dog
"""
java
"""
The quick brown fox""" + "  \n" + """
jumps over the lazy dog
"""
java
"""
The quick brown fox  |
jumps over the lazy dog
""".replace("|", "");

原始字符串字面量

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

测试

用于创建、池化和操作 String 实例的字符串字面量测试应该也使用文本块进行复制。对于涉及行终止符和文件结束符(EOF)的边界情况,应添加否定测试。

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