跳到主要内容

JEP 368:文本块(第二预览版)

QWen Max 中英对照 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 程序中每个字符串的作用仍然是不可能的。仅仅因为一个字符串跨越了多行源代码,并不意味着该字符串中就一定需要换行符。程序的某一部分在将字符串分布在多行时可能更具可读性,但嵌入的换行符可能会改变程序另一部分的行为。因此,如果开发者能够精确控制换行符出现的位置,以及与文本“块”左右两侧的空白量,那将会很有帮助。

HTML 示例

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

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>
""";
java

SQL 示例

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

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

描述

本节与其前身 JEP 355 中的相同部分几乎一致,除了新增了关于 新转义序列 的小节。

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

一个文本块由零个或多个内容字符组成,并由起始和结束分隔符包围。

起始分隔符 是由三个双引号字符(""")组成的序列,后跟零个或多个空白字符,然后是行终止符。内容 从起始分隔符的行终止符之后的第一个字符开始。

结束分隔符 是由三个双引号字符组成的序列。内容在结束分隔符的第一个双引号之前的一个字符处结束。

内容可以直接包含双引号字符,这与字符串字面量中的字符不同。在文本块中允许使用 \",但不是必须的,也不推荐使用。选择胖分隔符(""")是为了让 " 字符可以不转义出现,同时也是为了从视觉上将文本块与字符串字面量区分开来。

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

"""
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 = """""";   // no line terminator after opening delimiter
String b = """ """; // no line terminator after opening delimiter
String c = """
"; // no closing delimiter (text block continues to EOF)
String d = """
abc \ def
"""; // unescaped backslash (see below for escape processing)
java

编译时处理

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

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

  2. 围绕内容的、因匹配 Java 源代码缩进而引入的附带空白会被移除。

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

处理后的内容被记录在 class 文件的常量池中,作为一个 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(换行符)、\f(换页符)和 \r(回车符)在规范化过程中不会被解释;转义处理会在稍后进行。

2. 非故意的空白

上图所示 的文本块比它们的串联字符串字面量对应部分更容易阅读,但对于文本块内容的直观解释会包含为了与起始分隔符对齐而添加的用于缩进嵌入字符串的空格。以下是使用点来可视化开发者为缩进而添加的空格的 HTML 示例:

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

由于起始分隔符通常位于与使用该文本块的语句或表达式同一行的位置,因此每行开头的 14 个可视化空格实际上并没有重要意义。将这些空格包含在内容中意味着文本块表示的字符串不同于由串联字符串字面量所表示的字符串。这会不利于迁移,并且会不断带来意外:开发者几乎可以肯定不希望字符串中包含这些空格。此外,结束分隔符通常与内容对齐,这也进一步表明这 14 个可视化空格并不重要。

每行的末尾也可能出现空格,特别是当一个文本块是通过从其他文件复制粘贴片段填充时(这些文件本身可能也是通过从更多文件复制粘贴形成的)。下面是重新构想的 HTML 示例,其中包含一些尾随的空白,同样使用点来可视化空格:

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

尾随的空白通常都是无意的、独特的且不重要的。开发人员极不可能关心它。尾随的空白字符与行终止符类似,因为它们都是源代码编辑环境中的不可见产物。由于没有视觉提示来表明尾随空白字符的存在,将它们包含在内容中会不断带来意外,因为这会影响字符串的长度、哈希码等。

因此,对文本块内容的适当解释是区分每行开头和结尾的附带空白必要空白。Java 编译器通过去除附带空白来处理内容,以得到开发者所期望的结果。如果需要的话,可以使用 String::indent 来管理缩进。使用 | 来可视化边界:

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

重新缩进算法 接收一个文本块的内容,该文本块的行终止符已规范化为 LF。它会从每行内容中删除相同数量的空白字符,直到至少有一行的最左侧位置出现非空白字符为止。 开头的 """ 字符的位置对算法没有影响,但如果将其单独放在一行上,则结尾的 """ 字符的位置确实会产生影响。算法如下:

  1. 在每个 LF 处拆分文本块的内容,生成一个单独行的列表。请注意,内容中的任何仅包含 LF 的行在单独行列表中将变成空行。

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

  3. 如果单独行列表中的最后一行(即带有结束分隔符的行)是空白的,则将其添加到确定行集合中。(结束分隔符的缩进应该影响整体内容的缩进——这是一个重要尾随行策略。)

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

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

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

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

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

重新缩进算法在《Java 语言规范》中将是规范性的。开发者可以通过 String::stripIndent 这个新的实例方法来访问它。

重要尾行策略

通常,格式化文本块的方式有两种:首先,将内容的左边缘放置在起始分隔符的第一个 " 下方;其次,将结束分隔符单独放在一行,与起始分隔符完全对齐。最终生成的字符串在任何行的开头都不会有空格,并且不会包含结束分隔符的尾随空行。

但是,由于末尾的空行被视为确定行,将其向左移动会减少共同的空白前缀,从而减少每行开头被去除的空白量。在极端情况下,如果闭合分隔符被一直移到最左边,这会将共同的空白前缀减少到零,实际上等于放弃了空白去除功能。

例如,将结束分隔符一直移到最左边时,就没有附带的空白区域需要用点来表示了:

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

包含带有结束分隔符的尾随空行在内,公共的前缀空格为零,因此每行开头不会移除任何空格。该算法从而生成如下结果:(使用 | 来可视化左边距)

|func greet() {
| print("Hello, world!")
|}
|              <html>
| <body>
| <p>Hello, world</p>
| </body>
| </html>
java

或者,假设结束分隔符不是一直移到最左边,而是放在 htmlt 下方,这样它就比变量声明深了八个空格:

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

用点表示的空间被视为偶然存在:

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

包含带有结束分隔符的尾随空行在内,公共的前缀空白是八个,因此每行开头会移除八个空格。这样,该算法就保留了内容相对于结束分隔符的基本缩进:

        Some text
And more text
text

变为

Some text
And more text
text
|      <html>
| <body>
| <p>Hello, world</p>
| </body>
| </html>
java

最后,假设将结束分隔符稍微移到内容的 右侧

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

用点表示的空间被视为偶然的:

The spaces visualized with dots are considered to be incidental:
String html = """
..............<html>
.............. <body>
.............. <p>Hello, world</p>
.............. </body>
..............</html>
.............. """;
java

公共的空白前缀是 14,因此每行开头会删除 14 个空格。尾部的空行被剥离为一个空行,由于是最后一行,随后被丢弃。换句话说,将结束分隔符向内容右侧移动没有任何效果,该算法再次保留了内容的基本缩进:

无代码翻译
注:这里没有具体的代码示例,因此添加了占位说明。

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

3. 转义序列

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

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

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

CR 转义符在行终止符被规范化为 LF 后才会被处理。使用 Unicode 转义符来可视化 LF(\u000A)和 CR(\u000D),结果是:

|<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
\""";
""";
java

新的转义序列

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

首先,\<line-terminator> 转义序列显式地抑制了新行字符的插入。

例如,通常的做法是将非常长的字符串字面量拆分为较小子字符串的连接,然后将结果字符串表达式硬换行到多行:

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

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

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

原因很简单,因为字符字面量和传统的字符串字面量不允许嵌入换行符,所以 \<line-terminator> 转义序列只适用于文本块。

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

转义序列在事件空间剥离之后才会被翻译,因此 \s 可以充当围栏,防止尾随的空白被剥离。在此示例中,在每一行的末尾使用 \s 可保证每行恰好为六个字符长:

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

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

文本块的串联

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

String code = """
public void print(Object o) {
System.out.println(Objects.toString(o));
}
""" + "System.out.println(\"Hello, World\");";
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));
}
""";
java

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

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));
}
""";
java

一个更简洁的替代方法是使用 String::replaceString::format,如下所示:

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);
java

另一个替代方案涉及引入一个新的实例方法 String::formatted,它可以如下使用:

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

其他方法

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

  • 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 类中加入一个去除方法。然而,这意味着每次在运行时实例化字符串时都会产生潜在的高昂计算开销,这将降低字符串驻留(string interning)的好处。让 Java 语言强制去除前导和尾随的无关空白字符似乎是最合适的解决方案。开发者可以通过谨慎放置结束分隔符来选择不删除前导空白字符。

原始字符串字面量

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

测试

测试应复制使用文本块来创建、内部化和操作 String 实例的字符串字面量。应添加针对涉及行终止符和文件结尾(EOF)的边界情况的负面测试。

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