构造函数中的虚拟成员调用

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

为什么这不是要做的事情?

答案

构造用 C#编写的对象时,发生的事情是初始化程序从最派生类到基类按顺序运行,然后构造函数从基类到最派生类按顺序运行( 有关详细信息,请参见 Eric Lippert 的博客)关于这是为什么 )。

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

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

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

为了回答您的问题,请考虑以下问题:实例化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为 null。 对象的基础构造函数在其自己的构造函数之前被调用 。通过在对象的构造函数中进行virtual调用,您将引入一种可能性,即继承对象将在代码完全初始化之前执行代码。

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

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

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

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

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

上面有为什么你希望这样做,写得很好的答案。这是一个反例,您可能想这样做(Sandi Metz 从 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 { }

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

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

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

可以从覆盖虚拟方法的子类的构造函数中调用构造函数(此后,在软件的扩展中)。现在,不是子类的函数实现,而是基类的实现将被调用。因此,在这里调用虚函数实际上没有任何意义。

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

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