关于C:构造函数中的虚拟成员调用

Virtual member call in a constructor

我从Resharper得到一个关于从我的对象构造函数调用虚拟成员的警告。

为什么这是不该做的事?


构造用C编写的对象时,会发生的情况是,初始值设定项按顺序从最派生的类运行到基类,然后按顺序从基类运行到最派生的类(有关原因的详细信息,请参阅Eric Lippet的博客)。

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

当结合这两个事实时,您将面临这样一个问题:如果在构造函数中进行虚拟方法调用,而该方法不是其继承层次结构中最派生的类型,则将在其构造函数尚未运行的类上调用该方法,因此可能处于调用该方法的不合适状态。

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


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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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,您将引入继承对象在完全初始化之前执行代码的可能性。


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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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之间的规则是如此不同。你的程序员可能不知道该期待什么!


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

1
2
3
4
5
6
7
8
9
10
11
12
  class B
  {
    protected virtual void Foo() { }
  }

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

您可以密封A级:

1
2
3
4
5
6
7
  sealed class A : B
  {
    public A()
    {
      Foo(); // no warning
    }
  }

或者您可以密封方法foo:

1
2
3
4
5
6
7
8
9
10
11
12
  class A : B
  {
    public A()
    {
      Foo(); // no warning
    }

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

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

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


上面有很好的答案来解释为什么你不想这样做。下面是一个相反的例子,您可能希望这样做(从SandiMetz的Ruby中的面向对象实用设计翻译成C),第126页。

注意,GetDependency()不涉及任何实例变量。如果静态方法可以是虚拟的,那么它将是静态的。

(公平地说,通过依赖注入容器或对象初始值设定项可能有更聪明的方法来实现这一点…)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 { }


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

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


因为在构造函数完成执行之前,对象没有完全实例化。虚拟函数引用的任何成员都不能初始化。在C++中,当您在构造函数中时,EDCOX1(0)仅引用您所构建的构造函数的静态类型,而不是正在创建的对象的实际动态类型。这意味着虚拟函数调用可能无法达到预期的效果。


可以(稍后,在软件扩展中)从重写虚方法的子类的构造函数调用构造函数。现在不是子类的函数实现,而是将调用基类的实现。所以在这里调用一个虚拟函数是没有意义的。

但是,如果您的设计满足Liskov替换原则,则不会造成任何伤害。也许这就是为什么它被容忍的原因——警告,而不是错误。


这个问题的一个重要方面是,如果派生类希望它这样做,那么基类从其构造函数中调用虚拟成员是安全的。在这种情况下,派生类的设计人员负责确保在构造完成之前运行的任何方法在这种情况下都将像它们在这种情况下那样敏感地工作。例如,在C++/CLI中,构造函数被封装在代码中,如果构造失败,它将调用部分构造的对象上的EDCOX1×0。在这种情况下,调用Dispose通常是防止资源泄漏所必需的,但Dispose方法必须为运行它们的对象可能尚未完全构造的可能性做好准备。


一个重要的遗漏是,解决这个问题的正确方法是什么?

正如Greg所解释的,这里的根问题是基类构造函数将在构造派生类之前调用虚拟成员。

以下代码摘自msdn的构造函数设计指南,演示了这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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,因为该字段尚未被派生构造函数更新。

1
2
3
4
5
6
7
public class Tester
{
    public static void Main()
    {
        var bad = new DerivedFromBad();
    }
}

改进的实现将虚拟方法从基类构造函数中移除,并使用Initialize方法。创建DerivedFromBetter的新实例将显示预期的"派生自更好的"

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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);
    }
}


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

下面的父类试图在其构造函数上为虚拟成员设置值。这将触发更尖锐的警告,请参见代码:

1
2
3
4
5
6
7
8
9
10
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"关键字时添加该关键字。

1
2
3
4
5
6
7
8
public class Child: Parent
{
    public Child():base()
    {
        this.Obj ="Something";
    }
    public override object Obj{get;set;}
}

最后是对使用的影响,下面的示例的输出放弃了父类构造函数设置的初始值。这是更尖锐的警告,父类构造函数上设置的值将被子类构造函数覆盖,子类构造函数在父类构造函数之后立即调用。

1
2
3
4
5
6
7
8
9
10
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"
    }
}


注意不要盲目听从雷斯哈珀的建议,把班级封起来!如果它是ef代码中的一个模型,它将首先删除虚拟关键字,这将禁用延迟加载它的关系。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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());
    }
}


在这种特殊情况下,C++和C语言有区别。在C++中,对象没有初始化,因此调用构造函数中的Viutar函数是不安全的。在C中,当创建类对象时,其所有成员都初始化为零。可以在构造函数中调用虚函数,但如果您可以访问仍然为零的成员。如果您不需要访问成员,那么在C中调用虚拟函数是非常安全的。


我发现的另一件有趣的事情是,通过执行下面这种对我来说很愚蠢的操作,可以"满足"resharper错误(然而,正如许多之前提到的,调用ctor中的虚拟prop/methods仍然不是一个好主意。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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";
   }

}


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