spotlight
重要要点
- Java SE 13(2019年9月)引入了文本块作为预览功能,旨在减轻Java中声明和使用多行字符串文字的痛苦。 随后在第二次预览中对其进行了改进,并进行了少许更改,并计划成为Java SE 15(2020年9月)中Java语言的永久功能。
- Java程序中的字符串文字不限于诸如“是”和“否”之类的短字符串。 它们通常对应于结构化语言(例如HTML,SQL,XML,JSON甚至Java)中的整个“程序”。
- 文本块是字符串文字,可以包含多行文本,并使用三引号(“”“)作为其开始和结束定界符。
- 文本块可以被认为是嵌入Java程序中的二维文本块。
- 能够保留该嵌入式程序的二维结构,而不必将其与转义符和其他语言干扰混为一谈,这样就不容易出错,并且程序可读性更高。
预览功能
鉴于Java平台的全球影响力和高度兼容性承诺,语言功能设计错误的代价非常高。 在语言功能不佳的情况下,对兼容性的承诺不仅意味着很难或很难更改或更改功能,而且现有功能还限制了将来的功能-当今闪亮的新功能是明天的兼容性约束。
语言功能的最终证明是实际使用。 实际上已在真实代码库上试用过它们的开发人员的反馈对于确保该功能按预期工作至关重要。 当Java具有多年的发布周期时,就有足够的时间进行试验和反馈。 为了确保在较新的快速发布节奏下有足够的时间进行实验和反馈,新的语言功能将经历一轮或多轮预览 ,它们是平台的一部分,但必须单独选择并且尚未永久生效- -因此,如果需要根据开发人员的反馈对它们进行调整,则可以在不破坏关键任务代码的情况下做到这一点。
在纽约QCon的Java Futures中,Java语言架构师Brian Goetz带我们绕过Java语言的一些近期和未来功能。 在本文中,他深入探讨了文本块。
Java SE 13(2019年9月)引入了文本块作为预览功能 ,旨在减轻Java中声明和使用多行字符串文字的痛苦。
随后在第二次预览中对其进行了改进,并进行了少许更改,并计划成为Java SE 15中的Java语言的永久功能 (2020年9月)。
文本块是可以包含多行文本的字符串文字。 文本块如下所示:
String address = """
25 Main Street
Anytown, USA, 12345
""";
在这个简单的示例中,变量address
将包含两行字符串,每行之后都有行终止符。 没有文本块,我们将不得不编写:
String address = "25 Main Street\n" +
"Anytown, USA, 12345\n";
or
String address = "25 Main Street\nAnytown, USA, 12345\n";
每个Java开发人员都知道,编写这些替代方法很麻烦。 但是,更重要的是,它们也更容易出错 (很容易忘记\n
而不会意识到),并且更难阅读 (因为语言语法与字符串的内容混合在一起)。 由于文本块通常没有转义符和其他语言中断,因此它使语言不受干扰,因此读者可以更轻松地查看字符串的内容。
字符串文字中最常见的转义字符是换行符( \n
),并且文本块允许直接表示多行字符串,从而消除了对这些字符的需要。 在换行符之后,下一个最常转义的字符是双引号( \"
),因为与字符串文字定界符冲突,因此必须转义。文本块也消除了对这些字符的需要,因为单引号与三重字符不冲突。 -quote文本块定界符。
为什么是有趣的名字?
有人可能会认为此功能将被称为“多行字符串文字”(也许很多人会称之为“多行字符串文字”。)但是,我们选择了一个不同的名称( 文本块 )来强调以下事实:文本块不仅是不相关的行集合,而且更好地被认为是嵌入Java程序中的二维文本块 。 为了说明“二维”的含义,我们来看一个结构化的示例,其中我们的文本块是XML的代码段。 (相同的注意事项适用于作为其他文字(例如SQL,HTML,JSON甚至Java)的“程序”片段的字符串,这些字符串作为文字直接嵌入Java程序中。)
void m() {
System.out.println("""
<person>
<firstName>Bob</firstName>
<lastName>Jones</lastName>
</person>
""");
}
作者希望这能印出什么? 虽然我们看不懂他们的想法,但似乎不太可能是XML块应该缩进21个空格; 这21个空格很有可能仅用于将文本块与周围的代码对齐。 另一方面,几乎可以肯定,作者的意图是输出的第二行应比第一行缩进更多的四个空格。 此外,即使作者确实确实希望缩进21个空格,但在修改程序并且更改周围代码的缩进时会发生什么? 我们不希望仅仅因为重新格式化了源代码而改变了输出的缩进方式-我们也不希望文本块相对于周围的代码看起来“不合适”,因为它不在一行中对齐明智的方式。
从这个例子中,我们可以看到,嵌入到我们程序源中的多行文本块的块的自然缩进既来自块行之间的所需相对缩进,又来自于块与行之间的相对缩进。周围的代码。 我们希望字符串文字与我们的代码对齐(因为如果它们不匹配,它们看起来会不合适),并且我们希望字符串文字的行反映行之间的相对缩进,但是这两个缩进源-我们可以将其称为偶然的和必不可少的 -必须混入程序的源代码表示中。 (传统字符串文字不存在此问题,因为它们不能跨行,因此没有诱惑在文字内部放置额外的前导空格以使内容对齐。)
解决此问题的一种方法是使用库方法,我们可以将其应用于多行字符串文字,例如Kotlin的trimIndent方法,而Java确实提供了这样的方法: String::stripIndent 。 但是因为这是一个普遍的问题,所以Java走得更远,在编译时自动剥离附带的缩进。
为了弄清偶然的和基本的缩进,我们可以想象在包含整个片段的XML片段周围绘制最小的矩形,并将该矩形的内容视为一个二维文本块。 该“魔术矩形”是文本块的内容,反映了文本行之间的相对缩进,但忽略了任何缩进,这些缩进是程序缩进的产物。
这种“魔术矩形”的类比可能有助于激发文本块的工作方式,但是细节有些微妙,因为我们可能希望更好地控制哪些凹痕被认为是偶然的还是必要的。 可以使用尾部定界符相对于内容的位置来调整偶然压痕与基本压痕的平衡。
细节
文本块使用三引号( """
)作为其开始和结束定界符,带有开始定界符的行的其余部分必须为空白。文本块的内容从下一行开始,一直持续到关闭定界符。块内容的编译时处理分为三个阶段:
- 行终止符已标准化。 所有行终止符均替换为LF(
\u000A
)字符。 这可以防止文本块的值不受上一次在其上编辑代码的平台的换行约定的影响。 (Windows使用CR
+LF
终止行; Unix系统仅使用LF
,甚至还有其他使用的方案 。)
- 计算一组确定行 ,这些确定行是上一步结果的所有非空白行,以及最后一行(包含结束定界符的行),即使它们为空;
- 计算所有确定行的公共空格前缀 ;
- 从每个确定行中删除公共空格前缀。
- 内容中的转义序列被解释。 文本块与字符串和字符文字使用相同的转义序列集。 最后执行这些意味着
\n
,\t
,\s
和\<eol>
类的转义符不会影响空白处理。 (已将两个新的转义序列作为JEP 368的一部分添加到集合中;\s
用于显式空间,\<eol>
作为连续指示符。)
在我们的XML示例中,所有空格将从第一行和最后一行删除,中间的两行将缩进四个空格,因为在此示例中有五个确定行-四个行包含XML代码和该行包含结束定界符-并且所有行都至少缩进了与内容的第一行相同的空格。 通常,这种缩进是所期望的,但是有时我们可能不想剥离所有前导缩进。 例如,如果要使整个块缩进四个空格,可以通过将闭合定界符向左移动四个空格来实现:
void m() {
System.out.println("""
<person>
<firstName>Bob</firstName>
<lastName>Jones</lastName>
</person>
""");
}
由于最后一行也是确定行,因此公共空白前缀现在是块最后一行中结束定界符之前的空白量,这是从每行中删除的量,整个块都缩进了四。 我们还可以通过实例方法String::indent来以编程方式管理缩进,该方法采用多行字符串(无论它是否来自文本块),并以固定数量的空格缩进每行:
void m() {
System.out.println("""
<person>
<firstName>Bob</firstName>
<lastName>Jones</lastName>
</person>
""".indent(4));
}
在极端情况下,如果不需要空格剥离,则可以将结束定界符一路移回左边界:
void m() {
System.out.println("""
<person>
<firstName>Bob</firstName>
<lastName>Jones</lastName>
</person>
""");
}
或者,我们可以通过将整个文本块移回空白处来达到相同的效果:
void m() {
System.out.println("""
<person>
<firstName>Bob</firstName>
<lastName>Jones</lastName>
</person>
""");
}
这些规则起初听起来可能有些复杂,但是选择这些规则是为了平衡各种相互竞争的问题,这些问题是希望能够相对于周围程序缩进文本块,而又不产生可变数量的附带前导空白,并且提供了一种简单的方法如果不需要默认算法,则可以调整或选择退出空白剥离。
嵌入式表达式
Java的字符串文字不像某些其他语言那样支持表达式的插值。 文本块也不一样。 (就将来我们可能会考虑的特性而言,它并不是特定于文本块,而是同样适用于字符串文字。)从历史上看,参数化的字符串表达式是使用普通的字符串连接( +
)构建的; 在Java 5中,添加了String::format
以支持“ printf”样式的字符串格式。
由于围绕空白进行了全局分析,因此在将文本块与字符串连接组合在一起时正确获取缩进可能很棘手。 但是,文本块的计算结果为普通字符串,因此我们仍然可以使用String::format
来参数化字符串表达式。 另外,我们可以使用新的String::formatted
方法,它是String::format
的实例版本:
String person = """
<person>
<firstName>%s</firstName>
<lastName>%s</lastName>
</person>
""".formatted(first, last));
(不幸的是,该方法也不能称为format
因为我们不能重载具有相同名称和参数列表的static和instance方法。
先例与历史
从某种意义上说,字符串字面量是一种“琐碎”的功能,但它们的使用频率很高,以至于可能会产生一些小麻烦。 因此,缺乏多行字符串一直是近年来有关Java的最普遍的抱怨,并且许多其他语言具有多种形式的字符串文字来支持不同的用例也就不足为奇了。
令人惊讶的是,以流行语言表达这种功能的方式多种多样。 说“我们想要多行字符串”很容易,但是当我们调查其他语言时,我们发现语法和目标方面的方法令人惊讶地多样化。 (当然,开发人员对“正确”方法的看法也相当广泛。)虽然没有两种语言是相同的,但对于大多数语言而言,大多数功能都是通用的(例如for
循环)通常,有几种语言可供选择; 在15种语言中找到功能的15种不同解释是不寻常的,但这正是我们在多行和原始字符串文字中发现的。
下表显示(某些)各种语言的字符串文字的选项。 在每一个中, ...
被视为字符串文字的内容,对于或不对转义序列和嵌入式插值进行处理, xxx
表示用户选择的随机数,该随机数保证不与字符串的内容冲突,和##
表示可变数量的#
符号(可以为零)。
语言 | 句法 | 笔记 |
重击 | '...' | [跨度] |
重击 | $'...' | [esc] [span] |
重击 | “ ...” | [esc] [interp] [span] |
C | “ ...” | [退出] |
C ++ | “ ...” | [退出] |
C ++ | R“ xxx(...)xxx” | [span] [delim] |
C# | “ ...” | [退出] |
C# | $“ ...” | [esc] [interp] |
C# | @“ ...” | |
镖 | '...' | [esc] [interp] |
镖 | “ ...” | [esc] [interp] |
镖 | '''...''' | [esc] [interp] [span] |
镖 | “”“ ...”“” | [esc] [interp] [span] |
镖 | r'...' | [字首] |
走 | “ ...” | [退出] |
走 |
| [跨度] |
Groovy | '...' | [退出] |
Groovy | “ ...” | [esc] [interp] |
Groovy | '''...''' | [esc] [span] |
Groovy | “”“ ...”“” | [esc] [interp] [span] |
哈斯克尔 | “ ...” | [退出] |
Java | “ ...” | [退出] |
Java脚本 | '...' | [esc] [span] |
Java脚本 | “ ...” | [esc] [span] |
Java脚本 |
| [esc] [interp] [span] |
Kotlin | “ ...” | [esc] [interp] |
Kotlin | “”“ ...”“” | [插入] [跨度] |
Perl | '...' | |
Perl | “ ...” | [esc] [interp] |
Perl | <<'xxx' | [这里] |
Perl | <<“ xxx” | [esc] [interp] [此处] |
Perl | q {...} | [跨度] |
Perl | qq {...} | [esc] [interp] [span] |
Python | '...' | [退出] |
Python | “ ...” | [退出] |
Python | '''...''' | [esc] [span] |
Python | “”“ ...”“” | [esc] [span] |
Python | r'...' | [esc] [前缀] |
Python | F'...' | [esc] [interp] [prefix] |
Ruby | '...' | [跨度] |
Ruby | “ ...” | [esc] [interp] [span] |
Ruby | %q {...} | [span] [delim] |
Ruby | %Q {...} | [esc] [interp] [span] [delim] |
Ruby | <<-xxx | [这里] [插入] |
Ruby | <<〜xxx | [这里] [插入] [条] |
锈 | “ ...” | [esc] [span] |
锈 | r ##“ ...” ## | [span] [delim] |
斯卡拉 | “ ...” | [退出] |
斯卡拉 | “”“ ...”“” | [跨度] |
斯卡拉 | s“ ...” | [esc] [interp] |
斯卡拉 | F”...” | [esc] [interp] |
斯卡拉 | 生的”...” | [插入] |
Swift | ##“ ...” ## | [esc] [interp] [delim] |
Swift | ##“”“ ...”“” ## | [esc] [interp] [delim] [span] |
传说:
- esc 。 某种程度的转义序列处理,其中转义通常是从C样式派生的(例如
\n
); - 插入 。 某些支持变量或任意表达式的内插。
- 跨度 多行字符串可以通过简单地跨越多条源代码行来表示。
- 在这里 。 “ here-doc”,其中以下各行,直到仅包含用户选择的随机数的行,都被视为字符串文字的主体。
- 前缀 。 前缀形式对字符串文字的所有其他形式均有效,为简洁起见,已将其省略。
- 德林 分隔符在某种程度上是可自定义的,无论是通过添加随机数(C ++),不同数量的
#
字符(Rust,Swift)还是将花括号替换为其他匹配的括号(Ruby)。 - 脱衣舞 。 支持某种程度的偶然压痕剥离。
尽管此表为字符串文字方法的多样性提供了一种风味,但它实际上只是表面上的东西,因为语言解释字符串文字的方式上的细微差别太多,以至于无法以这种简单形式捕获。 虽然大多数语言使用受C启发的转义语言,但它们在支持的转义,是否以及如何支持unicode转义(例如\unnnn
)以及不支持完整转义语言的形式是否仍支持某些受限方面有所不同。转义分隔符的形式(例如使用两个引号作为嵌入式引号而不是字符串的结尾。)为了简洁起见,该表还省略了许多其他形式(例如C ++中的各种前缀来控制字符编码)。
跨语言最明显的变化轴是定界符的选择,以及不同定界符如何表达不同形式的字符串文字(带或不带转义符,单行或多行,带或不带插值,字符编码的选择等)。但是阅读在这两行之间,我们可以看到这些语法选择通常如何反映语言设计的哲学差异-如何平衡各种目标,例如简单性,表现力和用户便利性。
毫不奇怪,脚本语言(bash,Perl,Ruby,Python)已将“用户选择”作为第一要务,其文字形式多种多样,它们可能以非正交的方式(通常是表示同一件事的多种方式)变化。 )但是,总的来说,语言遍及整个地图,它们如何鼓励用户考虑字符串文字,它们公开了多少形式以及这些形式如何正交。 我们还看到了关于跨越多行的字符串的几种哲学。 有些(例如Javascript和Go)将行终止符只是另一个字符,允许所有形式的字符串文字跨越多行,有些(例如C ++)将它们视为“原始”字符串的特例,而另一些(例如Kotlin) )将字符串分为“简单”和“复杂”,然后将多行字符串放入“复杂”存储桶中,其他字符串则提供了太多选择,甚至不符合这些简单分类。 同样,它们对“原始字符串”的解释也有所不同。 真正的原始性需要某种形式的用户可控制的定界符(如C ++,Swift和Rust所具有的),尽管其他人将其字符串称为“原始”,但仍保留某种形式的转义符来作为其结束(固定)定界符。
尽管有很多方法和意见,但从平衡原则性设计与表达能力的角度来看,该调查显然有一个“赢家”: Swift 。 它通过单一灵活的机制(在单行和多行变体中)设法支持转义,内插和真正的原始性。不足为奇的是,该组中的最新语言拥有最干净的故事,因为它有事后见识,可以从他人的成功和错误中学习。 (这里的主要创新是转义分隔符与字符串定界符在步调上有所不同,避免了在“煮熟”和“原始”模式之间进行选择的需要,同时仍在所有形式的字符串文字中共享转义语言,这种方法值得Java虽然由于现有的语言限制而无法全面采用Swift方法,但Java方法却从Swift社区所做的出色工作中获得了尽可能多的灵感-并留有余地将来需要更多。
几乎要走的路
文本块不是此功能的第一次迭代; 第一次迭代是原始字符串文字 。 像Rust的原始字符串一样,它使用大小可变的定界符(任意数量的反引号字符),根本不解释内容。 该提案在经过全面设计和原型设计后被撤回,因为它被认为虽然听起来足够合理,但感觉太“钉在了一边”-与传统的字符串文字几乎没有什么共同之处,因此,如果我们愿意为了将来扩展功能,没有办法将它们一起扩展。 (由于快速发布节奏,这仅将功能延迟了六个月,并导致了更好的功能。)
对JEP 326方法的一个主要反对意见是,原始字符串在各个方面都与传统的字符串文字有所不同。 不同的定界符,可变定界符与固定定界符,单行与多行,转义与非转义。 总是有人会想要一些不同的选择组合,并且会要求更多不同的形式,这使我们走上了Bash采取的道路。 最重要的是,它并没有采取任何措施来解决“偶然缩进”问题,这显然将成为Java程序中脆弱的原因。 从这种经验中学到的是,文本块与传统字符串文字(定界符语法,转义语言)的共享更多,仅在一个关键方面有所不同-字符串是一维字符序列还是二维文本块。
风格指导
Oracle Java团队的Jim Laskey和Stuart Marks发布了一份程序员指南,概述了文本块的详细信息和样式建议。
使用文本块可以提高代码的清晰度。 串联,转义换行符和转义引号定界符使字符串文字的内容变得模糊; 文本块会“移开”,因此内容更加明显,但从语法上讲,它们比传统的字符串文字要重。 在福利支付额外费用的地方使用它们; 如果字符串适合单行且没有转义的换行符,则最好保留传统的字符串文字。
避免在复杂表达式中插入行内文本块。 尽管文本块是字符串值的表达式,因此可以在需要字符串的任何地方使用,但将文本块嵌套在复杂表达式中并不总是最好的方法。 有时最好将其拉出一个单独的变量。 在下面的示例中,文本块在阅读时破坏了代码流,迫使读者在思维上切换齿轮:
String poem = new String(Files.readAllBytes(Paths.get("jabberwocky.txt")));
String middleVerses = Pattern.compile("\\n\\n")
.splitAsStream(poem)
.match(verse -> !"""
’Twas brillig, and the slithy toves
Did gyre and gimble in the wabe;
All mimsy were the borogoves,
And the mome raths outgrabe.
""".equals(verse))
.collect(Collectors.joining("\n\n"));
如果将文本块放入其自己的变量中,则读者可以更轻松地跟踪计算流程:
String firstLastVerse = """
’Twas brillig, and the slithy toves
Did gyre and gimble in the wabe;
All mimsy were the borogoves,
And the mome raths outgrabe.
""";
String poem = new String(Files.readAllBytes(Paths.get("jabberwocky.txt")));
String middleVerses = Pattern.compile("\\n\\n")
.splitAsStream(poem)
.match(verse -> !firstLastVerse.equals(verse))
.collect(Collectors.joining("\n\n"));
避免在文本块的缩进中混用空格和制表符。 用于剥离偶然压痕的算法会计算一个公共的空格前缀,因此,如果使用空格和制表符的组合一致地压痕行,则该算法仍然可以使用。 但是,这显然很脆弱且容易出错,因此最好避免将它们混合使用-之一使用。
将文本块与相邻的Java代码对齐。 由于偶然的空格会自动剥离,因此我们应利用此优势使代码更易于阅读。 虽然我们可能很想写:
void printPoem() {
String poem = """
’Twas brillig, and the slithy toves
Did gyre and gimble in the wabe;
All mimsy were the borogoves,
And the mome raths outgrabe.
""";
System.out.print(poem);
因为我们不需要字符串中的任何前导缩进,所以大多数时候我们应该这样写:
void printPoem() {
String poem = """
’Twas brillig, and the slithy toves
Did gyre and gimble in the wabe;
All mimsy were the borogoves,
And the mome raths outgrabe.
""";
System.out.print(poem);
}
因为这会给读者带来较少的认知负担。
不要觉得必须将文本与开头的定界符对齐。 我们可以选择使用开始定界符将文本块内容对齐:
String poem = """
’Twas brillig, and the slithy toves
Did gyre and gimble in the wabe;
All mimsy were the borogoves,
And the mome raths outgrabe.
""";
这看起来似乎很吸引人,但是如果行很长或分隔符从左边缘开始很远,则可能会很麻烦,因为现在文本将一直粘贴到右边缘。 但是这种形式的缩进不是必需的。 我们可以使用任何连续缩进,只要我们始终如一:
String poem = """
’Twas brillig, and the slithy toves
Did gyre and gimble in the wabe;
All mimsy were the borogoves,
And the mome raths outgrabe.
""";
当文本块包含嵌入式三引号时,仅转义第一引号。 尽管可以转义每个引号,但这不是必需的,并且不必要地影响可读性; 仅转义第一引号是必需的:
String code = """
String source = \"""
String message = "Hello, World!";
System.out.println(message);
\""";
""";
考虑用\分隔很长的行。 与文本块一起,我们得到两个新的转义序列, \s
(用于文字空间)和\<newline>
(连续行指示符。)如果我们的文字具有很长的行,则可以使用\<newline>
来放置源代码中的换行符,但在字符串的编译时转义处理期间将其删除。
结语
Java程序中的字符串文字不限于诸如"yes"
和"no"
类的短字符串; 它们通常对应于结构化语言(例如HTML,SQL,XML,JSON甚至Java)中的整个“程序”。 能够保留该嵌入式程序的二维结构,而不必将其与转义符和其他语言干扰混为一谈,这样就不容易出错,并且程序可读性更高。