ChatGPT解决这个技术问题 Extra ChatGPT

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

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

为什么这是不应该做的事情?

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

S
Simon Touchtech

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

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

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

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


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

为了回答您的问题,请考虑以下问题:实例化 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"); 而不访问任何局部变量,则存在 no 问题。
A
André Haupt

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# 中,在声明点初始化的字段将在基构造函数调用之前处理其初始化;这样做是为了允许派生类对象可以从构造函数中使用,但不幸的是,这种能力仅适用于其初始化不受任何派生类参数控制的派生类功能。
I
Ilya Ryzhenkov

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

  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 的构造函数中调用。
A
Alex Lyman

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

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


J
Josh Kodroff

上面有很好的答案说明您不想这样做的原因。这是一个反例,也许您 想要这样做(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 功能的行为中断。
D
David Pierre

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

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


1
1800 INFORMATION

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


G
Gustavo Mori

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

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 会被调用两次
您可以在具有附加 bool initialize 参数的 BetterBaseClass 类中定义受保护的构造函数,该参数确定是否在基本构造函数中调用 Initialize。然后派生的构造函数将调用 base(false) 以避免调用 Initialize 两次
@user1778606:绝对!我已经用你的观察解决了这个问题。谢谢!
@GustavoMori 这不起作用。在 DerivedFromBetter 构造函数运行之前,基类仍然调用 DisplayState,因此它输出“BetterBaseClass”。
x
xtofl

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

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


s
supercat

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


T
Tunaki

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

下面的父类尝试在其构造函数上将值设置为虚拟成员。这将触发 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"
    }
} 

没有“父”和“子”类,只有“基”和“派生”。
t
typhon04

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

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

Y
Yuval Peled

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


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

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

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

我几乎从不这样做,因为如果您想进入构造函数,它会使调试变得更加困难。
p
pasx

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

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

R
Ross

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


这会使警告消失,但不能解决问题。当您添加更多派生类时,您会遇到与其他人解释的相同的问题。
P
Pang

我发现的另一件有趣的事情是,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/…