【问题标题】:Virtual member call in a constructor构造函数中的虚拟成员调用
【发布时间】:2010-09-12 06:44:48
【问题描述】:

我从 ReSharper 收到关于从我的对象构造函数调用虚拟成员的警告。

为什么不能这样做?

【问题讨论】:

  • @m.edmondson,说真的……您的评论应该是这里的答案。虽然格雷格的解释是正确的,但直到我读了你的博客我才明白。
  • 您现在可以在这里找到来自@m.edmondson 的文章:codeproject.com/Articles/802375/…

标签: c# constructor warnings resharper virtual-functions


【解决方案1】:

当一个用C#写的对象被构​​造时,会发生初始化器从最派生类到基类的顺序运行,然后构造函数从基类到最派生类的顺序运行(see Eric Lippert's blog for details as to why this is )。

同样在 .NET 中,对象在构造时不会更改类型,而是从最派生的类型开始,方法表是最派生的类型。这意味着虚方法调用总是在最派生的类型上运行。

当你结合这两个事实时,你会遇到一个问题,如果你在构造函数中调用虚方法,并且它不是继承层次结构中最派生的类型,那么它将在一个构造函数的类上调用尚未运行,因此可能不适合调用该方法。

当然,如果您将类标记为密封以确保它是继承层次结构中派生程度最高的类型,那么这个问题当然会得到缓解——在这种情况下,调用虚方法是完全安全的。

【讨论】:

  • Greg,请告诉我,当它具有 VIRTUAL 成员 [即在 DERIVED 类中覆盖] 时,为什么会有一个类 SEALED(不能被继承)?
  • 如果你想确保派生类不能被进一步派生,密封它是完全可以接受的。
  • @Paul - 关键是已经完成了 base 类[es] 的虚拟成员的派生,因此将该类标记为您希望的完全派生是。
  • @Greg 如果虚拟方法的行为与实例变量无关,这不好吗?似乎我们应该能够声明一个虚拟方法不会修改实例变量? (静态?)例如,如果您想要一个可以被覆盖的虚拟方法来实例化一个更派生的类型。这对我来说似乎是安全的,并且不需要此警告。
  • @PaulPacurar - 如果您想在最派生的类中调用虚方法,您仍然会收到警告,但您知道它不会导致问题。在这种情况下,您可以通过密封该课程与系统分享您的知识。
【解决方案2】:

为了回答您的问题,请考虑以下问题:当Child 对象被实例化时,下面的代码会打印出什么?

class Parent
{
    public Parent()
    {
        DoSomething();
    }

    protected virtual void DoSomething() 
    {
    }
}

class Child : Parent
{
    private string foo;

    public Child() 
    { 
        foo = "HELLO"; 
    }

    protected override void DoSomething()
    {
        Console.WriteLine(foo.ToLower()); //NullReferenceException!?!
    }
}

答案是事实上NullReferenceException 会被抛出,因为foo 是空的。 对象的基本构造函数在其自己的构造函数之前调用。通过在对象的构造函数中调用virtual,您将引入继承对象在完全初始化之前执行代码的可能性。

【讨论】:

  • 这比上面的答案更清楚。一个示例代码值一千字。
  • 我认为就地初始化foo(如private string foo="INI";)会使foo 确实被初始化更清楚。 (而不是一些未初始化状态)。
  • 展示危险的绝佳例子。但是,为了演示这种情况的安全变体,如果 DoSomething() 只是执行 Console.WriteLine("hello"); 而不访问任何局部变量,则没有问题。
【解决方案3】:

C# 的规则与 Java 和 C++ 的规则有很大的不同。

当您在 C# 中某个对象的构造函数中时,该对象以完全初始化(只是不是“构造”)的形式存在,作为它的完全派生类型。

namespace Demo
{
    class A 
    {
      public A()
      {
        System.Console.WriteLine("This is a {0},", this.GetType());
      }
    }

    class B : A
    {      
    }

    // . . .

    B b = new B(); // Output: "This is a Demo.B"
}

这意味着如果你从 A 的构造函数中调用一个虚函数,它将解析为 B 中的任何覆盖(如果提供了)。

即使您故意这样设置 A 和 B,充分了解系统的行为,您以后也可能会大吃一惊。假设您在 B 的构造函数中调用了虚函数,“知道”它们将由 B 或 A 酌情处理。然后时间过去了,其他人决定他们需要定义 C,并覆盖那里的一些虚函数。突然之间,B 的构造函数最终调用了 C 中的代码,这可能会导致非常令人惊讶的行为。

无论如何,在构造函数中避免使用虚函数可能是个好主意,因为 C#、C++ 和 Java 之间的规则如此不同。您的程序员可能不知道会发生什么!

【讨论】:

  • Greg Beech 的回答,虽然很遗憾没有我的回答那么高,但我觉得是更好的回答。它肯定有一些更有价值的解释性细节,我没有花时间包括在内。
  • 其实Java中的规则是一样的。
  • @JoãoPortela C++ 实际上非常不同。构造函数(和析构函数!)中的虚拟方法调用是使用当前正在构造的类型(和 vtable)解析的,而不是 Java 和 C# 都使用的最派生类型。 Here is the relevant FAQ entry.
  • @JacekSieka 你是绝对正确的。自从我用 C++ 编码以来已经有一段时间了,我不知何故混淆了这一切。我应该删除评论以避免混淆其他人吗?
  • C# 与 Java 和 VB.NET 的显着不同之处在于:在 C# 中,在声明点初始化的字段将在基构造函数调用之前处理其初始化;这样做是为了让派生类对象可以从构造函数中使用,但不幸的是,这种能力仅适用于其初始化不受任何派生类参数控制的派生类功能。
【解决方案4】:

已经描述了警告的原因,但是您将如何解决警告?您必须密封类或虚拟成员。

  class B
  {
    protected virtual void Foo() { }
  }

  class A : B
  {
    public A()
    {
      Foo(); // warning here
    }
  }

你可以封A级:

  sealed class A : B
  {
    public A()
    {
      Foo(); // no warning
    }
  }

或者你可以密封方法Foo:

  class A : B
  {
    public A()
    {
      Foo(); // no warning
    }

    protected sealed override void Foo()
    {
      base.Foo();
    }
  }

【讨论】:

  • 或者可以在类 A 的构造函数中明确说:A() { base.Foo(); } 然后基类 BFoo() 将始终在 A 的构造函数中调用。
【解决方案5】:

在 C# 中,基类的构造函数派生类的构造函数之前运行,因此派生类可能在可能被覆盖的虚拟成员中使用的任何实例字段都尚未初始化。 p>

请注意,这只是一个警告,让您注意并确保一切正常。这种情况有实际的用例,您只需要记录虚拟成员的行为,它不能使用在调用它的构造函数所在的派生类中声明的任何实例字段。

【讨论】:

    【解决方案6】:

    上面有很好的答案说明您不想为什么要这样做。这是一个反例,也许您 想要这样做(Sandi Metz 从Practical Object-Oriented Design in Ruby 翻译成C#,第126 页)。

    请注意,GetDependency() 没有触及任何实例变量。如果静态方法可以是虚拟的,它就是静态的。

    (公平地说,通过依赖注入容器或对象初始化器可能有更聪明的方法......)

    public class MyClass
    {
        private IDependency _myDependency;
    
        public MyClass(IDependency someValue = null)
        {
            _myDependency = someValue ?? GetDependency();
        }
    
        // If this were static, it could not be overridden
        // as static methods cannot be virtual in C#.
        protected virtual IDependency GetDependency() 
        {
            return new SomeDependency();
        }
    }
    
    public class MySubClass : MyClass
    {
        protected override IDependency GetDependency()
        {
            return new SomeOtherDependency();
        }
    }
    
    public interface IDependency  { }
    public class SomeDependency : IDependency { }
    public class SomeOtherDependency : IDependency { }
    

    【讨论】:

    • 我会考虑为此使用工厂方法。
    • 我希望 .NET Framework 拥有,而不是将大部分无用的 Finalize 作为 Object 的默认成员,而是将 vtable 插槽用于 ManageLifetime(LifetimeStatus) 方法,该方法将被调用当构造函数返回客户端代码时、构造函数抛出时或发现对象被放弃时。大多数需要从基类构造函数调用虚拟方法的场景最好使用两阶段构造来处理,但两阶段构造应该表现为实现细节,而不是客户端调用第二阶段的要求。跨度>
    • 不过,这个代码可能会出现问题,就像这个线程中显示的任何其他情况一样;在调用MySubClass 构造函数之前,不能保证GetDependency 可以安全调用。此外,默认情况下实例化默认依赖项并不是您所说的“纯 DI”。
    • 这个例子做了“依赖输出”。 ;-) 对我来说,这是从构造函数调用虚拟方法的另一个很好的反例。 SomeDependency 不再在 MySubClass 派生中实例化,从而导致依赖于 SomeDependency 的每个 MyClass 功能的行为中断。
    【解决方案7】:

    是的,在构造函数中调用虚方法通常是不好的。

    此时,对象可能还没有完全构建,方法所期望的不变量可能还不成立。

    【讨论】:

      【解决方案8】:

      一个重要的缺失是,解决此问题的正确方法是什么?

      作为Greg explained,这里的根本问题是基类构造函数会在构造派生类之前调用​​虚拟成员。

      以下代码取自MSDN's constructor design guidelines,演示了这个问题。

      public class BadBaseClass
      {
          protected string state;
      
          public BadBaseClass()
          {
              this.state = "BadBaseClass";
              this.DisplayState();
          }
      
          public virtual void DisplayState()
          {
          }
      }
      
      public class DerivedFromBad : BadBaseClass
      {
          public DerivedFromBad()
          {
              this.state = "DerivedFromBad";
          }
      
          public override void DisplayState()
          {   
              Console.WriteLine(this.state);
          }
      }
      

      创建DerivedFromBad 的新实例时,基类构造函数调用DisplayState 并显示BadBaseClass,因为派生构造函数尚未更新该字段。

      public class Tester
      {
          public static void Main()
          {
              var bad = new DerivedFromBad();
          }
      }
      

      改进的实现从基类构造函数中删除了虚方法,并使用了Initialize 方法。创建DerivedFromBetter 的新实例会显示预期的“DerivedFromBetter”

      public class BetterBaseClass
      {
          protected string state;
      
          public BetterBaseClass()
          {
              this.state = "BetterBaseClass";
              this.Initialize();
          }
      
          public void Initialize()
          {
              this.DisplayState();
          }
      
          public virtual void DisplayState()
          {
          }
      }
      
      public class DerivedFromBetter : BetterBaseClass
      {
          public DerivedFromBetter()
          {
              this.state = "DerivedFromBetter";
          }
      
          public override void DisplayState()
          {
              Console.WriteLine(this.state);
          }
      }
      

      【讨论】:

      • 嗯,我认为 DerivedFromBetter 构造函数隐式调用 BetterBaseClass 构造函数。上面的代码应该等价于 public DerivedFromBetter() : base(),所以 intialize 会被调用两次
      • 您可以在 BetterBaseClass 类中定义一个受保护的构造函数,该构造函数具有一个额外的bool initialize 参数,该参数确定是否在基本构造函数中调用Initialize。然后派生的构造函数将调用base(false) 以避免调用Initialize 两次
      • @user1778606:绝对!我已经用你的观察解决了这个问题。谢谢!
      • @GustavoMori 这不起作用。在 DerivedFromBetter 构造函数运行之前,基类仍然调用 DisplayState,因此它输出“BetterBaseClass”。
      【解决方案9】:

      因为在构造函数完成执行之前,对象还没有完全实例化。虚函数引用的任何成员都可能不会被初始化。在 C++ 中,当您在构造函数中时,this 仅指您所在构造函数的静态类型,而不是正在创建的对象的实际动态类型。这意味着虚函数调用甚至可能不会去你期望的地方。

      【讨论】:

        【解决方案10】:

        您的构造函数可以(稍后,在您的软件的扩展中)从覆盖虚方法的子类的构造函数中调用。现在不是子类的函数实现,而是基类的实现会被调用。所以在这里调用虚函数真的没有意义。

        但是,如果您的设计满足 Liskov 替换原则,则不会造成任何损害。可能这就是它被容忍的原因 - 警告,而不是错误。

        【讨论】:

          【解决方案11】:

          其他答案尚未解决的这个问题的一个重要方面是基类从其构造函数中调用虚拟成员是安全的如果这是派生类期望它做的事情。在这种情况下,派生类的设计者负责确保在构造完成之前运行的任何方法在这种情况下都尽可能地表现得尽可能合理。例如,在 C++/CLI 中,构造函数被包装在代码中,如果构造失败,它将在部分构造的对象上调用 Dispose。在这种情况下调用Dispose 通常是防止资源泄漏所必需的,但Dispose 方法必须为运行它们的对象可能尚未完全构造的可能性做好准备。

          【讨论】:

            【解决方案12】:

            该警告提醒您,派生类可能会覆盖虚拟成员。在这种情况下,父类对虚拟成员所做的任何事情都将通过覆盖子类来撤消或更改。为了清楚起见,看一下这个小例子

            下面的父类尝试在其构造函数上为虚拟成员设置值。这将触发 Re-sharper 警告,让我们看看代码:

            public class Parent
            {
                public virtual object Obj{get;set;}
                public Parent()
                {
                    // Re-sharper warning: this is open to change from 
                    // inheriting class overriding virtual member
                    this.Obj = new Object();
                }
            }
            

            这里的子类覆盖了父属性。如果此属性未标记为虚拟,编译器会警告该属性隐藏了父类上的属性,并建议您添加“new”关键字(如果有意)。

            public class Child: Parent
            {
                public Child():base()
                {
                    this.Obj = "Something";
                }
                public override object Obj{get;set;}
            }
            

            最后对使用的影响,下例的输出放弃了父类构造函数设置的初始值。 这就是 Re-sharper 试图警告你的内容父类构造函数上设置的值可以被子类构造函数覆盖,子类构造函数在父类构造函数之后调用。

            public class Program
            {
                public static void Main()
                {
                    var child = new Child();
                    // anything that is done on parent virtual member is destroyed
                    Console.WriteLine(child.Obj);
                    // Output: "Something"
                }
            } 
            

            【讨论】:

            • 没有“父”和“子”类,只有“基”和“派生”。
            【解决方案13】:

            谨防盲目听从 Resharper 的建议,将课程封印! 如果它是 EF Code First 中的模型,它将删除 virtual 关键字,这将禁用延迟加载它的关系。

                public **virtual** User User{ get; set; }
            

            【讨论】:

              【解决方案14】:

              在这种特定情况下,C++ 和 C# 之间存在差异。 在 C++ 中,对象未初始化,因此在构造函数中调用虚拟函数是不安全的。 在 C# 中,当创建类对象时,其所有成员都初始化为零。可以在构造函数中调用虚函数,但如果您可能访问仍然为零的成员。如果您不需要访问成员,那么在 C# 中调用虚函数是非常安全的。

              【讨论】:

              • 在C++的构造函数中不禁止调用虚函数。
              • 同样的论点适用于 C++,如果你不需要访问成员,你不在乎他们没有被初始化......
              • 没有。当您在 C++ 的构造函数中调用虚方法时,它不会调用最深的覆盖实现,而是与当前类型关联的版本。它被虚拟调用,但就像在当前类的一种类型上一样 - 您无权访问派生类的方法和成员。
              【解决方案15】:

              只是为了补充我的想法。如果在定义私有字段时总是初始化它,应该避免这个问题。至少下面的代码就像一个魅力:

              class Parent
              {
                  public Parent()
                  {
                      DoSomething();
                  }
                  protected virtual void DoSomething()
                  {
                  }
              }
              
              class Child : Parent
              {
                  private string foo = "HELLO";
                  public Child() { /*Originally foo initialized here. Removed.*/ }
                  protected override void DoSomething()
                  {
                      Console.WriteLine(foo.ToLower());
                  }
              }
              

              【讨论】:

              • 我几乎从不这样做,因为如果你想进入构造函数,它会使调试更加困难。
              【解决方案16】:

              如果您想让子类能够设置或覆盖父构造函数将立即使用的属性,我认为忽略警告可能是合法的:

              internal class Parent
              {
                  public Parent()
                  {
                      Console.WriteLine("Parent ctor");
                      Console.WriteLine(Something);
                  }
              
                  protected virtual string Something { get; } = "Parent";
              }
              
              internal class Child : Parent
              {
                  public Child()
                  {
                      Console.WriteLine("Child ctor");
                      Console.WriteLine(Something);
                  }
              
                  protected override string Something { get; } = "Child";
              }
              

              这里的风险是子类从其构造函数中设置属性,在这种情况下,值的变化将在基类构造函数被调用之后发生。

              我的用例是我希望子类提供特定的值或实用程序类(例如转换器),并且我不想在基础上调用初始化方法。

              上面实例化子类时的输出是:

              Parent ctor
              Child
              Child ctor
              Child
              

              【讨论】:

                【解决方案17】:

                我只需将 Initialize() 方法添加到基类,然后从派生构造函数中调用它。该方法将在所有构造函数执行后调用任何虚拟/抽象方法/属性:)

                【讨论】:

                • 这会使警告消失但不能解决问题。当您添加更多派生类时,您遇到了与其他人解释的相同的问题。
                【解决方案18】:

                我发现的另一件有趣的事情是,ReSharper 错误可以通过执行以下类似的操作来“满足”,这对我来说是愚蠢的。但是,正如前面许多人所提到的,在构造函数中调用虚拟属性/方法仍然不是一个好主意。

                public class ConfigManager
                {
                   public virtual int MyPropOne { get; private set; }
                   public virtual string MyPropTwo { get; private set; }
                
                   public ConfigManager()
                   {
                    Setup();
                   }
                
                   private void Setup()
                   {
                    MyPropOne = 1;
                    MyPropTwo = "test";
                   }
                }
                

                【讨论】:

                • 你不应该找到解决方法,而是解决实际问题。
                • 我同意@alzaimar!我试图为面临类似问题并且不想实施上述解决方案的人留下选项,可能是由于一些限制。有了这个(正如我在上面的解决方法中提到的),我想指出的另一件事是,如果可能的话,ReSharper 也需要能够将此解决方法标记为错误。然而,它目前并没有,这可能导致两件事 - 他们忘记了这种情况,或者他们想故意将其排除在一些目前无法想到的有效用例中。
                • @adityap 要抑制警告,请使用警告抑制jetbrains.com/help/resharper/…
                猜你喜欢
                • 1970-01-01
                • 2010-10-02
                • 2015-06-27
                • 1970-01-01
                • 2014-09-13
                • 2019-05-06
                • 1970-01-01
                • 1970-01-01
                • 1970-01-01
                相关资源
                最近更新 更多