改进的插值字符串

注意

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

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

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

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

总结

我们引入了一种新的模式,用于创建和使用内插字符串表达式,以便在常规 string 方案和更专用的方案(如日志记录框架)中实现高效格式化和使用,同时避免因在框架中格式化字符串而产生不必要的分配。

动机

如今,字符串插值主要简化为调用 string.Format。 这虽然是常规用途,但出于多种原因可能效率低下:

  1. 除非运行时碰巧引入了 string.Format 的重载,以完全正确的顺序接收完全正确类型的参数,否则它将禁用任何结构参数。
    • 这种排序方式正是运行时犹豫是否引入通用版本方法的原因,因为这将导致一个非常普通的方法的通用实例组合爆炸。
  2. 在大多数情况下,它必须为参数分配数组。
  3. 如果不需要实例,就没有机会避免实例化。 例如,日志记录框架会建议避免字符串内插,因为它将导致实现可能不需要的字符串,具体取决于应用程序的当前日志级别。
  4. 它目前永远不能使用 Span 或其他 ref 结构类型,因为不允许 ref 结构作为泛型类型参数,这意味着如果用户希望避免复制到中间位置,则必须手动设置字符串格式。

在内部,运行时具有一个名为 ValueStringBuilder 的类型,以帮助处理其中前 2 种方案。 它们将用 stackalloc 分配的缓冲区传递给构建器,对每个部分反复调用 AppendFormat,然后获取最终的字符串。如果生成的字符串超出堆栈缓冲区的边界,则可以转移到堆上的数组。 但是,直接公开这种类型是危险的,因为不正确地使用可能导致租用的数组被重复释放,这会引发程序中各种不可预测的未定义行为,因为两个地方都认为它们具有对租用数组的独占访问权限。 此提案创建了一种方法,只需编写一个插值字符串字面量,就可以在本地 C# 代码中安全地使用该类型,从而在改进用户编写的每个插值字符串的同时,保持编写的代码不变。 它还扩展了此模式,允许将作为参数传递给其他方法的内插字符串,以使用由该方法的接收方定义的处理程序模式,从而允许日志记录框架等内容避免分配永远不会需要的字符串,并为 C# 用户提供熟悉、方便的内插语法。

详细设计

处理程序模式

我们引入了一个新的处理程序模式,该模式可以表示作为参数传递给方法的内插字符串。 模式的简单英语如下所示:

interpolated_string_expression 作为参数传递给方法时,我们将查看参数的类型。 如果参数类型有一个构造函数,可以调用 literalLengthformattedCount 这两个 int 参数,也可以选择使用原始参数上的属性指定的其他参数,还可以选择使用布尔尾部参数,并且原始参数的类型有实例 AppendLiteralAppendFormatted 方法,可以针对插值字符串的每一部分调用这些方法,那么我们就可以使用这些方法降低插值,而不是传统的调用 string.Format(formatStr, args) 方法。 一个更具体的示例有助于描述此内容:

// The handler that will actually "build" the interpolated string"
[InterpolatedStringHandler]
public ref struct TraceLoggerParamsInterpolatedStringHandler
{
    // Storage for the built-up string

    private bool _logLevelEnabled;

    public TraceLoggerParamsInterpolatedStringHandler(int literalLength, int formattedCount, Logger logger, out bool handlerIsValid)
    {
        if (!logger._logLevelEnabled)
        {
            handlerIsValid = false;
            return;
        }

        handlerIsValid = true;
        _logLevelEnabled = logger.EnabledLevel;
    }

    public void AppendLiteral(string s)
    {
        // Store and format part as required
    }

    public void AppendFormatted<T>(T t)
    {
        // Store and format part as required
    }
}

// The logger class. The user has an instance of this, accesses it via static state, or some other access
// mechanism
public class Logger
{
    // Initialization code omitted
    public LogLevel EnabledLevel;

    public void LogTrace([InterpolatedStringHandlerArguments("")]TraceLoggerParamsInterpolatedStringHandler handler)
    {
        // Impl of logging
    }
}

Logger logger = GetLogger(LogLevel.Info);

// Given the above definitions, usage looks like this:
var name = "Fred Silberberg";
logger.LogTrace($"{name} will never be printed because info is < trace!");

// This is converted to:
var name = "Fred Silberberg";
var receiverTemp = logger;
var handler = new TraceLoggerParamsInterpolatedStringHandler(literalLength: 47, formattedCount: 1, receiverTemp, out var handlerIsValid);
if (handlerIsValid)
{
    handler.AppendFormatted(name);
    handler.AppendLiteral(" will never be printed because info is < trace!");
}
receiverTemp.LogTrace(handler);

在这里,由于 TraceLoggerParamsInterpolatedStringHandler 有一个带有正确参数的构造函数,我们可以说插值字符串对该参数进行了隐式处理程序转换,并降低到上图所示的模式。 所需的规格有点复杂,下面会详细说明。

在此建议的其余部分中,将使用 Append... 来指代在两者都适用的情况下的 AppendLiteralAppendFormatted

新属性

编译器可识别 System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute

using System;
namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
    public sealed class InterpolatedStringHandlerAttribute : Attribute
    {
        public InterpolatedStringHandlerAttribute()
        {
        }
    }
}

编译器使用此属性来确定类型是否为有效的内插字符串处理程序类型。

编译器还识别 System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
    public sealed class InterpolatedStringHandlerArgumentAttribute : Attribute
    {
        public InterpolatedHandlerArgumentAttribute(string argument);
        public InterpolatedHandlerArgumentAttribute(params string[] arguments);

        public string[] Arguments { get; }
    }
}

此属性用于参数,以通知编译器如何降低参数位置中使用的内插字符串处理程序模式。

插值字符串处理程序转换

如果类型 T 的属性为 System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute,则称该类型为 applicable_interpolated_string_handler_type。 存在一个从 interpolated_string_expressionT 的隐式 interpolated_string_handler_conversion,或一个完全由 _interpolated_string_expression_s 组成并仅使用 + 运算符的 additive_expression

为了简单起见,在本规范中,插值字符串表达式 既指简单的 插值字符串表达式,也指完全由插值字符串表达式组成并且仅使用 + 运算符的 加法表达式

请注意,无论以后使用处理程序模式实际尝试降低插值时是否会出现错误,这种转换始终存在。 这样做是为了帮助确保存在可预测的有用错误,并且运行时行为不会根据内插字符串的内容而更改。

适用的函数成员调整

我们对适用的函数成员算法 (§12.6.4.2) 的措辞作了如下调整(每个部分都添加了一个新的小标题,以粗体显示):

当以下条件全部为 true 时,就参数列表 A 而言,一个函数成员被称为适用的函数成员

  • A 中的每个参数都对应于函数成员声明中的参数,如相应的参数(§12.6.2.2),并且没有参数对应的任何参数都是可选参数。
  • 对于 A中的每个参数,参数传递模式(即值、refout)与相应参数的参数传递模式相同,并且
    • 对于值参数或参数数组,存在从参数到相应参数类型的隐式转换(§10.2),
    • 对于类型为结构类型的 ref 参数,存在从参数到相应参数类型的隐式 interpolated_string_handler_conversion,或者
    • 对于 refout 参数,参数的类型与相应参数的类型相同。 毕竟,refout 参数是传递的参数的别名。

对于包含参数数组的函数成员,如果函数成员适用上述规则,则表示其 正常形式适用。 如果一个包含参数数组的函数成员在其普通形式中不适用,则该函数成员可以改用其 展开的形式

  • 扩展窗体通过将函数成员声明中的参数数组替换为参数数组的元素类型的零个或多个值参数来构造,以便参数列表中的参数数 A 与参数总数匹配。 如果 A 的参数数少于函数成员声明中的固定参数数,则无法构造函数成员的扩展形式,因此不适用。
  • 否则,如果 A 中每个参数的参数传递模式与相应参数的参数传递模式相同,则适用扩展形式,
    • 对于扩展创建的固定值参数或值参数,从参数的类型到相应参数的类型存在隐式转换(§10.2),或者
    • 对于类型为结构类型的 ref 参数,存在从参数到相应参数类型的隐式 interpolated_string_handler_conversion,或者
    • 对于 refout 参数,参数的类型与相应参数的类型相同。

重要说明:这意味着,如果有 2 个原本等效的重载,只是 applicable_interpolated_string_handler_type 的类型不同,这些重载将被视为模棱两可。 此外,由于我们无法看到显式转换,因此有可能出现一种无法解决的情况,即两个适用的重载都使用 InterpolatedStringHandlerArguments,并且在不手动执行处理程序降低模式的情况下完全无法调用。 如果选择此选项,我们可能会对更好的函数成员算法进行更改,以解决此问题,但这种情况不太可能发生,并且不是解决的优先级。

更好地转换表达式调整

我们将表达式 (§12.6.4.5) 部分的较佳转换改为以下内容:

给定从表达式 E 转换为类型 T1 的隐式转换 C1 和从表达式 E 转换为类型 T2 的隐式转换 C2,如果符合以下条件,则 C1 是比 C2 更好的转换

  1. E 是非常量的 插值字符串表达式C1隐式字符串处理器转换T1适用的插值字符串处理器类型,而 C2 不是 隐式字符串处理器转换
  2. ET2 不完全匹配,并且至少满足以下之一:

这确实意味着存在一些可能不明显的重载解析规则,具体取决于所插字符串是否为常量表达式。 例如:

void Log(string s) { ... }
void Log(TraceLoggerParamsInterpolatedStringHandler p) { ... }

Log($""); // Calls Log(string s), because $"" is a constant expression
Log($"{"test"}"); // Calls Log(string s), because $"{"test"}" is a constant expression
Log($"{1}"); // Calls Log(TraceLoggerParamsInterpolatedStringHandler p), because $"{1}" is not a constant expression

引入这种措施的目的是为了让那些可以简单地作为常量发布的内容能够这样做,并且不产生任何开销,而那些不能作为常量发布的内容则使用处理程序模式。

InterpolatedStringHandler 及其用法

我们在 System.Runtime.CompilerServices中引入了一种新类型:DefaultInterpolatedStringHandler。 这是一个 ref 结构,其许多语义与 ValueStringBuilder相同,旨在由 C# 编译器直接使用。 此结构大致如下所示:

// API Proposal issue: https://github.com/dotnet/runtime/issues/50601
namespace System.Runtime.CompilerServices
{
    [InterpolatedStringHandler]
    public ref struct DefaultInterpolatedStringHandler
    {
        public DefaultInterpolatedStringHandler(int literalLength, int formattedCount);
        public string ToStringAndClear();

        public void AppendLiteral(string value);

        public void AppendFormatted<T>(T value);
        public void AppendFormatted<T>(T value, string? format);
        public void AppendFormatted<T>(T value, int alignment);
        public void AppendFormatted<T>(T value, int alignment, string? format);

        public void AppendFormatted(ReadOnlySpan<char> value);
        public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string? format = null);

        public void AppendFormatted(string? value);
        public void AppendFormatted(string? value, int alignment = 0, string? format = null);

        public void AppendFormatted(object? value, int alignment = 0, string? format = null);
    }
}

我们对 interpolated_string_expression (§12.8.3) 的含义规则稍作修改:

如果内插字符串的类型 string 且类型 System.Runtime.CompilerServices.DefaultInterpolatedStringHandler 存在,并且当前上下文支持使用该类型,则使用处理程序模式降低字符串。然后,通过对处理程序类型调用 ToStringAndClear() 来获取最终 string 值。否则,如果 内插字符串的类型 System.IFormattableSystem.FormattableString [其余的未更改]

“和当前上下文支持使用该类型”规则有意含糊地让编译器在优化此模式的用法时有余地。 处理程序类型可能是 ref 结构类型,在异步方法中通常不允许 ref 结构类型。 对于此特定情况,如果没有任何内插孔包含 await 表达式,则允许编译器使用处理程序,因为我们可以静态确定处理程序类型是安全地使用的,而无需进行额外的复杂分析,因为在计算内插字符串表达式后,处理程序将被丢弃。

打开 问题

我们是否只想让编译器知道 DefaultInterpolatedStringHandler 并完全跳过 string.Format 调用? 这将允许我们隐藏一种方法,而当人们手动调用 string.Format 时,并不一定要让他们看到这个方法。

回答:是的。

打开 问题

我们还要为 System.IFormattableSystem.FormattableString 设置处理程序吗?

答案:否。

处理程序模式 codegen

在本部分中,方法调用解析是指 §12.8.10.2中列出的步骤。

构造函数解析

在给定 applicable_interpolated_string_handler_typeT and an interpolated_string_expressioni 的情况下,对 T 的有效构造函数进行方法调用解析和验证的过程如下:

  1. 实例构造函数的成员查询在 T上执行。 生成的方法组称为 M
  2. 参数列表 A 构造如下:
    1. 前两个参数是整数常量,分别表示 i的文本长度,以及 i内插 组件的数目。
    2. 如果 i 用作方法 M1中某些参数 pi 的参数,并且参数 pi 使用 System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute进行特性化,则对于该属性的 Arguments 数组中的每个名称 Argx,编译器将其与具有相同名称的参数 px 匹配。 空字符串与 M1 的接收者匹配。
      • 如果任何 Argx 无法与 M1参数匹配,或者 Argx 请求 M1 的接收方和 M1 是静态方法,则会生成错误,并且不执行进一步的步骤。
      • 否则,每个已解析 px 的类型将按照 Arguments 数组指定的顺序添加到参数列表中。 每个 px 都以与 M1 中指定的 ref 相同的语义进行传递。
    3. 最后一个参数是 bool,作为 out 参数传递。
  3. 传统方法调用解析使用方法组 M 和参数列表 A执行。 就方法调用的最终验证而言,M 的上下文被视为通过类型 Tmember_access
    • 如果找到F作为单一最佳构造函数,那么重载解析的结果就是F
    • 如果未找到适用的构造函数,将重试步骤 3,从 A中删除最终的 bool 参数。 如果此重试还找不到适用的成员,则会生成错误,并且不会采取进一步的步骤。
    • 如果未找到单一最佳方法,则重载解析的结果不明确,将生成错误,并且不会采取进一步的步骤。
  4. F 执行最终验证。
    • 如果 A 中的任何元素出现在 i 之后,则会产生错误,并且不会采取进一步措施。
    • 如果有任何 A 请求接收 F,而 F 是一个索引器,被用作 member_initializer 中的 initializer_target,则会报告错误,并且不会采取进一步措施。

注意:这里的解析有意使用作为 Argx 元素其他参数传递的实际表达式。 我们仅考虑转换后的类型。 这将确保我们不会出现双重转换问题,或出现意外情况,即当 lambda 传递到 M1 时绑定到一种委托类型,而当传递到 M 时绑定到另一种委托类型。

注意:由于嵌套成员初始值设定项的评估顺序,对于作为成员初始值设定项使用的索引器,我们会报告一个错误。 请考虑以下代码片段:


var x1 = new C1 { C2 = { [GetString()] = { A = 2, B = 4 } } };

/* Lowering:
__c1 = new C1();
string argTemp = GetString();
__c1.C2[argTemp][1] = 2;
__c1.C2[argTemp][3] = 4;

Prints:
GetString
get_C2
get_C2
*/

string GetString()
{
    Console.WriteLine("GetString");
    return "";
}

class C1
{
    private C2 c2 = new C2();
    public C2 C2 { get { Console.WriteLine("get_C2"); return c2; } set { } }
}

class C2
{
    public C3 this[string s]
    {
        get => new C3();
        set { }
    }
}

class C3
{
    public int A
    {
        get => 0;
        set { }
    }
    public int B
    {
        get => 0;
        set { }
    }
}

__c1.C2[]的参数在索引器的接收方之前进行计算。 虽然我们可以想出一种适用于这种情况的降级方法(或者为 __c1.C2 创建一个临时文件并在两次索引器调用中共享,或者只在第一次索引器调用中使用临时文件并在两次调用中共享参数),但我们认为任何降级方法都会对我们认为是极端的情况造成混乱。 因此,我们完全禁止这种情况。

公开问题

如果使用构造函数而不是 Create,我们会改进运行时 codegen,但代价是稍微缩小模式。

答案:我们暂时只限于构造函数。 如果出现这种情况,我们可以稍后重新考虑添加一个通用的 Create 方法。

Append... 方法重载解析

在给定 applicable_interpolated_string_handler_typeTinterpolated_string_expressioni 的情况下,对 T 上的一组有效 Append... 方法进行重载解析的过程如下:

  1. 如果 i 中有任何 interpolated_regular_string_character 组件:
    1. 对名称为 AppendLiteralT 进行成员查找。 生成的方法组称为 Ml
    2. 参数列表 Al 是用类型为 string的一个值参数构造的。
    3. 传统方法调用解析使用方法组 Ml 和参数列表 Al执行。 就方法调用的最终验证而言,Ml 的上下文被视为通过 T 实例的 member_access
      • 如果找到单一最佳方法 Fi 且未生成错误,则方法调用解析的结果 Fi
      • 否则将报错。
  2. 对于 i 的每个插值ix组件:
    1. 对名称为 AppendFormattedT 进行成员查找。 生成的方法组称为 Mf
    2. 参数列表 Af 已构建:
      1. 第一个参数是 ixexpression,按值进行传递。
      2. 如果 ix 直接包含 constant_expression 组件,则会添加整数值参数,并指定名称 alignment
      3. 如果 ix 后面直接跟有 interpolation_format,则会添加一个字符串值参数,并指定名称 format
    3. 传统方法调用解析使用方法组 Mf 和参数列表 Af执行。 就方法调用的最终验证而言,Mf 的上下文被视为通过 T 实例的 member_access
      • 如果找到了唯一最佳的方法 Fi,则方法调用解析的结果是 Fi
      • 否则将报错。
  3. 最后,对于步骤 1 和 2 中发现的每个 Fi,将执行最终验证:
    • 如果任何 Fi 没有返回 bool 值或 void,则会报错。
    • 如果所有 Fi 都不返回同一类型,则报告错误。

请注意,这些规则不允许为 Append... 调用设置扩展方法。 如果我们愿意的话,可以考虑启用这一点,但这类似于枚举器模式,我们允许 GetEnumerator 为扩展方法,而不是 CurrentMoveNext()

这些规则的确会允许 Append... 调用使用默认参数,这些参数将与 CallerLineNumberCallerArgumentExpression(如果语言支持)配合使用。

对于基元素与内插漏洞,我们有单独的重载查找规则,因为某些处理程序希望能够了解内插的组件与作为基字符串一部分的组件之间的差异。

未决问题

某些方案(如结构化日志记录)希望能够为内插元素提供名称。 例如,今天日志记录调用可能类似于 Log("{name} bought {itemCount} items", name, items.Count);{} 内的名称为记录器提供重要的结构信息,有助于确保输出一致且统一。 在某些情况下,可以为此重复使用插值孔的 :format 组件,但许多日志记录器已经能够理解格式规范,并根据这些信息对输出格式化进行了处理。 我们是否可以使用某种语法来添加这些命名规范?

在某些情况下,如果 C# 10 中确实支持 CallerArgumentExpression,那么它也可能是可行的。 但是,对于调用方法/属性的情况,这可能不够。

答案

虽然模板化字符串有一些有趣的部分,我们可以在正交语言功能中探索,但我们并不认为此处的特定语法比使用元组:$"{("StructuredCategory", myExpression)}"等解决方案大有好处。

执行转换

给定一个 applicable_interpolated_string_handler_typeT 和一个 interpolated_string_expressioni,其中的有效构造函数 FcAppend... 方法 Fa 已被解析,对 i 的降低操作如下:

  1. Fc 的任何参数,如果在词法上出现在 i 之前,都会按词法顺序进行评估并存储到临时变量中。 为了保留词法排序,如果 i 作为较大表达式 e的一部分发生,则 i 之前发生的 e 的任何组件也将按词法顺序进行计算。
  2. 调用 Fc 时,会同时调用插值字符串字面量组件的长度、插值孔的数量、任何先前求值的参数,以及 bool 输出参数(如果 Fc 在最后一个参数中使用了一个参数)。 结果存储在 ib的临时值中。
    1. 字面量组件的长度是在将 open_brace_escape_sequence 替换为单个 { 和将 close_brace_escape_sequence 替换为单个 } 之后计算得出的。
  3. 如果 Fcbool 输出参数结束,则会对该 bool 值进行检查。 如果为 true,将调用 Fa 中的方法。 否则,它们将不会被调用。
  4. 对于 Fa 中的每一个 FaxFax 都会在 ib 中被调用,并根据情况使用当前的字面量组件或插值表达式。 如果 Fax 返回 bool,则结果在逻辑上与前面的所有 Fax 调用绑定。
    1. 如果 Fax 是对 AppendLiteral 的调用,则通过用单个 { 替换 open_brace_escape_sequence 和用单个 } 替换 close_brace_escape_sequence 来取消字面量组件的转义。
  5. 转换的结果为 ib

同样请注意,传递给 Fc 的参数和传递给 e 的参数是相同的临时变量。转换可能发生在临时参数之上,以转换为 Fc 所需的形式,但例如,在 Fce 之间,lambdas 不能绑定到不同的委托类型。

未决问题

这种降低意味着,在 false 返回的 Append... 调用之后,插值字符串的后续部分不会被求值。 这可能会造成很大的混乱,尤其是在格式漏洞具有副作用的情况下。 相反,我们可以先评估所有格式洞,然后重复调用 Append... 并返回结果,如果返回 false 则停止调用。 这将确保所有表达式按照人们所期望的那样进行评估,同时我们需要调用的方法尽可能少。 虽然部分评估对于一些更高级的情况可能是可取的,但对于一般情况来说,它可能并不直观。

另一种替代方法(如果我们希望始终评估所有格式漏洞)是删除 API 的 Append... 版本,只需执行重复的 Format 调用。 处理程序可以跟踪是否应该放弃参数并立即返回该版本。

回答:我们将对漏洞进行有条件的评估。

未决问题

我们是否需要对一次性处理程序类型进行处置,并用 try/finally 封装调用,以确保 Dispose 被调用? 例如,bcl 中的内插字符串处理程序内部可能有一个租用的数组,如果其中一个内插孔在计算过程中引发异常,则如果未释放,则租用的数组可能会泄露。

答案:否。处理程序可以分配给局部变量(如 MyHandler handler = $"{MyCode()};),并且此类处理程序的生存期尚不清楚。 与 foreach 枚举器不同,其中生存期很明显,并且不会为枚举器创建用户定义的本地。

对可以为 null 的引用类型的影响

为了尽量减少实现的复杂性,我们对如何对用作方法或索引器参数的插值字符串处理程序构造函数进行可为 null 分析有一些限制。 具体而言,我们不会将来自构造函数的信息传递回原始上下文中参数或参数的原始存储位置,也不会使用构造函数参数类型来指导在包含方法中进行的类型参数的泛型类型推断。 其中可能产生影响的示例是:

string s = "";
C c = new C();
c.M(s, $"", c.ToString(), s.ToString()); // No warnings on c.ToString() or s.ToString(), as the `MaybeNull` does not flow back.

public class C
{
    public void M(string s1, [InterpolatedStringHandlerArgument("", "s1")] CustomHandler c1, string s2, string s3) { }
}

[InterpolatedStringHandler]
public partial struct CustomHandler
{
    public CustomHandler(int literalLength, int formattedCount, [MaybeNull] C c, [MaybeNull] string s) : this()
    {
    }
}
string? s = null;
M(s, $""); // Infers `string` for `T` because of the `T?` parameter, not `string?`, as flow analysis does not consider the unannotated `T` parameter of the constructor

void M<T>(T? t, [InterpolatedStringHandlerArgument("s1")] CustomHandler<T> c) { }

[InterpolatedStringHandler]
public partial struct CustomHandler<T>
{
    public CustomHandler(int literalLength, int formattedCount, T t) : this()
    {
    }
}

其他注意事项

允许 string 类型也可转换为处理程序

为了简化类型作者的工作,我们可以考虑允许 string 类型的表达式隐式转换为 applicable_interpolated_string_handler_types 类型。 按照目前的建议,作者可能需要同时对处理程序类型和常规 string 类型进行重载,这样他们的用户就不必了解两者的区别。 这可能是一个恼人且不明显的开销,因为一个 string 表达式可以被看作是一个预填充长度为 expression.Length 的插值,待填充孔为 0。

这样,新的 API 就可以只公开一个处理程序,而不必同时公开一个 string 接受重载。 然而,它无法避免需要进行更改以更好地转换表达式,因此,即便它可以正常运行,这可能也会带来不必要的负担。

答案

我们认为,这可能让人感到困惑,不过有一个简单的解决方法适用于自定义处理程序类型:添加一种用户定义的字符串转换。

将范围纳入无堆字符串

现在的 ValueStringBuilder 有两个构造函数:一个需要一个计数并尽快在堆上分配;另一个需要一个 Span<char>Span<char> 通常是运行时代码库中的固定大小,平均约为 250 个元素。 要真正取代这种类型,我们应该考虑对其进行扩展,使我们也能识别采用 Span<char>GetInterpolatedString 方法,而不仅仅是计数版本。 但是,我们在此处看到了一些潜在的棘手案例::

  • 我们不想在热循环中重复使用 stackalloc。 如果我们要对该功能执行此扩展,我们可能需要在循环迭代之间共享 stackalloc 的跨度。 我们知道这样做是安全的,因为 Span<T> 是一个无法存储在堆上的 ref 结构,用户必须非常“狡猾”才能设法提取到 Span 的引用(例如创建一个接受此类处理程序的方法,然后故意从处理程序中检索 Span 并将其返回给调用者)。 但是,提前分配会产生其他问题:
    • 我们应该急切地使用 stackalloc 吗? 如果循环从未进入,或者在需要空间之前就退出了呢?
    • 如果我们没有急于使用 stackalloc,是否意味着我们在每个循环中都引入了一个隐藏分支? 大多数环路可能不会在意这一点,但这可能会影响到一些不想支付成本的紧密循环。
  • 某些字符串可能很大,stackalloc 的适当量取决于多种因素,包括运行时因素。 我们不希望 C# 编译器和规范提前确定这一点,因此我们希望解析 https://github.com/dotnet/runtime/issues/25423 并添加 API 供编译器在这些情况下调用。 这也为上一循环的观点增添了更多利弊,我们不希望在堆上多次分配大型数组或在需要数组之前分配大型数组。

答案

这超出了 C# 10 的范围。 当我们查看更常规的 params Span<T> 功能时,就可以大致了解这一点。

API 的非试用版

为简单起见,该规范目前只建议识别 Append... 方法,而总是成功的方法(如 InterpolatedStringHandler)将总是返回 true。 这样做是为了支持部分格式设置方案,其中在发生错误时或在格式设置不必要(如日志记录的情况)时,用户希望停止格式设置;但这可能会在标准内插字符串的使用中引入许多不必要的分支。 我们可以考虑一个附加方案,即如果不存在 Append... 方法,我们只使用 FormatX 方法。然而,如果同时存在 Append...FormatX 调用,这确实带来了我们该如何处理的问题。

答案

我们希望 API 的非试用版本。 该提案已更新,以反映这一点。

将以前的参数传递给处理程序

目前,建议中缺少对称性:以缩减形式调用扩展方法会产生与以正常形式调用扩展方法不同的语义。 这与语言中的其他大多数地方不同,在这些地方,简化形式只是一种糖。 建议在框架中添加一个属性,在绑定方法时识别该属性,通知编译器应将某些参数传递给处理程序的构造函数。 用法如下所示:

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
    public sealed class InterpolatedStringHandlerArgumentAttribute : Attribute
    {
        public InterpolatedStringHandlerArgumentAttribute(string argument);
        public InterpolatedStringHandlerArgumentAttribute(params string[] arguments);

        public string[] Arguments { get; }
    }
}

那么其用途是:

namespace System
{
    public sealed class String
    {
        public static string Format(IFormatProvider? provider, [InterpolatedStringHandlerArgument("provider")] ref DefaultInterpolatedStringHandler handler);
        …
    }
}

namespace System.Runtime.CompilerServices
{
    public ref struct DefaultInterpolatedStringHandler
    {
        public DefaultInterpolatedStringHandler(int baseLength, int holeCount, IFormatProvider? provider); // additional factory
        …
    }
}

var formatted = string.Format(CultureInfo.InvariantCulture, $"{X} = {Y}");

// Is lowered to

var tmp1 = CultureInfo.InvariantCulture;
var handler = new DefaultInterpolatedStringHandler(3, 2, tmp1);
handler.AppendFormatted(X);
handler.AppendLiteral(" = ");
handler.AppendFormatted(Y);
var formatted = string.Format(tmp1, handler);

我们需要回答的问题:

  1. 我们通常喜欢这种模式吗?
  2. 是否允许这些参数来自处理程序参数之后? 在 BCL 中,一些现有模式(例如 Utf8Formatter)会将需要格式化的值 放在要格式化到的对象 之前。 为了更好地适应这些模式,我们可能希望允许这样做,但我们需要决定是否可以进行这种不按顺序的计算。

答案

我们希望支持这一点。 规范已更新,以反映这一点。 调用时需要在调用站点按词法顺序指定参数,如果在插值字符串字面量之后才指定创建方法所需的参数,就会产生错误。

await 在插值孔中的使用方法

因为 $"{await A()}" 在今天是一个有效的表达式,所以我们需要用 await 来合理解释插值孔。 我们可以通过一些规则解决此问题:

  1. 如果用作 stringIFormattableFormattableString 的插值字符串在插值孔中具有 await,则回退到旧式格式化程序。
  2. 如果插值字符串经过 implicit_string_handler_conversion 转换,且 applicable_interpolated_string_handler_typeref struct,则格式孔中不允许使用 await

从根本上说,这种语法脱糖可以在异步方法中使用 ref 结构,只要我们能确保 ref struct 不需要保存在堆中。如果我们禁止在插值槽中使用 await,这应该是可行的。

或者,我们只需将所有处理程序类型设置为非 ref 结构,包括内插字符串的框架处理程序。 然而,这将阻碍我们在将来识别出一个不需要分配任何暂存空间的 Span 版本。

答案

我们将将内插字符串处理程序视为任何其他类型:这意味着,如果处理程序类型为 ref 结构,并且当前上下文不允许使用 ref 结构,则此处使用处理程序是非法的。 关于简化用作字符串的字符串文本的规范有意含糊不清,以允许编译器决定适用的规则,但对于自定义处理程序类型,它们必须遵循语言其他部分的相同规则。

作为 ref 参数的处理程序

某些处理程序可能需要作为 ref 参数传递(inref)。 我们应该允许这两种情况吗? 如果是这样,ref 处理程序会是什么样子? ref $"" 令人困惑,因为您实际上不是通过 ref 传递字符串,而是通过 ref 传递从 ref 创建的处理程序,并且与异步方法有类似的潜在问题。

答案

我们希望支持这一点。 规范已更新,以反映这一点。 这些规则应反映应用于值类型的扩展方法的相同规则。

通过二进制表达式和转换插值字符串

由于此建议使内插字符串上下文变得敏感,因此我们希望允许编译器将由完全内插字符串组成的二进制表达式或受强制转换的内插字符串视为内插字符串文本,以便进行重载解析。 例如,采用以下方案:

struct Handler1
{
    public Handler1(int literalLength, int formattedCount, C c) => ...;
    // AppendX... methods as necessary
}
struct Handler2
{
    public Handler2(int literalLength, int formattedCount, C c) => ...;
    // AppendX... methods as necessary
}

class C
{
    void M(Handler1 handler) => ...;
    void M(Handler2 handler) => ...;
}

c.M($"{X}"); // Ambiguous between the M overloads

这将是一个模棱两可的问题,有必要转换为 Handler1Handler2 才能解决。 然而,在进行该转换时,我们可能会丢弃方法接收器中的上下文信息,这意味着转换将失败,因为没有任何东西可以填充 c 的信息。 字符串的二进制连接也会产生类似的问题:用户可能希望格式化跨行的字面量,以避免换行,但这是不可能的,因为这将不再是一个可转换为处理程序类型的插值字符串字面量。

若要解决这些情况,我们进行了以下更改:

  • 一个完全由 interpolated_string_expressions 组成并只使用 + 运算符的 additive_expression 被视为 interpolated_string_literal 用于转换和重载解析。 最终的插值字符串是通过逻辑连接所有单独的 interpolated_string_expression 组件(从左到右)创建的。
  • 操作符为 interpolated_string_expressionscast_expression 或带有操作符 asrelational_expression 被视为 interpolated_string_expressions 用于转换和重载解析。

待解答问题

我们想这么做吗? 例如,我们不会为 System.FormattableString 这样做,但它可以被分到不同的行中,而这可能取决于上下文,因此不能被分到不同的行中。 FormattableStringIFormattable 也不存在解决重载的问题。

答案

我们认为这是加法表达式的一个有效用例,但目前的类型转换版本还不够有说服力。 如有必要,我们稍后可以添加它。 该规范已更新,以反映这一决定。

其他用例

有关使用此模式的建议处理程序 API 的示例,请参阅 https://github.com/dotnet/runtime/issues/50635