原始字符串字面量

注意

本文是特性规范。 该规范充当该功能的设计文档。 它包括建议的规范更改,以及功能设计和开发过程中所需的信息。 这些文章将发布,直到建议的规范更改最终确定并合并到当前的 ECMA 规范中。

功能规范与已完成的实现之间可能存在一些差异。 这些差异记录在相关的语言设计会议(LDM)记录中。

可以在 规范一文中详细了解将功能规范采用 C# 语言标准的过程。

支持者问题:https://github.com/dotnet/csharplang/issues/8647

总结

允许一种新的字符串字面量形式,它以最少三个 """ 字符(但没有最大值)开始,后面可选择跟一个 new_line、字符串的内容,然后以与字面量形式开始时相同数量的引号结束。 例如:

var xml = """
          <element attr="content"/>
          """;

由于嵌套内容本身可能想要使用 """ 因此起始/结束分隔符可能更长,如下所示:

var xml = """"
          Ok to use """ here
          """";

为了使文本易于阅读,并允许开发人员在代码中使用他们喜欢的缩进,这些字符串文本在生成最终的文本值时会自然去除最后一行的指定缩进。 例如,一个字面量形式为:

var xml = """
          <element attr="content">
            <body>
            </body>
          </element>
          """;

将包含以下内容:

<element attr="content">
  <body>
  </body>
</element>

这允许代码看起来自然,同时仍生成所需的文本,并避免运行时成本(如果这需要使用专用字符串操作例程)。

如果不需要缩进行为,也可以像这样将其轻松禁用:

var xml = """
          <element attr="content">
            <body>
            </body>
          </element>
""";

还支持单行表单。 它以最少三个 """ 字符开头(但没有最大值),后接字符串内容(不能包含任何 new_line 字符),然后以与字面量开头相同数量的引号结束。 例如:

var xml = """<summary><element attr="content"/></summary>""";

还支持插值原始字符串。 在这种情况下,字符串指定了开始插值所需的括号数(由字面量开头的美元符号数决定)。 任何括号少于此数的括号序列都被视为内容。 例如:

var json = $$"""
             {
                "summary": "text",
                "length" : {{value.Length}},
             };
             """;

动机

C# 缺乏创建简单字符串字面量的通用方法,而这些字符串字面量实际上可以包含任何任意文本。 目前,所有 C# 字符串字面量形式都需要某种形式的转义,以防内容使用了某些特殊字符(如果使用了分隔符,则始终需要)。 这样就不会轻易出现包含其他语言的字面量(例如 XML、HTML 或 JSON 字面量)。

目前在 C# 中形成这些字面量的所有方法都会迫使用户手动转义这些内容。 此时的编辑工作可能会非常烦人,因为无法避免转义,必须随时处理内容中出现的转义。 这对正则表达式来说尤其麻烦,特别是当它们包含引号或反斜线时。 即使是逐字 (@"") 字符串,引号本身也必须转义,从而导致 C# 和正则表达式的混合穿插。 {} 在内插($"")字符串中同样令人感到沮丧。

问题的关键是,我们所有字符串都有一个固定的开始/结束分隔符。 只要是这种情况,我们就必须始终采用转义机制,因为字符串内容可能需要在其内容中指定该结束分隔符。 这尤其有问题,因为分隔符 " 在多种语言中非常常见。

为了解决此问题,此建议允许灵活的开始和结束分隔符,以便始终可以采用不会与字符串内容冲突的方式进行。

目标

  1. 提供一种机制,允许用户提供所有字符串值,而无需任何转义序列。 由于所有字符串都必须可表示,而无需转义序列,因此用户必须始终能够指定保证不与任何文本内容冲突的分隔符。
  2. 以同样的方式支持插值。 如上所述,因为 所有 字符串都必须在不使用转义字符的情况下表示,所以用户必须始终能够指定一个可以确保不与任何文本内容冲突的 interpolation 分隔符。 重要的是,使用我们的插值分隔符({})的语言应该能提供良好的体验,使用起来不会感到费力。
  3. 多行字符串字面量在代码中应看起来令人愉悦,不应使编译单元内的缩进看起来奇怪。 重要的是,如果字面值本身没有缩进,就不应该强制占据文件的第一列,因为这样会破坏代码的流畅性,看起来也会与周围的其他代码不一致。
    • 这种行为应易于重写,同时保持字面量清晰易读。
  4. 对于本身不包含 new_line 或以引号(")字符开头或结尾的所有字符串,应该可以在单行上表示字符串文本本身。
    • 我们还可以通过额外的复杂性将其细化为这样的表述:对于所有本身不包含 new_line(但可以以引号 " 字符开始或结束)的字符串,应该可以在一行中表示字符串字面量本身。 有关详细信息,请参阅 Drawbacks 部分中的扩展建议。

详细设计(非插值情况)

我们将添加一个新的 string_literal 生产,其形式如下:

string_literal
    : regular_string_literal
    | verbatim_string_literal
    | raw_string_literal
    ;

raw_string_literal
    : single_line_raw_string_literal
    | multi_line_raw_string_literal
    ;

raw_string_literal_delimiter
    : """
    | """"
    | """""
    | etc.
    ;

raw_content
    : not_new_line+
    ;

single_line_raw_string_literal
    : raw_string_literal_delimiter raw_content raw_string_literal_delimiter
    ;

multi_line_raw_string_literal
    : raw_string_literal_delimiter whitespace* new_line (raw_content | new_line)* new_line whitespace* raw_string_literal_delimiter
    ;

not_new_line
    : <any unicode character that is not new_line>
    ;

raw_string_literal 的结束分隔符必须与起始分隔符匹配。 因此,如果起始分隔符是 """"",则结束分隔符也必须是 """""

应将上述 raw_string_literal 语法解释为:

  1. 它从至少三个引用开始(但没有引用数量的上限)。
  2. 然后在起始引号的同一行继续输入内容。 同一行上的这些内容可以是空白的,也可以是非空白的。 “blank”是“完全空白”的同义词。
  3. 如果同一行上的内容不为空,则不能再继续添加其他内容。 换句话说,要求在同一行以相同数量的引号结束字面量。
  4. 如果同一行的内容为空白,则可以用 new_line 和一定数量的后续内容行和 new_line 继续字面量表达。
    • 内容行是除 new_line以外的任何文本。
    • 然后以 new_line 某个数量(可能是零)的 whitespace 和与字面量开头相同数量的引号结束。

原始字符串文本值

起始和结束 raw_string_literal_delimiter 之间的部分按以下方式构成 raw_string_literal 的值:

  • single_line_raw_string_literal 的情况下,字面量的值将正好是起始和终止 raw_string_literal_delimiter 之间的内容。
  • multi_line_raw_string_literal 的情况下,初始 whitespace* new_line 和最终 new_line whitespace* 不是字符串值的一部分。 不过,raw_string_literal_delimiter 终端之前的最后 whitespace* 部分被视为“缩进空格”,会影响其他行的解释。
  • 若要获取最终值,需要遍历 (raw_content | new_line)* 序列,并执行以下操作:
    • 如果是 new_line,则会将 new_line 的内容添加到最终字符串值中。
    • 如果它不是“空白”raw_content(即 not_new_line+ 包含非whitespace 字符):
      • “缩进空格”必须是 raw_content 的前缀。 否则为错误。
      • 会从 raw_content 的开头删除 “缩进空格”,并将剩余部分添加到最终字符串值中。
    • 如果是“空白”raw_content(即 not_new_line+ 完全是 whitespace):
      • “缩进空格”必须是 raw_content 的前缀,或者 raw_content 必须是“缩进空格”的前缀。 否则为错误。
      • 会从 raw_content 的开头删除尽可能多的“缩进空格”,并将剩余部分添加到最终字符串值中。

澄清:

  1. single_line_raw_string_literal 无法表示具有 new_line 值的字符串。 single_line_raw_string_literal 不会参与“缩进空格”剪裁。 其值始终是起始分隔符和结束分隔符之间的确切字符。

  2. 因为 multi_line_raw_string_literal 忽略了最后一个内容行的最终 new_line,所以以下字符串表示没有起始的 new_line,也没有终止的 new_line

var v1 = """
         This is the entire content of the string.
         """;

这样就保持了起始 new_line 被忽略的对称性,同时也提供了一种统一的方法,确保“缩进空格”可以随时调整。 要使用终端 new_line 表示字符串,则必须额外提供一行,如图所示:

var v1 = """
         This string ends with a new line.

         """;
  1. single_line_raw_string_literal 不能表示以引号(")开头或结尾的字符串值,不过在这个建议的拓展中,第 Drawbacks 部分展示了如何支持这一点。

  2. multi_line_raw_string_literalwhitespace* new_line 开头,紧接首字母 raw_string_literal_delimiter 之后。 完全忽略分隔符之后的此内容,在确定字符串的值时不会以任何方式使用。 这样就有了一种机制,可以指定内容以 " 字符本身开头的 raw_string_literal 字符。 例如:

var v1 = """
         "The content of this string starts with a quote
         """;
  1. raw_string_literal 还可以表示以引号结尾的内容(")。 由于终止分隔符必须在自己的行上,因此支持这种做法。 例如:
var v1 = """
         "The content of this string starts and ends with a quote"
         """;
var v1 = """
         ""The content of this string starts and ends with two quotes""
         """;
  1. “空白”raw_content 必须是”缩进空格 “的前缀,或者”缩进空格“必须是“空白”的前缀,这样的要求有助于确保不会出现混合空白的混乱情况,尤其是在不清楚该行应如何处理的情况下。 例如,以下情况是非法的:
var v1 = """
         Start
<tab>
         End
         """;
  1. 这里的“缩进空格”是九个空格字符,但“空白”raw_content 的开头并没有这样的前缀。 至于如何处理 <tab> 行,目前还没有明确的答案。 它应该被忽略吗? 它应该与 .........<tab>相同吗? 因此,将其定为非法似乎是避免混淆的最明晰方法。

  2. 不过,以下情况是合法的,并表示相同的字符串:

var v1 = """
         Start
<four spaces>
         End
         """;
var v1 = """
         Start
<nine spaces>
         End
         """;

在这两种情况下,“缩进空格”都是九个空格。 在这两种情况下,我们将尽可能多地删除该前缀,导致每个情况下的“空白”raw_content 为空(不计算每个 new_line)。 这样,用户就不必在复制/粘贴或编辑这些行时查看和担心这些行上的空格。

  1. 不过,在以下情况下:
var v1 = """
         Start
<ten spaces>
         End
         """;

“缩进空格”仍为九个空格。 不过,在这里,我们将尽可能多地删除“缩进空格”,而“空白”raw_content 将为最终内容贡献一个空间。 这样,如果内容需要在这些行上保留空格,就可以保留这些空格。

  1. 在技术上不是合法的:
var v1 = """
         """;

这是因为原始字符串的开头必须有一个 new_line(它确实如此),但末尾也必须有一个 new_line(它没有)。 最小的合法 raw_string_literal 是:

var v1 = """

         """;

但是,此字符串绝对无趣,因为它等效于 ""

缩进示例

“缩进空格”算法在多个输入端上的可视化效果如下。 以下示例使用垂直条形图字符 | 来说明生成的原始字符串中的第一列:

示例 1 - 标准案例

var xml = """
          <element attr="content">
            <body>
            </body>
          </element>
          """;

解释为

var xml = """
          |<element attr="content">
          |  <body>
          |  </body>
          |</element>
           """;

示例 2 - 与内容位于同一行上的结束分隔符。

var xml = """
          <element attr="content">
            <body>
            </body>
          </element>""";

这是非法的。 最后一个内容行必须以 new_line结尾。

示例 3 - 结束分隔符在开始分隔符之前

var xml = """
          <element attr="content">
            <body>
            </body>
          </element>
""";

解释为

var xml = """
|          <element attr="content">
|            <body>
|            </body>
|          </element>
""";

示例 4 - 起始分隔符之后是结束分隔符

var xml = """
          <element attr="content">
            <body>
            </body>
          </element>
              """;

这是非法的。 内容行必须以“缩进空格”开头

示例 5 - 空白行

var xml = """
          <element attr="content">
            <body>
            </body>

          </element>
          """;

解释为

var xml = """
          |<element attr="content">
          |  <body>
          |  </body>
          |
          |</element>
           """;

示例 6 - 空格小于前缀的空白行(点表示空格)

var xml = """
          <element attr="content">
            <body>
            </body>
....
          </element>
          """;

解释为

var xml = """
          |<element attr="content">
          |  <body>
          |  </body>
          |
          |</element>
           """;

示例 7 - 空格多于前缀的空白行(点表示空格)

var xml = """
          <element attr="content">
            <body>
            </body>
..............
          </element>
          """;

解释为

var xml = """
          |<element attr="content">
          |  <body>
          |  </body>
          |....
          |</element>
           """;

详细设计(插值情况)

通过使用 { 字符开始 interpolation 和使用 {{ 转义序列插入一个实际的开放括号字符,目前已支持正常插值字符串(如 $"...")中的插值。 使用相同的机制将违反此提案的目标“1”和“2”。 以 { 为核心字符的语言(例如 JavaScript、JSON、Regex,甚至嵌入式 C#)现在都需要转义,从而破坏了原始字符串字面量的目的。

为了支持插值,我们采用一种不同于普通 $" 插值字符串的方式引入它们。 具体来说,interpolated_raw_string_literal 会以一定数量的 $ 字符开头。 这些字符的个数表示字面内容中需要多少个 {(和 })字符来分隔 interpolation 字符。 重要的是,大括号始终没有转义机制。 相反,就像使用引号 (") 一样,字面量本身总是可以确保为插值指定分隔符,而这些分隔符肯定不会与字符串的其他内容相冲突。 例如,包含插值孔的 JSON 文字可以按照如下所示进行编写:

var v1 = $$"""
         {
            "orders": 
            [
                { "number": {{order_number}} }
            ]
         }
         """

此处,{{...}} 匹配由 $$ 分隔符前缀指定的所需的大括号数量,即两个。 在单个 $ 的情况下,这意味着要像 {...} 一样指定插值,就像在普通插值字符串字面量中一样。 重要的是,这意味着带有 N$ 字符的插值字面量可以有一连串 2*N-1 括号(同一类型的括号排成一行)。 最后一个 N 大括号将开始(或结束)插值,其余的 N-1 大括号只是内容。 例如:

var v1 = $$"""X{{{1+1}}}Z""";

在这种情况下,内部的两个 {{}} 大括号属于插值,外部的单数大括号只是内容。 因此,上述字符串等效于内容 X{2}Z。 使用 2*N(或更多)大括号总是错误的。 若要将较长的大括号序列作为内容,必须相应地增加 $ 个字符的数量。

插值原始字符串字面量定义为:

interpolated_raw_string_literal
    : single_line_interpolated_raw_string_literal
    | multi_line_interpolated_raw_string_literal
    ;

interpolated_raw_string_start
    : $
    | $$
    | $$$
    | etc.
    ;

interpolated_raw_string_literal_delimiter
    : interpolated_raw_string_start raw_string_literal_delimiter
    ;

single_line_interpolated_raw_string_literal
    : interpolated_raw_string_literal_delimiter interpolated_raw_content raw_string_literal_delimiter
    ;

multi_line_interpolated_raw_string_literal
    : interpolated_raw_string_literal_delimiter whitespace* new_line (interpolated_raw_content | new_line)* new_line whitespace* raw_string_literal_delimiter
    ;

interpolated_raw_content
    : (not_new_line | raw_interpolation)+
    ;

raw_interpolation
    : raw_interpolation_start interpolation raw_interpolation_end
    ;

raw_interpolation_start
    : {
    | {{
    | {{{
    | etc.
    ;

raw_interpolation_end
    : }
    | }}
    | }}}
    | etc.
    ;

上述内容类似于 raw_string_literal 的定义,但存在一些重要的差异。 应将 interpolated_raw_string_literal 解释为:

  1. 开头至少有一个美元符号(但没有上限),然后是三个引号(也没有上限)。
  2. 然后在起始引号的同一行继续输入内容。 同一行上的此内容可以为空或非空白。 “blank”是“完全空白”的同义词。
  3. 如果同一行上的内容不为空,则后面不能有更多内容。 换句话说,要求在同一行以相同数量的引号结束字面量。
  4. 如果同一行的内容为空白,则可以用 new_line 和一定数量的后续内容行和 new_line 继续字面量表达。
    • 内容行是除 new_line以外的任何文本。
    • 内容行可以包含任意位置的多个 raw_interpolation 事件。 raw_interpolation 开头的大括号 ({) 数量必须与字面量开头的美元符号数量相等。
    • 如果“缩进空格”不为空,则 raw_interpolation 不能紧跟在 new_line 后面。
    • raw_interpolation 将遵循 §12.8.3指定的正常规则。 任何 raw_interpolation 都必须以与美元符号和大括号相同数量的小括号 (}) 结束。
    • 任何 interpolation 本身都可以包含新行,其方式与普通 verbatim_string_literal (@"") 中的 interpolation 相同。
    • 然后以 new_line 某个数量(可能是零)的 whitespace 和与字面量开头相同数量的引号结束。

内插字符串值的计算方法与普通 raw_string_literal 的计算方法相同,只是进行了更新,以处理包含 raw_interpolation 的行。 构建字符串值的方式与此相同,只是用这些表达式在运行时产生的值替换了插值孔。 如果 interpolated_raw_string_literal 被转换为 FormattableString,那么内插值将按照各自的顺序传递到 arguments 数组,然后再传递到 FormattableString.Create。 从所有行中删除“缩进空格”之后interpolated_raw_string_literal 中的其余内容将用于生成传递给 FormattableString.Createformat 字符串,但在出现 raw_interpolation 的每个位置(或 {N,constant}interpolation 的形式为 expression ',' constant_expression 的情况下)会添加适当编号的 {N} 内容。

上述规范中存在歧义。 特别是当文本中的 { 部分和插值的 { 部分相邻时。 例如:

var v1 = $$"""
         {{{order_number}}}
         """

这可以解释为:{{ {order_number } }}{ {{order_number}} }。 然而,由于前者是非法的(没有 C# 表达式可以从 {开始),所以解释方式毫无意义。 因此,我们按照后一种方式进行解释,即最内层的 {} 大括号构成插值,最外层的任何大括号构成文本。 将来,如果语言支持任何用大括号包围的表达式,这可能会成为一个问题。 但是,在这种情况下,建议编写这样的案例:{{({some_new_expression_form})}}。 在这里,括号有助于将表达式部分与字面量/插值的其余部分区分开来。 三元条件表达式需要这样包装,以避免与插值的格式化/对齐指定符(如 {(x ? y : z)})发生冲突。

缺点

原始字符串文本会增加语言的复杂性。 我们已经有许多字符串文本形式,用于不同的目的。 "" 字符串、@"" 字符串和 $"" 字符串已具有很大的功能和灵活性。 但它们都缺少一种方法来提供永远不需要转义的原始内容。

上述规则不支持 4.a的情况:

  1. ...
    • 我们还可以通过额外的复杂性将其细化为这样的表述:对于所有本身不包含 new_line(但可以以引号 " 字符开始或结束)的字符串,应该可以在一行中表示字符串字面量本身。

这是因为我们不知道起始或结束引号(")应该属于内容,而不是分隔符本身。 如果这是我们想要支持的重要情境,我们可以添加一个并行的 ''' 构造以配合 """ 形式。 通过这种并行构造,很容易将以 " 开头和结尾的单行字符串编写为 '''"This string starts and ends with quotes"''',并且配合并行构造 """'This string starts and ends with apostrophes'"""。 如果嵌入的语言主要使用一个引号字符,而不是另一个引号字符,那么最好也能支持该功能,以帮助在视觉上区分引号字符。

替代方案

https://github.com/dotnet/csharplang/discussions/89 涵盖此处的许多选项。 替代方案有很多,但我觉得这些方案过于复杂,并且在人机工效方面做得不够好。 此方法选择简单起见,只需不断增加开始/结束引号长度,直到不关心与字符串内容的冲突。 它还能让你编写的代码看起来缩进得很好,同时还能产生大多数代码都想要的缩进字面量。

但是,最有趣的潜在变化之一是为这些原始字符串字面量使用 `(或 ```)围栏。 这有以下几个好处:

  1. 这样可以避免字符串以引号开头或结尾的所有问题。
  2. 它看起来很像 markdown。 虽然这本身可能不是一件好事,因为用户可能会期待对 markdown 进行解释。
  3. 在大多数情况下,原始字符串字面量只需以一个字符开始和结束,只有在内容本身包含反向标记的罕见情况下,才需要多个字符。
  4. 将来用 ```xml 来扩展这一点也很自然,这与 markdown 又很相似。 当然,""" 形式也是如此。

不过,总的来说,这里的净收益似乎很小。 根据 C# 的历史,我认为 " 应继续作为 string literal 分隔符,就像 @""$"" 一样。

设计会议

开放讨论的问题 已解决的问题:

  • [x] 是否应该使用单行形式? 从技术上讲,我们完全可以不用这个。 但这意味着不包含换行符的简单字符串始终至少需要三行。 我认为我们应该这样做。仅仅为了避免转义,就把单行结构强制为三行,这是非常沉重的负担。

设计决定:是的,我们将采用单行形式。

  • [x] 是否应该要求多行必须以换行符开头? 我认为我们应该这样做。 它还使我们能够在未来支持诸如 """xml 之类的事情。

设计决定:是的,我们将要求多行必须以换行符开始

  • [x] 是否应该进行自动缩进? 我认为我们应该这样做。 它使代码看起来更愉快。

设计决定:是的,将进行自动缩进。

  • [x] 我们是否应该限制常规空格的使用,防止混合不同类型的空格? 我不认为我们应该。 事实上,有一种常见的缩进策略,称为“Tab键用于缩进,空格用于对齐”。 在起始分隔符不在制表符止符上的情况下,用它来使末端分隔符与起始分隔符对齐是非常自然的。

设计决策:我们不会对混合空格有任何限制。

  • [x] 我们应该对围栏使用其他东西吗? ` 将匹配 markdown 语法,这意味着我们不需要始终使用三个引号启动这些字符串。 普通情况下只需一个就够了。

设计决定:我们将使用 """

  • [x] 是否要求分隔符的引号数比字符串值中最长的引号序列多? 从技术上说,这不是必需的。 例如:
var v = """
        contents"""""
        """

这是一个字符串,""" 作为分隔符。 一些社区成员表示这令人感到困惑,我们应该要求在这种情况下,分隔符始终要有更多字符。 那就应该是:

var v = """"""
        contents"""""
        """"""

设计决策:是的,分隔符必须比字符串本身中的任何引号序列长。