跳转到内容

JEP 378:文本块

原文:https://openjdk.org/jeps/378
翻译:张欢

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

历史

文本块以JEP 355在2019年初提出,作为在JEP 326(原始字符串字面量)中试探的后续动作,该试探最初针对JDK 12,但最终被撤回且并未出现在该发行版中。JEP 355于2019年6月在JDK 13中作为预览特性。对JDK 13的反馈建议应再次预览该特性,并增加两个新的转义序列,因而后续的JEP 368用于2019年11月的JDK 14。对JDK 14的反馈表明,文本块特性已经准备好作为最终永久特性。

目标

  • 在写Java程序时,让表示跨行源代码的字符串更容易,同时避免常见情况下的转义序列。
  • 增强Java程序中由非Java语言编写的代码字符串的可读性。
  • 规定任何新构造都可以表示与字符串字面量相同的字符串集,解释相同的转义序列并以与字符串字面量相同的方式进行操作,以此来支持从字符串字面量的迁移。
  • 添加转义序列以管理显式空格与换行控制符。

非目标

  • 为任何新构造表示的字符串定义不同于java.lang.String的新引用类型,这不是目标。
  • 定义不同于+的、用于String的新运算符,这不是目标。
  • 文本块不直接支持字符串插值。将来的JEP中可能会考虑内插。同时,新的实例方法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的前身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 = """"""; // 开头分隔符后面没有行终止符
String b = """ """; // 开头分隔符后面没有行终止符
String c = """
"; // 没有结尾分隔符(代码块持续到EOF)
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)。

例如,在Windows平台(行终止符为CRLF)上编辑了在Unix平台(行终止符为LF)上创建的Java源代码,如果不进行标准化,那么内容会在每一行上都多一个字符。任何依赖LF作为行终止符的算法都可能失败,并且任何需要使用String::equals验证字符串相等的测试都将失败。

转义序列\n(LF)、\f(FF)和\r(CR)在标准化过程中不会被解释;转义处理会稍后进行。

2. 附带空格

上面展示的文本块比分成几块连接的字符串字面量更易于阅读,但显然对文本块内容的解释会包括为了缩进嵌入的字符串而添加的空格,以便与开头分隔符对齐。这是使用点的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处分割文本块的内容,生成单独行的列表。请注意,内容中任何只有LF的行都将成为列表中的空白行。
  2. 在独立行的列表中,将所有非空白行添加到确定行的集合中。(空白行——空白或完全由空格组成的行——对缩进没有可见影响。从确定行的集合中排除空白行,可以避免引发算法中的第4步。)
  3. 如果独立行列表中的最后一行(即带有结尾分隔符的一行)是空白,那么将其添加到确定行集合中。(结尾分隔符的缩进影响整个内容的缩进——这是重要尾行策略。)
  4. 计算出每行前置空格字符的数目并取得最小值,来确定整个集合的公共空白前缀。
  5. 为独立行列表中的每一个非空白行删除公共空白前缀。
  6. 为第5步中已修改的所有行都删除末尾的空格。该步骤折叠已修改列表中的全空白行,使它们为空,但不丢弃它们。
  7. 用LF作为行之间的分隔符,将第6步中已修改的所有行连接起来,构造结果字符串。如果第6步的列表中最后一行是空的,则前一行连接的LF将是字符串中最后一个字符。

转义序列\b(退格)、\t(缩进)和\s(空格)不会被算法解释;转义会稍后发生。类似地,\<行终止符>转义序列不会阻止行终止符的行拆分,因为在转义处理之前,该序列被视为两个单独的字符。

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

重要尾行策略

通常可以通过两种方式设置文本块的格式:第一,将左侧边缘放在开头分隔符的第一个”下方;第二,将结尾分隔符放在独立一行,且正好位于开头分隔符的下方。结果字符串在所有行的开头都没有空格,且不包含结尾分隔符的末尾空白行。

但是,由于末尾的空白行被视为确定行,因此将其向左移动可以减少公共空白前缀,从而减少每行开头去除的空格数量。在极端情况下,结束分隔符一直向左移动,可以将公共空格前缀减少为零,从而有效地实现了空白剥离。

例如,当结束分隔符一直向左移动时,这里没有附带的空格可以用点表示:

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

包括末尾的空白行和结尾分隔符在内,公共空白前缀为零,因此从每行的开头删除零空白。该算法因此生成(使用|表示左侧边距):

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

或者,假设结尾分隔符没有一直移到最左侧,而是移动到htmlt下面,那么它比变量声明多8个空格:

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个空格。删除末尾的空白行以留下空行,然后将其作为最后一行丢弃。也就是说,将结尾分隔符移到内容的右侧没有任何作用,并且算法再次保留了内容的基本缩进:

|<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(CR)的文本块:

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

直到将行终止符标准化为LF后,转义字符CR才会被处理。使用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 empty = "";
""";

然而,连续3个"字符需要有至少一个"被转义,以避免被当做结尾分隔符。(由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 ""\"""\"""\"""\"
""");

新的转义序列

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

第一点,转义序列\<行终止符>会显式压制换行符的插入。

例如,常见的做法是将很长的字符串字面量拆分成为较小的子字符串连接在一起,然后将结果字符串表达式表示为多行:

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

\<行终止符>转义序列时,这可以表示为:

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

由于字符字面量和传统的字符串字面量不允许嵌入换行符的简单原因,转义序列\<行终止符>仅适用于文本块。

第二点,新的转义序列\s仅转换为一个空格(\u0020)。

直到附带空格被去除之后,转义序列才会被转换,因此\s可以充当栅栏以防止末尾的空白。在本例中,每行末尾使用\s可以确保每行正好是6个字符长度:

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

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

拼接文本块

所有可以使用字符串字面量的地方都可以使用文本块。例如,文本块和字符串字面量可以互换使用:

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

假设需要修改,以便otype是可变的。使用拼接,包含末尾代码的文本块将需要从新行开始。不幸的是,如下所示,在程序中直接插入换行符会导致type和以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类包含该删除方法。但是,这意味着每次在运行时实例化字符串时,都可能需要进行昂贵的运算,这会降低字符串插入的收益。让Java语言强制删除开头和结尾位置的附带空格似乎是最合适的解决方案。开发者可以通过仔细放置结尾分隔符来选择不删除主要空格。

原始字符串字面量

对于JEP 326(原始字符串字面量),我们采用了另一种方法来解决在表示字符串时不转义换行和引号的问题,重点是字符串的原始性。现在,我们认为这种关注是错误的,因为尽管原始字符串字面量可以轻松跨越源代码的多行,但在内容中支持未转义的分隔符的代价却特别高。这限制了该功能再多行用例中的有效性,这是至关重要的功能,因为在Java程序中嵌入了多行(但不是真正的原始)代码片段的频率很高。从原始性到多行性的转变的一个很好的结果是重新关注字符串字面量、文本块和将来可能添加的相关特性之间使用一致的转义语言。

测试

使用字符串字面量进行String实例的创建、intern和操作的测试,同样应该复用于文本块。对于涉及到行终结符和EOF的用例,应该添加负面测试。

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