重载解析优先级

注意

本文是特性规范。 此规范是功能的设计文档。 它包括建议的规范变更,以及功能设计和开发过程中所需的信息。 这些文章将持续发布,直至建议的规范变更最终确定并纳入当前的 ECMA 规范。

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

可以在有关规范的文章中了解更多有关将功能规范子块纳入 C# 语言标准的过程。

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

总结

我们引入了一个新属性 System.Runtime.CompilerServices.OverloadResolutionPriority,API 作者可以使用该属性来调整单个类型中重载的相对优先级,以此引导 API 消费者使用特定的 API,即使这些 API 通常会被视为模棱两可或不会被 C# 的重载解析规则选中。

动机

API 作者经常会遇到一个问题,即在成员被废弃后如何处理该成员。 出于向后兼容性目的,许多人将保留现有成员,并将 ObsoleteAttribute 设置为永久错误,以避免中断在运行时升级二进制文件的使用者。 这对插件系统的打击尤为严重,因为插件作者无法控制插件的运行环境。 环境的创建者可能希望保留旧方法,但阻止新开发的代码访问该方法。 但是,ObsoleteAttribute 本身还不够。 类型或成员在重载解析中仍然可见,在存在绝佳的替代方案时可能会导致不必要的重载解析失败,但该替代方案与已过时的成员有歧义,或者已过时成员的存在导致重载解析提早结束,而没有考虑良好成员。 为此,我们希望让 API 作者通过一种方法来指导重载解析,以解决歧义问题,这样他们可以优化 API 接口范围,并引导用户使用更高效的 API,而不必牺牲用户体验。

基类库(BCL)团队提供了几个示例,展示了这种做法的有用之处。 一些(假设的)示例包括:

  • 创建一个 Debug.Assert 的重载,使用 CallerArgumentExpression 来获取被断言的表达式,从而可以将其包含在消息中,并使其优先于现有的重载。
  • 使 string.IndexOf(string, StringComparison = Ordinal) 优先于 string.IndexOf(string)。 这必须被视为一项潜在的重大更改进行讨论,但有人认为这是更好的默认设置,更可能是用户想要的。
  • 此建议和 CallerAssemblyAttribute 的组合将允许具有隐式调用方标识的方法避免昂贵的堆栈审核。 Assembly.Load(AssemblyName) 如今就在这样做,而且效率会更高。
  • Microsoft.Extensions.Primitives.StringValuesstringstring[] 公开隐式转换。 这意味着,当传递给同时具有 params string[]params ReadOnlySpan<string> 重载的方法时,它是不明确的。 该属性可用于优先处理其中一个重载,以防止出现歧义。

详细设计

重载解析优先级

我们定义一个新的概念 overload_resolution_priority,在解析方法组的过程中使用。 overload_resolution_priority 是 32 位整数值。 默认情况下,所有方法的 overload_resolution_priority 都为 0,可以通过将 OverloadResolutionPriorityAttribute 应用于该方法来更改。 我们对 C# 规范的第 §12.6.4.1 节进行了如下更新(更改为粗体):

一旦确定候选函数成员和参数列表,在所有情况下,最佳函数成员的选择都是相同的:

  • 首先,候选函数成员集被简化为适用于给定参数列表的函数成员 (§12.6.4.2)。 如果此简化集为空,则会发生编译时错误。
  • 然后,按声明类型对减少后的候选成员集进行分组。 在每个组中:
    • 候选函数成员按 重载解析优先级排序。 如果成员是替代成员,则 overload_resolution_priority 来自该成员的派生程度最低的声明。
    • 所有其 overload_resolution_priority 低于其声明类型组中最高值的成员都将被删除。
  • 随后,将缩减的组重新组合成最终一组适用的候选函数成员。
  • 然后,从一组适用的候选函数成员中找到最佳函数成员。 如果集仅包含一个函数成员,则该函数成员是最佳函数成员。 否则,最佳函数成员是在给定参数列表中,与所有其他函数成员比较后更占优势的那个函数成员,前提是每个函数成员都依据 §12.6.4.3中的规则与所有其他函数成员进行比较。 如果没有一个函数成员优于所有其他函数成员,则函数成员调用不明确,并且会发生绑定时错误。

例如,该功能将导致以下代码段打印“Span”,而不是“Array”:

using System.Runtime.CompilerServices;

var d = new C1();
int[] arr = [1, 2, 3];
d.M(arr); // Prints "Span"

class C1
{
    [OverloadResolutionPriority(1)]
    public void M(ReadOnlySpan<int> s) => Console.WriteLine("Span");
    // Default overload resolution priority
    public void M(int[] a) => Console.WriteLine("Array");
}

此更改的效果是,就像对大多数派生类型进行修剪一样,我们为重载解决优先级添加了最终的修剪。 因为这种修剪发生在重载解析过程的最后,因此这确实意味着基类型不能使其成员的优先级高于任何派生类型。 这是有意为之,并防止出现一种军备竞赛,即基类型可能试图始终优于派生类型。 例如:

using System.Runtime.CompilerServices;

var d = new Derived();
d.M([1, 2, 3]); // Prints "Derived", because members from Base are not considered due to finding an applicable member in Derived

class Base
{
    [OverloadResolutionPriority(1)]
    public void M(ReadOnlySpan<int> s) => Console.WriteLine("Base");
}

class Derived : Base
{
    public void M(int[] a) => Console.WriteLine("Derived");
}

允许使用负数,负数可用于将特定重载标记为比所有其他默认重载更差。

成员的 overload_resolution_priority 来源于该成员的派生程度最低的声明。 overload_resolution_priority 不是从类型成员实现的任何接口成员继承或推断出来的。给定一个成员 Mx,它实现了接口成员 Mi,如果 MxMi 具有不同的 overload_resolution_priorities,则不会发出警告。

注:本规则旨在复制 params 修饰符的行为。

System.Runtime.CompilerServices.OverloadResolutionPriorityAttribute

我们为 BCL 引入了以下属性:

namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
public sealed class OverloadResolutionPriorityAttribute(int priority) : Attribute
{
    public int Priority => priority;
}

C# 中的所有方法的默认 overload_resolution_priority 为 0,除非它们被赋予 OverloadResolutionPriorityAttribute 属性。 如果它们被赋予该属性,那么它们的 overload_resolution_priority 等于该属性第一个参数提供的整数值。

在以下位置应用 OverloadResolutionPriorityAttribute 是错误的:

  • 非索引器属性
  • 属性、索引器或事件访问器
  • 转换运算符
  • Lambdas
  • 本地函数
  • 终结器
  • 静态构造函数

C# 会忽略在元数据中这些位置上遇到的属性。

在将被忽略的位置(例如对基方法的替代)应用 OverloadResolutionPriorityAttribute 是错误的,因为优先级是从成员的派生程度最低的声明中读取的。

注意:这有意不同于 params 修饰符的行为,后者允许在忽略时重新指定或添加。

成员的可调用性

对于 OverloadResolutionPriorityAttribute 的一个重要注意事项是,它可以使某些成员从源代码中有效地无法调用。 例如:

using System.Runtime.CompilerServices;

int i = 1;
var c = new C3();
c.M1(i); // Will call C3.M1(long), even though there's an identity conversion for M1(int)
c.M2(i); // Will call C3.M2(int, string), even though C3.M1(int) has less default parameters

class C3
{
    public void M1(int i) {}
    [OverloadResolutionPriority(1)]
    public void M1(long l) {}

    [Conditional("DEBUG")]
    public void M2(int i) {}
    [OverloadResolutionPriority(1), Conditional("DEBUG")]
    public void M2(int i, [CallerArgumentExpression(nameof(i))] string s = "") {}

    public void M3(string s) {}
    [OverloadResolutionPriority(1)]
    public void M3(object o) {}
}

对于这些示例,默认的优先级重载实际上已经过时,只能通过几个需要额外努力的步骤来调用:

  • 将方法转换为委托,然后使用该委托。
    • 对于某些引用类型的变体情形,例如 M3(object) 优先于 M3(string)的情况,此策略将不起作用。
    • 条件方法(如 M2)也无法通过此策略进行调用,因为条件方法无法转换为委托。
  • 使用 UnsafeAccessor 运行时功能通过匹配签名调用它。
  • 手动使用反射获取对方法的引用,然后调用该方法。
  • 未重新编译的代码将继续调用旧方法。
  • 手写 IL 可以指定它选择的任何选项。

待讨论的问题

扩展方法分组(已解答)

按照当前的描述,扩展方法仅在其所属的类型内按优先级排序。 例如:

new C2().M([1, 2, 3]); // Will print Ext2 ReadOnlySpan

static class Ext1
{
    [OverloadResolutionPriority(1)]
    public static void M(this C2 c, Span<int> s) => Console.WriteLine("Ext1 Span");
    [OverloadResolutionPriority(0)]
    public static void M(this C2 c, ReadOnlySpan<int> s) => Console.WriteLine("Ext1 ReadOnlySpan");
}

static class Ext2
{
    [OverloadResolutionPriority(0)]
    public static void M(this C2 c, ReadOnlySpan<int> s) => Console.WriteLine("Ext2 ReadOnlySpan");
}

class C2 {}

在对扩展成员进行重载解析时,是否应该不按声明类型排序,而是考虑同一范围内的所有扩展?

Answer

我们将始终分组。 上例将打印 Ext2 ReadOnlySpan

重写的属性继承(已解答)

属性是否应该继承? 如果不是,替代成员的优先级是多少?
如果在虚拟成员上指定了该特性,是否需要重写该成员以重复该属性?

Answer

属性不会被标记为继承。 我们将查看成员的派生程度最低的声明,以确定其重载解析优先级。

替代时出现应用程序错误或警告(已解答)

class Base
{
    [OverloadResolutionPriority(1)] public virtual void M() {}
}
class Derived
{
    [OverloadResolutionPriority(2)] public override void M() {} // Warn or error for the useless and ignored attribute?
}

在被忽略的情境中(例如替代),我们应该如何处理 OverloadResolutionPriorityAttribute 的应用程序:

  1. 不执行任何操作,让它以无提示方式被忽略。
  2. 发出该属性将被忽略的警告。
  3. 发出不允许该属性的错误。

3 是最谨慎的方案,如果我们认为将来可能存在某种空间,我们可能希望允许重写来指定此属性。

Answer

我们决定选择 3,并阻止应用程序在会被忽略的场所运行。

隐式接口实现(已回答)

隐式接口实现的行为应该是什么? 是否需要指定 OverloadResolutionPriority? 当编译器遇到没有优先级的隐式实现时,应该如何处理? 这种情况几乎肯定会发生,因为接口库可能会更新,但实现却不会。 此处涉及 params 的现有技术是不指定,也不保留值:

using System;

var c = new C();
c.M(1, 2, 3); // error CS1501: No overload for method 'M' takes 3 arguments
((I)c).M(1, 2, 3);

interface I
{
    void M(params int[] ints);
}

class C : I
{
    public void M(int[] ints) { Console.WriteLine("params"); }
}

我们的选项包括:

  1. 关注 paramsOverloadResolutionPriorityAttribute 不会隐式继承或被要求明确指定。
  2. 隐式传递属性。
  3. 不要隐式转入属性,要求在调用点指定该属性。
    1. 这就带来了一个额外的问题:当编译器遇到这种带有编译引用的情况时,应该采取什么行为?

Answer

我们将选择 1。

更多应用程序错误(已回答)

还有一些像这样的位置需要确认。 其中包括:

  • 转换运算符 - 该规范从未说明转换运算符要经过重载解析,因此实现会阻碍应用程序处理这些成员。 是否应该确认?
  • Lambda - 同样,Lambdas 永远不会受到重载解析的影响,因此实现会阻止它们。 是否应该确认?
  • 析构函数 - 当前再次被阻止。
  • 静态构造函数 - 同样,当前已被阻止。
  • 本地函数 - 这些函数当前没有被阻止,因为它们经历了重载解析,因此无法重载它们。 这类似于将属性应用于未重载类型的成员时不会出错的情况。 这种行为是否应该得到确认?

Answer

上面列出的所有位置都被阻止。

Langversion 行为(已解答)

目前,该实现仅在应用 OverloadResolutionPriorityAttribute 时才会出现 langversion 错误;而在实际影响任何内容时,则不会出现。 之所以做出此决定是因为 BCL 将添加一些 API(现在和以后),这些 API 将开始使用此属性;如果用户手动将其 langversion 设置回 C#12 或更早版本,他们可能会看到这些成员,并且根据我们的 langversion 行为,他们可以:

  • 如果我们忽略 C# <13 中的属性,则会遇到歧义错误,因为没有该属性的 API 确实存在歧义,或者;
  • 如果我们在属性影响结果时出错,则会遇到 API 无法正常运行的错误。 这尤其糟糕,因为 Debug.Assert(bool) 在 .NET 9 中的优先级降低,或者;
  • 如果我们悄无声息地更改分辨率,如果一个编译器版本理解该属性,而另一个不理解,那么在不同的编译器版本之间可能会遇到不同的行为。

之所以选择最后一种行为,是因为它具有最大的向前兼容性,但其变化结果可能会让一些用户感到意外。 我们应该确认这一点,还是选择其他选项之一?

Answer

我们将采用选项 1,静默忽略以前语言版本中的属性。

替代方案

一个之前的提案试图指定一种 BinaryCompatOnlyAttribute 方法,这在从可见性中删除一些东西方面非常严厉。 然而,这有很多难以实现的问题,这意味着提案太强而无法使用(例如,阻止测试旧的 API),或者太弱以至于错过了一些最初的目标(例如,能够拥有一个 API,否则会被视为不明确的调用新 API)。 该版本复制如下。

BinaryCompatOnlyAttribute 提案(已过时)

BinaryCompatOnlyAttribute

详细设计

System.BinaryCompatOnlyAttribute

我们引入了一个新的保留属性:

namespace System;

// Excludes Assembly, GenericParameter, Module, Parameter, ReturnValue
[AttributeUsage(AttributeTargets.Class
                | AttributeTargets.Constructor
                | AttributeTargets.Delegate
                | AttributeTargets.Enum
                | AttributeTargets.Event
                | AttributeTargets.Field
                | AttributeTargets.Interface
                | AttributeTargets.Method
                | AttributeTargets.Property
                | AttributeTargets.Struct,
                AllowMultiple = false,
                Inherited = false)]
public class BinaryCompatOnlyAttribute : Attribute {}

应用于类型成员时,编译器会将该成员视为在所有位置不可访问,这意味着该成员不会参与成员查找、重载解析或任何其他类似过程。

可访问性领域

我们将 §7.5.3 可访问性域更新为如下

成员的可访问域由允许访问该成员的程序文本的(可能不相交的)部分组成。 为了定义成员的可访问性域,如果它不是在一个类型中声明,则该成员称为 顶级;如果它是在另一个类型中声明,则该成员称为 嵌套。 此外,程序文本 被定义为程序的所有编译单元中包含的所有文本,而一个类型的程序文本则定义为该类型的 type_declaration中的所有文本(可能包括嵌套在此类型中的类型)。

预定义类型的可访问性范围(如 objectintdouble)不受限制。

在一个程序 T 中声明的顶层未绑定类型 P)的可达性域是这样定义的:

  • 如果 T 标有 BinaryCompatOnlyAttribute,则 T 的访问域对 P 程序的文本和引用 P的任何程序完全不可访问。
  • 如果 T 的声明的可见性是公共的,则 T 的可访问范围是 P 的程序文本及任何引用 P的程序。
  • 如果声明的 T 访问权限是内部的, 则 T 的访问域是对应 P的程序文本。

注意:根据这些定义,可以得出结论,顶级未绑定类型的可访问性域始终至少是声明该类型的程序的程序文本。 尾注

构造类型 T<A₁, ..., Aₑ> 的可访问性域是未绑定泛型类型 T 的可访问性域与类型参数 A₁, ..., Aₑ的可访问性域的交集。

在程序 M 的类型 T 中,声明的嵌套成员 P 的可访问域是按以下方法定义的(注意,M 本身也可能是一个类型):

  • 如果 M 标有 BinaryCompatOnlyAttribute,则 M 的访问域对 P 程序的文本和引用 P的任何程序完全不可访问。
  • 如果 M 的声明可访问性是 public,那么 M 的可访问性域是 T的可访问性域。
  • 如果 M 的已声明可访问性为 protected internal,则令 DP 的程序文本与从 T 派生的任何类型的程序文本的并集,这些类型在 P 之外声明。 M 的辅助功能域是T 的辅助功能域与 D 的交集。
  • 如果 M 的可访问性被声明为 private protected,那么让 D 成为 P 的程序文本与 T 的程序文本以及任何从 T派生的类型的交集。 M 的辅助功能域是T 的辅助功能域与 D 的交集。
  • 如果 M 的已声明可访问性为 protected,则令 DT 的程序文本与任何从 T 派生类型的程序文本的并集。 M 的辅助功能域是T 的辅助功能域与 D 的交集。
  • 如果 M 的已声明可访问性为 internal,则 M 的可访问域就是 T 的可访问域与 P 的程序文本之间的交集。
  • 如果 M 的声明访问性是 private,那么 M 的访问范围是 T所在的程序文本。

添加这些内容的目的是使标记为 BinaryCompatOnlyAttribute 的成员完全无法访问任何位置,它们不会参与成员查找,也不会影响程序的其他部分。 因此,这意味着它们不能实现接口成员,不能相互调用,也不能被重写(虚拟方法)、隐藏或实现(接口成员)。 这是否过于严格,是下文几个未决问题的主题。

未解决的问题

虚拟方法和重写

当虚拟方法被标记为 BinaryCompatOnly 时,我们该怎么办? 派生类中的重写甚至可能不在当前程序集中,并且可能是用户希望引入一个新版本的方法,例如,该方法仅在返回类型上有所不同,而 C# 通常不允许对其进行重载。 重新编译时,对之前方法的任何重写会发生什么? 如果它们也被标记为 BinaryCompatOnly,是否允许它们覆盖 BinaryCompatOnly 成员?

在同一个 DLL 中使用

此提案指出,BinaryCompatOnly 成员在任何地方都不可见,即便是在当前正在编译的程序集内也是如此。 这是否过于严格,或者 BinaryCompatAttribute 成员是否需要相互链接?

显式实现接口成员

BinaryCompatOnly 成员是否能够实现接口成员? 还是应该阻止他们这样做。 当用户希望将隐式接口实现转换为 BinaryCompatOnly时,这将要求他们额外提供一个显式接口实现,并可能克隆与 BinaryCompatOnly 成员相同的主体,因为显式接口实现届时将无法访问到原始成员。

实现标记为 BinaryCompatOnly 的接口成员

当接口成员被标记为 BinaryCompatOnly时,我们该怎么办? 该类型仍需要为此成员提供实现,可能我们必须简单地说,接口成员不能标记为 BinaryCompatOnly