跳到主要内容

JEP 355:文本块(预览)

QWen Max 中英对照

概述

在 Java 语言中添加 文本块(text blocks)。文本块是一种多行字符串字面量,它避免了大多数转义序列的使用需求,以可预测的方式自动格式化字符串,并在需要时给予开发者对格式的控制权。这是 JDK 13 中的一项 预览语言功能

历史

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

目标

  • 通过简化表达跨越多行源代码的字符串任务,避免在常见情况下使用转义序列,从而简化 Java 程序的编写工作。

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

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

非目标

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

  • 定义不同于 + 的新运算符来处理 String 操作数并不是目标。

  • 文本块并不直接支持字符串插值。字符串插值可能会在未来的 JEP(JDK 改进提案)中被考虑。

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

动机

在 Java 中,将一段 HTML、XML、SQL 或 JSON 嵌入到字符串字面量 "..." 中通常需要进行大量的编辑,包括转义和拼接,才能使包含该代码片段的代码成功编译。这样的代码片段通常难以阅读且维护起来十分繁琐。

更一般地说,在 Java 程序中表示短、中、长文本块的需求几乎是普遍存在的,无论这些文本是来自其他编程语言的代码、代表黄金文件的结构化文本,还是自然语言中的消息。一方面,Java 语言通过允许无大小和内容限制的字符串认识到了这一需求,但另一方面,它体现了一种设计默认值,即字符串应小到可以在源文件的一行上表示,并且简单到可以轻松转义。Java 语言可以通过接受字符串可能大到跨越源文件的多行,并设想其内容中的转义符可能代表格式化和布局操作以及单个字符,从而实现规范化。

因此,对于广义的 Java 程序而言,如果有一种语言机制可以比字符串字面量更直观地表示字符串(跨多行且无需转义字符带来的视觉混乱),将有助于提升程序的可读性和可写性。本质上,这是一块二维的文本块,而非一维的字符序列。

HTML 示例

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

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

使用“二维”文本块

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

SQL 示例

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

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

使用“二维”文本块

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

多语言示例

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

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

使用“二维”文本块

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

hello();
""");

描述

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

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

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

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

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

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

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

等价于字符串字面量:

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

或者是字符串字面量的串联:

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

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

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

等价于字符串字面量:

"line 1\nline 2\nline 3"

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

A text block can denote the empty string, although this is not recommended because it needs two lines of source code:
markdown
String empty = """
""";

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

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)

编译时处理

文本块是类型为 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 平台上创建的 Java 源代码(行终止符为 LF)在 Windows 平台上编辑(行终止符为 CRLF),那么在没有标准化的情况下,每一行的内容都会多出一个字符。任何依赖 LF 作为行终止符的算法都可能失败,并且任何需要通过 String::equals 验证字符串相等性的测试也会失败。

转义序列 \n(换行符)、\f(换页符)和 \r(回车符)在规范化过程中不会被解释;转义处理会在稍后进行。

2. 非故意的空白

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

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

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

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

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

尾随的空白通常是非故意的、独特的且无关紧要的。开发者几乎可以肯定并不关心它。尾随的空白字符与行终止符类似,因为它们都是源代码编辑环境中的不可见产物。由于没有视觉上的提示来显示尾随空白字符的存在,将它们包含在内容中会不断带来意外,因为这会影响到字符串的长度、哈希码等。

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

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

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

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

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

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

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

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

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

  7. 通过使用换行符(LF)作为行之间的分隔符,将第 6 步修改后的单独行列表中的所有行连接起来构造结果字符串。如果第 6 步列表中的最后一行为空,则上一行的连接换行符将是结果字符串中的最后一个字符。

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

重新缩进算法将在《Java 语言规范》中具有规范性。开发者可以通过 String::stripIndent 这一新实例方法来使用它。

重要尾行策略

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

然而,由于末尾的空行被视为一个决定性行,将其向左移动会减少共同的空白前缀,从而减少从每行开头剥离的空白量。在极端情况下,当闭合分隔符被完全移到最左边时,这会将共同的空白前缀减少到零,从而有效地选择不进行空白剥离。

例如,当右边界完全左移时,就没有多余的空白需要用点来表示:

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

包括带有结束分隔符的尾随空行在内,共同的前缀空白为零,因此每行开头不会删除任何空白。于是算法生成如下结果:(使用 | 来可视化左边界)

|这是一个示例
| 这是另一个示例
|
markdown
|              <html>
| <body>
| <p>Hello, world</p>
| </body>
| </html>

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

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

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

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

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

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

最后,假设闭合分隔符稍微移动到内容的 右侧

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

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

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

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

公共的空白前缀是 14,因此每行开头会删除 14 个空格。结尾的空行会被去除,留下一个空行,由于是最后一行,则该空行被丢弃。换句话说,将结束分隔符移到内容右侧没有任何效果,该算法再次保留了内容的基本缩进:
markdown
|<html>
| <body>
| <p>Hello, world</p>
| </body>
|</html>

3. 转义序列

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

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

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

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

请注意,在文本块内可以自由使用 ",即使它紧邻开或关的分隔符。例如:

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

然而,三个 " 字符的序列需要至少转义一个 ",以避免模仿结束分隔符:

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

文本块的连接

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

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

然而,涉及到文本块的连接可能会变得相当笨拙。以下面这个文本块为起点:

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

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

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

可以手动删除空白,但这会降低被引用代码的可读性:

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

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

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

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

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

无法选择不删除尾随空格,因此在尾随空格具有重要意义的罕见情况下(例如 Markdown 中用于 <br /> 标签的两个空格),开发人员必须采取特殊措施来强制包含它,例如使用 八进制转义序列 \040(ASCII 字符 32,空格):

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

原始字符串字面量

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

测试

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

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