题 虚构成员在构造函数中调用


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

为什么这不该做?


1151
2017-09-23 07:11


起源


@ m.edmondson,说真的..你的评论应该是答案。虽然格雷格的解释是正确的,但在我阅读你的博客之前我并不理解。 - Rosdi Kasim
@ m.edmondson您的域名已过期。 - Robert Noack
你现在可以在这里找到@ m.edmondson的文章: codeproject.com/Articles/802375/... - SpeziFish


答案:


构造用C#编写的对象时,会发生的情况是初始化程序按从最派生类到基类的顺序运行,然后构造函数按从基类到最派生类的顺序运行(请参阅Eric Lippert的博客,了解其原因)。

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

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

当然,如果将类标记为已密封以确保它是继承层次结构中派生类型最多的类型,则可以缓解此问题 - 在这种情况下,调用虚方法是完全安全的。


1041
2017-09-23 07:21



格雷格,请告诉我,为什么有VALTUAL成员[在DERIVED课程中覆盖]时会有一个SEALED(不能是INHERITED)类? - Paul Pacurar
如果你想确保派生类不能进一步派生,那么密封它是完全可以接受的。 - Øyvind
@Paul - 关键是已经完成了派生的虚拟成员 基础 class [es],因此将类标记为您想要的完全派生类。 - ljs
@Greg如果虚方法的行为与实例变量无关,那不行吗?似乎我们应该能够声明虚方法不会修改实例变量? (静态?)例如,如果您想要一个可以重写的虚方法来实例化更多派生类型。这对我来说似乎是安全的,并不保证这个警告。 - Dave Cousineau
@PaulPacurar - 如果你想在派生程度最高的类中调用虚方法,你仍会收到警告,而你知道它不会导致问题。在这种情况下,您可以通过密封该类来与系统分享您的知识。 - Revolutionair


为了回答你的问题,请考虑以下问题:下面的代码将在什么时候打印出来 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 将被抛出,因为 foo 一片空白。 在自己的构造函数之前调用对象的基础构造函数。通过拥有 virtual 在对象的构造函数中调用,您正在介绍继承对象在完全初始化之前执行代码的可能性。


480
2017-09-23 07:17



比标记的答案更好的答案。 - Hele
这比上面的答案更清楚。示例代码值得千言万语。 - Novice in.NET


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之间有如此不同。你的程序员可能不知道会发生什么!


154
2017-09-23 07:36



格雷格比奇的答案,虽然不幸的是没有像我的答案那样高,但我觉得这是更好的答案。它肯定有一些我没有花时间包含的更有价值的解释性细节。 - Lloyd
实际上Java中的规则是相同的。 - OlegYch
@JoãoPortelaC++实际上非常不同。构造函数(和析构函数!)中的虚方法调用使用当前构造的类型(和vtable)解析,而不是Java和C#都可以解析的派生类型。 这是相关的FAQ条目。 - Jacek Sieka
@JacekSieka你是绝对正确的。自从我用C ++编写代码以来,我已经有一段时间了。我应该删除评论以避免混淆其他人吗? - João Portela
有一种重要的方式,C#与Java和VB.NET都不同;在C#中,在声明点初始化的字段将在基本构造函数调用之前处理它们的初始化;这样做是为了允许派生类对象可以从构造函数中使用,但不幸的是,这种能力仅适用于初始化不受任何派生类参数控制的派生类特性。 - supercat


已经描述了警告的原因,但是如何修复警告?你必须密封班级或虚拟成员。

  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();
    }
  }

77
2017-09-23 13:20





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

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


16
2017-09-23 07:21





上面有很好的答案,为什么你 不会 想要那样做。这是一个反例,或许你  我想这样做(从C#转换成C# Ruby中实用的面向对象设计 作者:Sandi Metz,p。 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 { }

11
2017-12-28 01:19



我会考虑使用工厂方法。 - Ian Ringrose
我希望.NET Framework具有,而不是包括大多数无用的 Finalize 作为默认成员 Object,曾用过那个vtable插槽 ManageLifetime(LifetimeStatus) 当构造函数返回到客户端代码,构造函数抛出时,或者发现某个对象被放弃时,将调用的方法。大多数需要从基类构造函数调用虚方法的场景最好使用两阶段构造来处理,但两阶段构造应该表现为实现细节,而不是客户端调用第二阶段的要求。 - supercat
但是,这个代码可能会出现问题,就像这个线程中显示的任何其他情况一样; GetDependency 不保证以前调用是安全的 MySubClass 构造函数已被调用。此外,默认情况下实例化默认依赖项不是您所谓的“纯DI”。 - Groo
该示例执行“依赖性排除”。 ;-)对我来说,这是从构造函数调用虚拟方法的另一个很好的反例。 SomeDependency不再在MySubClass派生中实例化,导致依赖于SomeDependency的每个MyClass特性的行为中断。 - Nachbars Lumpi


是的,在构造函数中调用虚方法通常很糟糕。

此时,对象可能尚未完全构建,并且方法所预期的不变量可能尚未成立。


5
2017-09-23 07:15