将文本块(Text Blocks)引入 Java 语言。文本块是一种多行字符串字面量,它避免了大多数转义序列的需要,能够以可预测的方式自动格式化字符串,并在需要时让开发者控制格式。这是 JDK 14 中的预览语言特性。

文本块由 JEP 355 在 2019 年初提出,作为对 JEP 326(原始字符串字面量)探索的后续改进。JEP 326 被撤回,并未包含在 JDK 12 中。JEP 355 于 2019 年中期作为预览特性被纳入 JDK 13。根据 JDK 13 的反馈,建议在 JDK 14 中再次预览文本块,并增加两个新的转义序列。

目标

  • 简化编写 Java 程序的任务,使开发者能够轻松地表达跨越多行源代码的字符串,同时避免常见情况下的转义序列。
  • 提高 Java 程序中表示非 Java 语言代码(如 SQL、XML、JSON 等)的字符串的可读性。
  • 支持从字符串字面量迁移,规定任何新构造都能表达与字符串字面量相同的字符串集,解释相同的转义序列,并以与字符串字面量相同的方式操作。
  • 增加用于管理显式空白和换行控制的转义序列。

非目标

  • 不打算为通过新构造表达的字符串定义一种新的引用类型(与 java.lang.String 不同)。
  • 不打算定义新的运算符(与 + 不同)来处理字符串操作数。
  • 文本块不直接支持字符串插值(String Interpolation)。插值可能会在未来的 JEP 中考虑。
  • 文本块不支持原始字符串(Raw Strings),即不对字符进行任何处理的字符串。

一、动机

在 Java 中,将 HTML、XML、SQL 或 JSON 片段嵌入字符串字面量 "..." 通常需要大量的转义和连接操作,才能使包含这些片段的代码编译通过。这些片段通常难以阅读且维护起来非常繁琐。

更广泛地说,在 Java 程序中表示短、中、长文本块的需求几乎无处不在,无论这些文本是来自其他编程语言的代码、表示标准文件的结构化文本,还是自然语言中的消息。一方面,Java 语言通过允许无边界大小和内容的字符串来满足这一需求;另一方面,它默认的设计理念是字符串应该足够小,可以在一行源代码中表示(用 " 字符包围),并且足够简单以便轻松转义。然而,这种设计理念与大量 Java 程序的实际情况相矛盾,因为这些程序中的字符串通常太长,无法舒适地放在一行中。

因此,如果有一种语言机制能够比字符串字面量更直观地表示字符串——跨越多行且无需转义字符的视觉干扰——将显著提高广泛 Java 程序的可读性和可写性。本质上,这是一种二维的文本块,而不是一维的字符序列。

然而,无法预测每个字符串在 Java 程序中的作用。仅仅因为一个字符串跨越多行源代码,并不意味着在字符串中包含换行符是理想的。程序的某一部分可能因为字符串跨越多行而更易读,但嵌入的换行符可能会改变程序另一部分的行为。因此,如果开发者能够精确控制换行符的位置,以及文本块左右两侧的空白量,将会非常有帮助。

1、HTML 示例

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

1
2
3
4
5
String html = "<html>\n" +
" <body>\n" +
" <p>Hello, world</p>\n" +
" </body>\n" +
"</html>\n";

使用“二维”文本块

1
2
3
4
5
6
7
String html = """
<html>
<body>
<p>Hello, world</p>
</body>
</html>
""";

2、SQL 示例

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

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

使用“二维”文本块

1
2
3
4
5
String query = """
SELECT `EMP_ID`, `LAST_NAME` FROM `EMPLOYEE_TB`
WHERE `CITY` = 'INDIANAPOLIS'
ORDER BY `EMP_ID`, `LAST_NAME`;
""";

二、描述

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

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

  • 开始分隔符:由三个双引号字符(""")组成,后跟零个或多个空格字符和一个行终止符。内容从开始分隔符的行终止符后的第一个字符开始。
  • 结束分隔符:由三个双引号字符组成。内容在结束分隔符的第一个双引号前的最后一个字符处结束。

与字符串字面量不同,文本块的内容可以直接包含双引号字符。在文本块中使用 \" 是允许的,但不必要也不推荐。选择三个双引号作为分隔符是为了让 " 字符可以不经转义直接出现,同时也为了在视觉上区分文本块和字符串字面量。

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

1
2
3
4
5
"""
line 1
line 2
line 3
"""

等价于以下字符串字面量:

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

或者以下字符串字面量的连接:

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

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

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

等价于以下字符串字面量:

1
"line 1\nline 2\nline 3"

文本块可以表示空字符串,尽管不推荐这样做,因为它需要两行源代码:

1
2
String empty = """
""";

以下是一些格式错误的文本块示例:

1
2
3
4
5
6
7
String a = """""";   // 开始分隔符后没有行终止符
String b = """ """; // 开始分隔符后没有行终止符
String c = """
"; // 没有结束分隔符(文本块继续到文件末尾)
String d = """
abc \ def
"""; // 未转义的反斜杠(见下文关于转义处理的说明)

1、编译时处理

文本块是一个类型为 String 的常量表达式,就像字符串字面量一样。然而,与字符串字面量不同,文本块的内容由 Java 编译器分三个步骤处理:

  • 行终止符的规范化,内容中的行终止符被转换为 LF(\u000A)。此转换的目的是在跨平台移动 Java 源代码时遵循最小意外原则。
  • 去除附带空白,为了与 Java 源代码的缩进对齐而引入的附带空白被移除。
  • 转义序列的解释,内容中的转义序列被解释。将转义序列的解释作为最后一步意味着开发者可以编写诸如 \n 的转义序列,而不会在之前的步骤中被修改或删除。

处理后的内容会像字符串字面量一样,作为 CONSTANT_String_info 条目记录在类文件的常量池中。类文件不会记录 CONSTANT_String_info 条目是来自文本块还是字符串字面量。

在运行时,文本块会被计算为 String 的实例,就像字符串字面量一样。从文本块派生的 String 实例与从字符串字面量派生的实例无法区分。由于字符串驻留机制,具有相同处理内容的两个文本块将引用相同的 String 实例,就像字符串字面量一样。

1.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)在规范化过程中不会被解释;转义处理在稍后进行。

1.2 附带空白

上面展示的文本块比其对应的字符串字面量连接更容易阅读,但对文本块内容的直观解释可能包括为嵌入字符串添加的缩进空格,以使其与开始分隔符对齐。以下是使用点(.)可视化开发者为缩进添加的空格的 HTML 示例:

1
2
3
4
5
6
7
String html = """
..............<html>
.............. <body>
.............. <p>Hello, world</p>
.............. </body>
..............</html>
..............""";

由于开始分隔符通常位于与使用文本块的语句或表达式相同的行上,因此每行开头的 14 个可视化空格实际上没有意义。将这些空格包含在内容中意味着文本块表示的字符串与字符串字面量连接表示的字符串不同。这会妨碍迁移,并成为意外的常见来源:开发者极有可能不希望这些空格出现在字符串中。此外,结束分隔符通常与内容对齐,这进一步表明这些 14 个可视化空格是无意义的。

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

1
2
3
4
5
6
7
String html = """
..............<html>...
.............. <body>
.............. <p>Hello, world</p>....
.............. </body>.
..............</html>...
..............""";

尾随空格通常是特殊的且无意义的。开发者极有可能不关心它。尾随空格字符类似于行终止符,因为它们都是源代码编辑环境的不可见产物。由于没有视觉指南指示尾随空格字符的存在,将它们包含在内容中会成为意外的常见来源,因为它会影响字符串的长度、哈希码等。

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

1
2
3
4
5
|<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(制表符)不会被算法解释。

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

1.2.1 重要尾随行策略

通常情况下,文本块的格式化有两种方式:

  • 将内容的左边缘与开始分隔符的第一个 " 对齐。
  • 将结束分隔符单独放在一行,并使其与开始分隔符完全对齐。

这样生成的字符串在每行的开头不会有空白,并且不会包含结束分隔符的尾随空行。

然而,由于尾随空行被视为确定行(determining line),将其向左移动会减少公共空白前缀(common white space prefix),从而减少从每行开头移除的空白量。在极端情况下,如果将结束分隔符完全移动到最左侧,公共空白前缀将减少为零,从而有效地取消空白移除。

如果将结束分隔符完全移动到最左侧,则没有附带空白需要用点(.)可视化:

1
2
3
4
5
6
7
String html = """
<html>
<body>
<p>Hello, world</p>
</body>
</html>
""";

包含结束分隔符的尾随空行时,公共空白前缀为零,因此每行开头的零个空白被移除。算法生成的结果如下(使用 | 可视化左边缘):

1
2
3
4
5
|              <html>
| <body>
| <p>Hello, world</p>
| </body>
| </html>

假设结束分隔符没有完全移动到最左侧,而是位于 htmlt 下方,比变量声明多出 8 个空格:

1
2
3
4
5
6
7
String html = """
<html>
<body>
<p>Hello, world</p>
</body>
</html>
""";

用点(.)可视化的空格被视为附带空白:

1
2
3
4
5
6
7
String html = """
........ <html>
........ <body>
........ <p>Hello, world</p>
........ </body>
........ </html>
........""";

包含结束分隔符的尾随空行时,公共空白前缀为 8,因此每行开头的 8 个空白被移除。算法保留了内容相对于结束分隔符的必要缩进:

1
2
3
4
5
|      <html>
| <body>
| <p>Hello, world</p>
| </body>
| </html>

假设结束分隔符略微向右移动,超出内容的位置:

1
2
3
4
5
6
7
String html = """
<html>
<body>
<p>Hello, world</p>
</body>
</html>
""";

用点(.)可视化的空格被视为附带空白:

1
2
3
4
5
6
7
String html = """
..............<html>
.............. <body>
.............. <p>Hello, world</p>
.............. </body>
..............</html>
.............. """;

公共空白前缀为 14,因此每行开头的 14 个空白被移除。尾随空行被移除,留下一个空行,由于它是最后一行,因此被丢弃。换句话说,将结束分隔符向右移动对内容没有影响,算法再次保留了内容的必要缩进:

1
2
3
4
5
|<html>
| <body>
| <p>Hello, world</p>
| </body>
|</html>

1.3 转义序列

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

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

1
2
3
4
5
6
7
String html = """
<html>\r
<body>\r
<p>Hello, world</p>\r
</body>\r
</html>\r
""";

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

1
2
3
4
5
|<html>\u000D\u000A
| <body>\u000D\u000A
| <p>Hello, world</p>\u000D\u000A
| </body>\u000D\u000A
|</html>\u000D\u000A

请注意,在文本块中自由使用 " 是合法的,即使靠近开始或结束分隔符。例如:

1
2
3
4
5
6
7
8
9
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."
""";

然而,三个 " 字符的序列需要至少转义一个 ",以避免模仿结束分隔符。如果连续出现 N 个 " 字符时,至少需要转义 Math.floorDiv(n,3) 个。另外,如果 " 紧邻结束分隔符,也需要转义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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 ""\"""\"""\"""\"
""");

2、新转义序列

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

\<line-terminator> 显式抑制换行符的插入。

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

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

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

1
2
3
4
5
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 保证每行正好为六个字符长:

1
2
3
4
5
String colors = """
red \s
green\s
blue \s
""";

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

3、文本块的连接

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

1
2
3
4
5
String code = "public void print(Object o) {" +
"""
System.out.println(Objects.toString(o));
}
""";

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

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

假设需要更改以使 o 的类型来自变量。使用连接时,包含尾随代码的文本块需要在新行上开始。不幸的是,直接在程序中插入换行符会导致类型和文本开头 o 之间有很长的空白:

1
2
3
4
5
6
String code = """
public void print(""" + type + """
o) {
System.out.println(Objects.toString(o));
}
""";

可以手动移除空白,但这会损害引用代码的可读性:

1
2
3
4
5
6
String code = """
public void print(""" + type + """
o) {
System.out.println(Objects.toString(o));
}
""";

更干净的替代方法是使用 String::replaceString::format,如下所示:

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

另一种替代方法是引入一个新的实例方法 String::formatted,可以如下使用:

1
2
3
4
5
String source = """
public void print(%s object) {
System.out.println(Objects.toString(object));
}
""".formatted(type);

4、新增方法

以下方法将添加到 String 类中以支持文本块:

  • **String::stripIndent()**,用于去除文本块内容中的附带空白。
  • **String::translateEscapes()**,用于翻译转义序列。
  • **String::formatted(Object... args)**,简化文本块中的值替换。

三、替代方案

1、不做任何改动

Java 已经在其字符串字面量需要转义换行符的情况下繁荣发展了超过 20 年。集成开发环境(IDE)通过支持自动格式化和连接跨越多行源代码的字符串,减轻了维护负担。String 类也逐步发展,包含了简化长字符串处理和格式化的方法,例如将字符串表示为行流的方法。然而,字符串是 Java 语言的基础部分,字符串字面量的不足对大量开发者来说是显而易见的。其他 JVM 语言在表示长字符串和复杂字符串方面也取得了进展。因此,多行字符串字面量一直是 Java 最受欢迎的功能之一。引入一个低到中等复杂度的多行字符串构造将带来高回报。

2、允许字符串字面量跨越多行

可以通过允许现有字符串字面量中包含行终止符来简单地引入多行字符串字面量。然而,这并不能解决转义 " 字符的痛点。由于代码片段的频繁出现,\"\n 之后最常出现的转义序列。避免在字符串字面量中转义 " 的唯一方法是提供一种替代的字符串字面量分隔符方案。分隔符在 JEP 326(原始字符串字面量) 中被广泛讨论,其经验教训被用于文本块的设计。因此,破坏字符串字面量的稳定性是不明智的。

3、采用其他语言的多行字符串字面量

根据 Brian Goetz 的说法:

许多人建议 Java 应该采用 Swift 或 Rust 的多行字符串字面量。然而,“直接照搬语言 X 的做法”本质上是不负责任的;几乎每种语言的每个特性都受到该语言其他特性的制约。相反,我们应该从其他语言的做法中学习,评估它们所做出的权衡(显式和隐式),并思考如何将这些经验应用到我们现有语言的约束和我们社区的用户期望中。

JEP 326(原始字符串字面量) 中,我们调查了许多现代编程语言及其对多行字符串字面量的支持。这些调查的结果影响了当前的提案,例如选择三个 " 字符作为分隔符(尽管还有其他原因),并认识到自动缩进管理的必要性。

4、不移除附带空白

如果 Java 引入多行字符串字面量但不支持自动移除附带空白,那么许多开发者会编写自己的方法来移除空白,或者要求 String 类包含一个移除方法。然而,这意味着每次在运行时实例化字符串时都可能进行潜在的高成本计算,这将减少字符串驻留(interning)的好处。让 Java 语言强制移除附带空白(包括前导和尾随空白)似乎是最合适的解决方案。开发者可以通过仔细放置结束分隔符来选择退出前导空白的移除。

5、原始字符串字面量

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

四、其他

1、使用文本块的 SQL 在 JdbcTemplate 中是否可以正常工作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Repository  
@RequiredArgsConstructor
public class UserDAO {

private final JdbcTemplate jdbcTemplate;

public UserDO getUserById(Long id) {

String sql = """
select * from z2huo_user
where id = ?;
""";

return jdbcTemplate.queryForObject(sql, BeanPropertyRowMapper.newInstance(UserDO.class), id);
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Slf4j  
@SpringBootTest(classes = JdbcApplication.class)
class UserDAOTest {

@Autowired
private UserDAO userDAO;

@Test
void getUserById() {
long userId = 1;
UserDO user = userDAO.getUserById(userId);
Assertions.assertNotNull(user);
log.info("user is {}", user);
log.info("user name is {}", user.getUserName());
}
}

相关链接

JEP 368: Text Blocks (Second Preview)

docs.oracle.com

JEP 326(原始字符串字面量)

OB tags

#JDK新特性 #Java