跳到主要内容

JEP 378: 文本块

QWen Max 中英对照 JEP 378: Text Blocks

总结

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

历史

文本块最早于 2019 年初由 JEP 355 提出,作为对 JEP 326(原始字符串字面量)探索工作的后续。JEP 326 最初计划进入 JDK 12,但最终被撤回,并未出现在该版本中。JEP 355 于 2019 年 6 月被定为目标进入 JDK 13,作为一个预览功能。针对 JDK 13 的反馈表明,文本块应在 JDK 14 中再次以预览形式出现,同时增加两个新的转义序列。因此,JEP 368 于 2019 年 11 月被定为目标进入 JDK 14,同样作为预览功能。针对 JDK 14 的反馈表明,文本块已准备好在 JDK 15 中成为最终且永久的功能,无需进一步更改。

目标

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

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

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

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

非目标

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

  • 定义不同于 + 的、接受 String 操作数的新运算符并不是目标。

  • 文本块不直接支持字符串插值。插值可能会在未来的 JEP(JDK Enhancement Proposal,JDK 增强提案)中被考虑。在此期间,新的实例方法 String::formatted 可在需要插值的情况下提供帮助。

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

动机

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

使用“二维”文本块

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

描述

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

文本块是 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"

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

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. 非故意的空白

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

QWen Max 中英对照 JEP 378: Text Blocks
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 处拆分文本块的内容,生成一个单独行的列表。请注意,内容中的任何仅包含 LF 的行在单独行列表中将变为空行。

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

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

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

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

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

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

转义序列 \b(退格)、\t(制表符)和 \s(空格)不会被算法解释;转义处理会在之后进行。类似地,\<line-terminator> 转义序列并不会阻止在行终止符处分割行,因为该序列在转义处理之前被视为两个独立的字符。

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

重要尾行策略

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

但是,由于末尾的空行被视为确定行,将其向左移动会减少共同的空白前缀,从而减少每行开头被去除的空白量。在极端情况下,如果右分隔符被完全移到最左边,那么共同的空白前缀将减为零,实际上等于退出了空白去除。

例如,当右闭合分隔符一直移到最左边时,就没有附带的空白用点来表示:

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

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

|func HelloWorld() {
| fmt.Println("Hello, world!")
|}
|              <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>
........""";

包含带有结束定界符的尾随空行在内,公共前缀为空格的个数为 8,因此每行开头都会移除 8 个空格。这样,该算法会保留内容相对于结束定界符的基本缩进:

|      <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 个空格。尾随的空行被剥离,留下一个空行,由于是最后一行,该空行随后被丢弃。换句话说,将结束分隔符向内容右侧移动没有任何效果,并且该算法再次保留了内容的基本缩进:

                Line 1
Line 2
Line 3

变为:

Line 1
Line 2
Line 3
|<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(回车)的文本块:

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."
"""; // Note the newline before the closing delimiter

String code =
"""
String empty = "";
""";

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

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> 转义序列明确抑制了换行符的插入。

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

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

通过 \<line-terminator> 转义序列,可以将其表示为:

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 可保证每行恰好为六个字符长:

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

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

文本块的连接

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

String code = """
public void print(Object o) {
System.out.println(o);
}
""" + "This is a test";
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 类中加入一个删除方法。然而,这意味着每次在运行时实例化字符串时都会涉及潜在的昂贵计算,这将降低字符串驻留(string interning)的好处。让 Java 语言强制删除前导和尾随的无关空白字符似乎是最合适的解决方案。开发者可以通过谨慎放置结束分隔符来选择不删除前导空白字符。

原始字符串字面量

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

测试

测试应使用字符串字面量来创建、驻留和操作 String 类型的实例,同时这些测试也应复制为使用文本块的形式。对于涉及行终止符和文件结尾(EOF)的极端情况,应增加负面测试。

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