协变返回结果

注意

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

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

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

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

总结

支持协变返回类型。 具体而言,允许重写一个方法以声明比重写的方法更派生的返回类型;同样地,允许重写一个只读属性以声明更派生的类型。 出现在更多派生类型中的重写声明需要提供一个返回类型,其具体程度至少与出现在基类型重写中的返回类型一样。 方法或属性的调用方将从调用中静态接收更精细的返回类型。

动机

代码中的一种常见模式是,必须发明不同的方法名称来解决重写必须返回与重写方法相同类型的语言约束。

这对工厂模式很有用。 例如,在 Roslyn 代码库中,我们会有

class Compilation ...
{
    public virtual Compilation WithOptions(Options options)...
}
class CSharpCompilation : Compilation
{
    public override CSharpCompilation WithOptions(Options options)...
}

详细设计

这是 C# 中协变返回类型的规范。 我们的目的是允许重写一个方法,以返回比它重写的方法更派生的返回类型;同样地,也允许重写只读属性,以返回更派生的返回类型。 调用方法或属性的对象将在调用后静态获得更为精确的返回类型,且在更派生类型的重写中,需要提供的返回类型至少要与基类类型的重写中出现的返回类型一样具体。


类方法重写

类重写 (§15.6.5) 方法的现有约束

  • 重写方法和重写基方法具有相同的返回类型。

修改为

  • 重写方法必须具有可通过标识转换转换的返回类型,或者(如果方法具有值返回,而不是 ref 返回,请参阅 §13.1.0.5 对重写基方法的返回类型的隐式引用转换。

在此列表基础上,又增加了以下要求:

  • 重写方法必须具有可通过标识转换转换的返回类型,或者(如果方法具有值返回 ,而不是 ref 返回§13.1.0.5),隐式引用转换为重写方法的(直接或间接)基类型中声明的重写基方法的每个重写的返回类型。
  • 重写方法的返回类型的可访问性必须至少与重写方法本身相等(可访问性域 - §7.5.3)。

此约束允许 private 类中的重写方法具有 private 返回值类型。 但是,它要求 public 类型中的 public 重写方法具有 public 返回类型。

类属性和索引器重写

类重写 (§15.6.5) 属性的现有约束

重写属性声明应指定与继承属性完全相同的可访问性修饰符和名称,并且在重写属性类型与继承属性的类型之间应有一个标识转换。 如果继承属性只有一个访问器(即继承属性为只读或只写),则重写属性应只包括该访问器。 如果继承的属性包含两个访问器(即继承属性为读写),则重写属性可以包含一个访问器或两个访问器。

修改为

重写属性声明应指定与继承属性完全相同的可访问修饰符和名称,并且应有一个标识转换 或(如果继承属性是只读的并且有一个值返回,而不是 ref 返回§13.1.0.5)从重写属性的类型到继承属性的类型的隐式引用转换。 如果继承属性只有一个访问器(即继承属性为只读或只写),则重写属性应只包括该访问器。 如果继承的属性包含两个访问器(即继承属性为读写),则重写属性可以包含一个访问器或两个访问器。 重写属性的类型必须至少与重写属性具有相同的可访问性(可访问性域 - §7.5.3)。


草案规范的其余部分建议对接口方法的协变返回做进一步扩展,这将留待稍后考虑。

接口方法、属性和索引器重写

通过在 C# 8.0 中添加 DIM 功能,添加了接口中允许的成员类型,我们还添加了对 override 成员以及协变返回的支持。 这些遵循为类指定的 override 成员规则,但有以下区别:

类中的以下文本:

通过重写声明重写的方法称为重写基方法。 对于在类 M 中声明的重写方法 C,通过检查 C 的每个基类来确定被重写的基方法,从 C 的直接基类开始,并继续检查每一个后续的直接基类,直到找到一个给定基类类型中至少有一个可访问的方法,该方法在进行类型参数替换后,与 M 拥有相同的签名。

给出了相应的接口规范:

通过重写声明重写的方法称为重写基方法。 对于在接口 M 中声明的重写方法 I,通过检查 I 的每个直接或间接基接口来确定要重写的基方法,在替换类型参数后,收集声明与 M 具有相同签名的可访问方法的接口集。 如果此接口集具有一个派生度最高的类型,并且此集合中的每个类型都有一个标识或隐式引用转换,并且该类型包含一个唯一的此类方法声明,则这是重写基方法

我们同样允许在接口中使用 override 属性和索引器,就像在 §15.7.6 Virtual、sealed、override 和 abstract 中为类指定的一样。

名称查找

在类 override 声明中,名称查找会通过从标识符限定符的类型(若无限定符则从 override 开始)的类层次结构中,使用派生程度最高的 this 声明的详细信息来修改名称查找的结果。 例如,在 §12.6.2.2 相应参数中,我们

对于类中定义的虚拟方法和索引器,参数列表是从接收器的静态类型开始,在其基类中搜索到的函数成员的第一个声明或覆盖中提取的。

我们向其添加

对于接口中定义的虚拟方法和索引器,参数列表是从包含函数成员重写声明的类型中派生程度最高的类型中的函数成员的声明或重写中选取的。 如果不存在唯一的此类类型,则会在编译时出错。

对于属性或索引器访问的结果类型,现有文本

  • 如果 I 标识了一个实例属性,那么结果就是一个属性访问,其关联实例表达式为 E,关联类型为属性类型。 如果 T 是类类型,则从 T 开始搜索基类时发现的第一个属性声明或重写中选择相关类型。

填充

如果 T 是接口类型,则从派生程度最高的 T 或其直接或间接基接口中找到的属性的声明或重写中选择关联类型。 如果不存在唯一的此类类型,则会在编译时出错。

§12.8.12.3 索引器访问中进行了类似的更改

§12.8.10 调用表达式中,我们扩充了现有的文本

  • 否则,结果是一个值,具有方法或委托的返回类型的关联类型。 如果调用的是实例方法,而接收者属于类类型 T,则从 T 开始搜索基类时发现的第一个方法声明或重写中选择相关类型。

替换为

如果调用的是实例方法,并且接收方是接口类型 T,则从 T 及其直接和间接基接口中派生程度最高的接口中发现的方法的声明或重写中选择相关类型。 如果不存在唯一的此类类型,则会在编译时出错。

隐式接口实现

规范的此部分

为了进行接口映射,当类成员 A 与接口成员 B 匹配时:

  • AB 是方法,而 AB 的名称、类型和形式参数表完全相同。
  • AB 是属性,AB 的名称和类型相同,AB 具有相同的访问器(如果 A 不是显式接口成员实现,则允许它具有额外的访问器)。
  • AB 是事件,AB 的名称和类型相同。
  • AB 是索引器,AB 的类型和形式参数表完全相同,AB 具有相同的访问器(如果 A 不是显式接口成员实现,则允许它具有额外的访问器)。

修改如下:

为了进行接口映射,当类成员 A 与接口成员 B 匹配时:

  • AB 是方法,AB 的名称和形式参数列表是相同的,A 的返回类型可以通过隐式引用转换为 B 返回类型的恒等式转换为 B 返回类型。
  • AB 是属性,A 的名称和 B 相同,A 具有与 B 相同的访问器(如果A 不是显式接口成员实现,则允许其具有其他访问器),并且 A 的类型可通过标识转换转换为 B 的返回类型,或者如果 A 是只读属性,则通过隐式引用转换。
  • AB 是事件,AB 的名称和类型相同。
  • AB 是索引器,AB 的形式参数表完全相同,AB 具有相同的访问器(如果 A 不是显式接口成员实现,则允许它具有额外的访问器),A 的类型可通过标识转换转换为 B 的返回类型,如果 A 是只读索引器,则为隐式引用转换。

从技术角度讲,这是一项重大更改,因为下面的程序今天输出“C1.M”,但在建议的修改中会输出“C2.M”。

using System;

interface I1 { object M(); }
class C1 : I1 { public object M() { return "C1.M"; } }
class C2 : C1, I1 { public new string M() { return "C2.M"; } }
class Program
{
    static void Main()
    {
        I1 i = new C2();
        Console.WriteLine(i.M());
    }
}

由于这一重大更改,我们可能会考虑在隐式实现上不支持协变返回类型。

对接口实现的限制

我们需要一个规则,即显式接口实现必须声明一个返回类型,其基接口中声明的任何重写中声明的返回类型不亚于派生类型。

API 兼容性影响

待定

未结的问题

规范没有说明调用方如何获得更精细的返回类型。 据推测,这将以类似于调用方获取派生程度最高的覆盖参数规范的方式完成。


如果有以下接口:

interface I1 { I1 M(); }
interface I2 { I2 M(); }
interface I3: I1, I2 { override I3 M(); }

请注意,在 I3中,方法 I1.M()I2.M() 已“合并”。 在实现 I3 时,有必要同时实现它们。

一般来说,我们需要一个显式实现来引用原始方法。 问题是,在类中

class C : I1, I2, I3
{
    C IN.M();
}

这是什么意思? N 应该是什么?

我建议允许实现 I1.MI2.M(但不能同时实施),并将其视为对两者的实现。

缺点

  • [ ] 每种语言更改都必须自行付费。
  • [ ] 我们应确保即使在继承层次较深的情况下,性能也是合理的
  • 我们应该确保翻译策略的成果不会影响语言的语义,即使在使用旧编译器产生的新 IL 时也是如此。

替代方案

我们可以稍微放宽语言规则,在源代码中允许,

// Possible alternative. This was not implemented.
abstract class Cloneable
{
    public abstract Cloneable Clone();
}

class Digit : Cloneable
{
    public override Cloneable Clone()
    {
        return this.Clone();
    }

    public new Digit Clone() // Error: 'Digit' already defines a member called 'Clone' with the same parameter types
    {
        return this;
    }
}

未解决的问题

  • [ ] 已编译使用此功能的 API 将如何在旧版本的语言中运行?

设计会议