重写方法上的 C# 可选参数

本文关键字:参数 方法 重写 | 更新日期: 2023-09-27 18:20:52

似乎在.NET Framework中,当您重写该方法时,可选参数存在问题。以下代码的输出为:"咔嚓"啊".但我期望的输出是:"咔嚓"咔嚓".有没有解决方案。我知道它可以通过方法重载来解决,但想知道其中的原因。此外,代码在单声道中运行良好。

class Program
{
    class AAA
    {
        public virtual void MyMethod(string s = "aaa")
        {
            Console.WriteLine(s);
        }
        public virtual void MyMethod2()
        {
            MyMethod();
        }
    }
    class BBB : AAA
    {
        public override void MyMethod(string s = "bbb")
        {
            base.MyMethod(s);
        }
        public override void MyMethod2()
        {
            MyMethod();
        }
    }
    static void Main(string[] args)
    {
        BBB asd = new BBB();
        asd.MyMethod();
        asd.MyMethod2();
    }
}

重写方法上的 C# 可选参数

您可以通过调用以下内容来消除歧义:

this.MyMethod();

(MyMethod2()(

它是否是一个错误是棘手的;不过,它看起来确实不一致。ReSharper 警告您不要在覆盖中更改默认值,如果这有助于;p当然,ReSharper 告诉您this.是多余的,并提出为您删除它......这改变了行为 - 所以ReSharper也不完美。

看起来它确实可能有资格成为编译器错误,我会授予您。我需要非常仔细地查看才能确定...当你需要埃里克时,他在哪里,嗯?

<小时 />

编辑:

这里的关键点是语言规范;让我们看一下§7.5.3:

例如,方法调用的候选集合不包括标记为 override 的方法 (§7.4(,如果派生类中的任何方法适用,则基类中的方法不是候选方法(§7.6.5.1(。

(事实上,§7.4 显然省略了override方法的考虑(

这里有一些冲突。它指出,如果派生类中有适用的方法,则不使用基本方法 - 这将引导我们找到派生方法,但同时,它说不考虑标记为override的方法。

但是,§7.5.1.1接着指出:

对于类中

定义的虚拟方法和索引器,参数列表是从函数成员的最具体的声明或重写中选取的,从接收器的静态类型开始,并搜索其基类。

然后 §7.5.1.2 解释了在调用时如何计算这些值:

在函数成员调用 (§7.5.4( 的运行时处理期间,参数列表的表达式或变量引用按从左到右的顺序计算,如下所示:

。(截图(...

当具有相应可选参数的函数成员中省略参数时,将隐式传递函数成员声明的默认参数。由于这些参数始终是常量,因此它们的计算不会影响其余参数的计算顺序。

这明确地强调了它正在查看参数列表,该列表之前在 §7.5.1.1 中定义为来自最具体的声明或覆盖。这是 §7.5.1.2 中引用的"方法声明"似乎是合理的,因此传递的值应该从最派生到静态类型。

这表明:csc 有一个错误,它应该使用派生版本("bbb bbb"(,除非它被限制(通过 base. 或强制转换为基类型(查看基方法声明 (§7.6.8(。

这里需要注意的一件事是,每次都会调用被覆盖的版本。将覆盖更改为:

public override void MyMethod(string s = "bbb")
{
  Console.Write("derived: ");
  base.MyMethod(s);
}

输出为:

derived: bbb
derived: aaa

类中的方法可以执行以下一项或两项操作:

  1. 它定义了要调用的其他代码的接口。
  2. 它定义在调用时要执行的实现。

它可能不会同时执行这两项操作,因为抽象方法仅执行前者。

BBB内,调用MyMethod()调用 AAA定义的方法。

因为 BBB 中存在覆盖,所以调用该方法会导致BBB的实现被调用。

现在,AAA中的定义告知调用代码两件事(嗯,其他一些在这里无关紧要的事情(。

  1. 签名void MyMethod(string) .
  2. (对于那些支持它的语言(单个参数的默认值是"aaa",因此在编译表单的代码时MyMethod()如果找不到与MyMethod()匹配的方法,您可以将其替换为对 'MyMethod("aaa"( 的调用。

所以,这就是BBB中的调用所做的:编译器看到对MyMethod()的调用,没有找到方法MyMethod()但确实找到了方法MyMethod(string)。它还看到在定义它的地方有一个默认值"aaa",因此在编译时它将其更改为对MyMethod("aaa")的调用。

BBB内部来看,AAA被认为是定义AAA方法的地方,即使在BBB中被覆盖,所以它们可以被覆盖。

在运行时,MyMethod(string) 使用参数"aaa"调用。因为有一个被重写的表单,那就是调用的表单,但它不是用"bbb"调用的,因为该值与运行时实现无关,而是与编译时定义有关。

添加this.会更改要检查的定义,从而更改调用中使用的参数。

编辑:为什么这对我来说似乎更直观。

就个人而言,由于我谈论的是直觉,它只能是个人的,我发现这更直观,原因如下:

如果我BBB编码,那么无论是调用还是覆盖MyMethod(string),我都会认为这是"做AAA的事情"——这是BBB对"做AAA的事情"的看法,但它在做AAA事情都是一样的。因此,无论是调用还是覆盖,我都会意识到定义MyMethod(string)的是AAA

如果我调用使用BBB的代码,我会想到"使用BBB的东西"。我可能不太清楚最初在AAA中定义了哪个,我可能会认为这只是一个实现细节(如果我没有使用附近的AAA接口(。

编译器的行为符合我的直觉,这就是为什么当第一次阅读问题时,在我看来 Mono 有一个错误。经过考虑,我看不出它们如何比另一个更好地满足指定的行为。

就此而言,在保持个人水平的同时,我永远不会将可选参数与抽象、虚拟或覆盖的方法一起使用,如果覆盖其他人的参数,我会匹配他们的参数。

对我来说看起来像一个错误。我相信它很明确,并且它的行为方式应该与您调用具有显式this前缀的方法。

我已将示例简化为仅使用单个虚拟方法,并显示调用的实现和参数值是什么:

using System;
class Base
{
    public virtual void M(string text = "base-default")
    {
        Console.WriteLine("Base.M: {0}", text);
    }   
}
class Derived : Base
{
    public override void M(string text = "derived-default")
    {
        Console.WriteLine("Derived.M: {0}", text);
    }
    public void RunTests()
    {
        M();      // Prints Derived.M: base-default
        this.M(); // Prints Derived.M: derived-default
        base.M(); // Prints Base.M: base-default
    }
}
class Test
{
    static void Main()
    {
        Derived d = new Derived();
        d.RunTests();
    }
}

因此,我们只需要担心 RunTest 中的三个调用。规范中前两个调用的重要部分是7.5.1.1,其中谈到了查找相应参数时要使用的参数列表:

对于类中定义的虚拟方法和索引器,参数 列表是从最具体的声明或重写中选取的 函数成员,从 接收器,并搜索其基类。

以及第 7.5.1.2 节:

当具有相应可选参数的函数成员中省略参数时,将隐式传递函数成员声明的默认参数。

"相应的可选参数"是将 7.5.2 连接到 7.5.1.1 的位。

对于M()this.M(),该参数列表应为Derived作为接收器的静态类型的一个是Derived,事实上,你可以说编译器对待作为编译中前面的参数列表,就好像你使参数在 Derived.M()成为必填项两者都调用失败 - 因此M()调用要求参数具有Derived 中的默认值,但随后忽略它!

事实上,情况变得更糟:如果您为参数在Derived但使其在Base中强制,调用 M()最终使用 null 作为参数值。如果不出意外,我会说这证明这是一个错误:null价值不会来从任何地方有效。(由于这是默认值,因此nullstring类型的值;它始终只使用默认值作为参数类型。

规范的第 7.6.8 节涉及基础。M((,它说除了非虚拟行为外,还考虑了表达如((Base) this).M() ;所以对于基本方法来说是完全正确的用于确定有效参数列表。这意味着最后一行是正确的。

只是为了让任何想要查看上面描述的真正奇怪的错误的人更容易,其中使用了未在任何地方指定的值:

using System;
class Base
{
    public virtual void M(int x)
    {
        // This isn't called
    }   
}
class Derived : Base
{
    public override void M(int x = 5)
    {
        Console.WriteLine("Derived.M: {0}", x);
    }
    public void RunTests()
    {
        M();      // Prints Derived.M: 0
    }
    static void Main()
    {
        new Derived().RunTests();
    }
}

你试过吗:

 public override void MyMethod2()
    {
        this.MyMethod();
    }

因此,您实际上告诉您的程序使用重写的方法。

这种行为肯定非常奇怪;我不清楚它是否实际上是编译器中的错误,但可能是。

昨晚校园下了相当多的雪,西雅图不太擅长处理雪。我的公共汽车今天早上没有运行,所以我无法进入办公室比较C#4,C#5和Roslyn对此案例的看法以及他们是否不同意。一旦我回到办公室,我将尝试在本周晚些时候发布分析,并且可以使用适当的调试工具。

这可能是由于歧义,编译器优先考虑基/超类。下面对类 BBB 的代码进行了更改,并添加了对this关键字的引用,给出了输出"bbb bbb":

class BBB : AAA
{
    public override void MyMethod(string s = "bbb")
    {
        base.MyMethod(s);
    }
    public override void MyMethod2()
    {
        this.MyMethod(); //added this keyword here
    }
}

它暗示的一件事是,作为最佳实践,每当在类的当前实例上调用属性或方法时,都应始终使用 this 关键字。

如果 base 和 child 方法中的这种歧义甚至没有引发编译器警告(如果不是错误(,我会担心,但如果它确实如此,那么我想那是看不见的。

====

======================================================================

编辑:考虑以下来自这些链接的示例摘录:

http://geekswithblogs.net/BlackRabbitCoder/archive/2011/07/28/c.net-little-pitfalls-default-parameters-are-compile-time-substitutions.aspx

http://geekswithblogs.net/BlackRabbitCoder/archive/2010/06/17/c-optional-parameters---pros-and-pitfalls.aspx

陷阱:可选参数值是编译时使用可选参数时,只有一件事要记住。 如果您牢记这一点,那么您很可能会很好地理解并避免使用它们的任何潜在陷阱:有一件事是这样的:可选参数是编译时,语法糖!

陷阱:当心继承和接口实现中的默认参数

现在,第二个潜在的陷阱与继承和接口实现有关。 我将用一个谜题来说明:

   1: public interface ITag 
   2: {
   3:     void WriteTag(string tagName = "ITag");
   4: } 
   5:  
   6: public class BaseTag : ITag 
   7: {
   8:     public virtual void WriteTag(string tagName = "BaseTag") { Console.WriteLine(tagName); }
   9: } 
  10:  
  11: public class SubTag : BaseTag 
  12: {
  13:     public override void WriteTag(string tagName = "SubTag") { Console.WriteLine(tagName); }
  14: } 
  15:  
  16: public static class Program 
  17: {
  18:     public static void Main() 
  19:     {
  20:         SubTag subTag = new SubTag();
  21:         BaseTag subByBaseTag = subTag;
  22:         ITag subByInterfaceTag = subTag; 
  23:  
  24:         // what happens here?
  25:         subTag.WriteTag();       
  26:         subByBaseTag.WriteTag(); 
  27:         subByInterfaceTag.WriteTag(); 
  28:     }
  29: } 

会发生什么? 好吧,即使每种情况下的对象都是子标签,其标签是"子标签",你也会得到:

1:子标签 2:基本标签 3: 伊塔格

但请记住确保您:

不要在现有默认参数集的中间插入新的默认参数,这可能会导致不可预测的行为,这些行为不一定会引发语法错误 - 添加到列表末尾或创建新方法。在继承层次结构和接口中使用默认参数时要格外小心 – 选择最合适的级别以根据预期使用情况添加默认值。

===

=============================================================================================================================================================================================================================
我认为

这是因为这些默认值在编译时是固定的。 如果您使用反射器,您将在BBB中看到MyMethod2的以下内容。

public override void MyMethod2()
{
    this.MyMethod("aaa");
}

总体上同意@Marc Gravell。

但是,我想提一下,这个问题在C++世界中已经足够古老了(http://www.devx.com/tips/Tip/12737(,答案看起来像"与在运行时解决的虚函数不同,默认参数是静态解决的,即在编译时。因此,由于一致性,这种 C# 编译器行为被故意接受,尽管它似乎出乎意料。

无论哪种方式都需要修复

我肯定会将其视为错误,要么是因为结果是错误的,要么是预期的结果,那么编译器不应该让您将其声明为"覆盖",或者至少提供警告。

我建议您向Microsoft.Connect报告此事

但这是对还是错?

但是,关于这是否是预期的行为,让我们首先分析一下关于它的两种观点。

考虑我们有以下代码:

void myfunc(int optional = 5){ /* Some code here*/ } //Function implementation
myfunc(); //Call using the default arguments

有两种方法可以实现它:

  1. 该可选参数被视为重载函数,导致以下结果:

    void myfunc(int optional){ /* Some code here*/ } //Function implementation
    void myfunc(){ myfunc(5); } //Default arguments implementation
    myfunc(); //Call using the default arguments
    
  2. 默认值嵌入在调用方中,从而生成以下代码:

    void myfunc(int optional){ /* Some code here*/ } //Function implementation
    myfunc(5); //Call and embed default arguments
    

这两种方法之间存在许多差异,但我们首先要看一下 .Net 框架如何解释它。

    在 .Net 中,只能使用包含相同数量参数的方法重写方法,
  1. 但不能使用包含更多参数的方法重写,即使它们都是可选的(这将导致调用具有与重写方法相同的签名(,例如你有:

    class bassClass{ public virtual void someMethod()}
    class subClass :bassClass{ public override void someMethod()} //Legal
    //The following is illegal, although it would be called as someMethod();
    //class subClass:bassClass{ public override void someMethod(int optional = 5)} 
    
  2. 你可以用另一个没有参数的方法重载一个带有默认参数的方法(这会产生灾难性的影响,因为我稍后会讨论(,所以下面的代码是合法的:

    void myfunc(int optional = 5){ /* Some code here*/ } //Function with default
    void myfunc(){ /* Some code here*/ } //No arguments
    myfunc(); //Call which one?, the one with no arguments!
    
  3. 使用反射时,必须始终提供默认值。

所有这些都足以证明 .Net 采用了第二次实现,因此 OP 看到的行为是正确的,至少根据 .Net 的说法。

.Net 方法的问题

但是,.Net方法确实存在问题。

  1. 一致性

    • 就像在继承的方法中重写默认值时 OP 的问题一样,结果可能是不可预测的

    • 当默认值的原始植入发生更改时,并且由于调用方不必重新编译,我们最终可能会得到不再有效的默认值

    • 反射要求您提供默认值,调用方不必知道该默认值
  2. 破解代码

    • 当我们有一个带有默认参数的函数,后来我们添加一个没有参数的函数时,所有调用现在都将路由到新函数,从而破坏所有现有代码,没有任何通知或警告!

    • 类似的情况也会发生,如果我们稍后拿走没有参数的函数,那么所有调用将自动路由到带有默认参数的函数,同样没有通知或警告! 虽然这可能不是程序员的意图

    • 此外,它不一定是常规实例方法,扩展方法将
    • 解决相同的问题,因为没有参数的扩展方法将优先于具有默认参数的实例方法!

摘要:远离可选参数,改用重载(就像 .NET 框架本身所做的那样(