32

我知道从基类构造函数调用虚拟方法可能很危险,因为子类可能不处于有效状态。(至少在 C# 中)

我的问题是,如果虚拟方法是初始化对象状态的方法呢?这是一种好的做法还是应该是一个两步过程,首先创建对象然后加载状态?

第一个选项:(使用构造函数初始化状态)

public class BaseObject {
    public BaseObject(XElement definition) {
        this.LoadState(definition);
    }

    protected abstract LoadState(XElement definition);
}

第二种选择:(使用两步过程)

public class BaseObject {
    public void LoadState(XElement definition) {
        this.LoadStateCore(definition);
    }

    protected abstract LoadStateCore(XElement definition);
}

在第一种方法中,代码的使用者可以使用一条语句创建和初始化对象:

// The base class will call the virtual method to load the state.
ChildObject o = new ChildObject(definition)

在第二种方法中,消费者必须创建对象然后加载状态:

ChildObject o = new ChildObject();
o.LoadState(definition);
4

9 回答 9

39

(这个答案适用于 C# 和 Java。我相信 C++ 在这个问题上的工作方式不同。)

在构造函数中调用虚方法确实很危险,但有时它会以最干净的代码结束。

我会尽可能地避免它,但不会极大地弯曲设计。(例如,“稍后初始化”选项禁止不变性。)如果您确实在构造函数中使用了虚拟方法,请非常详细地记录它。只要涉及的每个人都知道它在做什么,它就不应该引起太多问题。不过,正如您在第一个示例中所做的那样,我会尝试限制可见性。

编辑:这里重要的一件事是 C# 和 Java 在初始化顺序上有所不同。如果你有一个类,例如:

public class Child : Parent
{
    private int foo = 10;

    protected override void ShowFoo()
    {
        Console.WriteLine(foo);
    }
}

Parent构造函数调用的地方ShowFoo,在 C# 中它将显示 10。Java 中的等效程序将显示 0。

于 2009-01-15T20:10:39.267 回答
10

在 C++ 中,在基类构造函数中调用虚方法将简单地调用该方法,就好像派生类还不存在(因为它不存在)。所以这意味着调用在编译时被解析为它应该在基类(或它派生的类)中调用的任何方法。

使用 GCC 测试,它允许您从构造函数调用纯虚函数,但它会发出警告,并导致链接时间错误。标准似乎未定义此行为:

“可以从抽象类的构造函数(或析构函数)调用成员函数;直接或间接地对纯虚函数进行虚拟调用( class.virtual )的效果是从这样的对象创建(或销毁)的对象构造函数(或析构函数)未定义。”

于 2009-01-15T20:18:50.653 回答
4

在 C++ 中,虚拟方法通过正在构建的类的 vtable 进行路由。因此,在您的示例中,它将生成一个纯虚拟方法异常,因为在构造 BaseObject 时根本没有要调用的 LoadStateCore 方法。

如果函数不是抽象的,只是什么都不做,那么你经常会让程序员摸不着头脑,试图记住为什么函数实际上没有被调用。

出于这个原因,你根本不能在 C++ 中这样做......

于 2009-01-15T20:19:27.067 回答
4

对于 C++,基构造函数在派生构造函数之前调用,这意味着虚拟表(保存派生类的重写虚函数的地址)尚不存在。出于这个原因,这被认为是一件非常危险的事情(特别是如果函数在基类中是纯虚拟的……这将导致纯虚拟异常)。

有两种方法可以解决这个问题:

  1. 做一个构造+初始化的两步过程
  2. 将虚函数移动到可以更紧密控制的内部类中(可以使用上述方法,详见示例)

(1) 的一个例子是:

class base
{
public:
    base()
    {
      // only initialize base's members
    }

    virtual ~base()
    {
      // only release base's members
    }

    virtual bool initialize(/* whatever goes here */) = 0;
};

class derived : public base
{
public:
    derived ()
    {
      // only initialize derived 's members
    }

    virtual ~derived ()
    {
      // only release derived 's members
    }

    virtual bool initialize(/* whatever goes here */)
    {
      // do your further initialization here
      // return success/failure
    }
};

(2) 的一个例子是:

class accessible
{
private:
    class accessible_impl
    {
    protected:
        accessible_impl()
        {
          // only initialize accessible_impl's members
        }

    public:
        static accessible_impl* create_impl(/* params for this factory func */);

        virtual ~accessible_impl()
        {
          // only release accessible_impl's members
        }

        virtual bool initialize(/* whatever goes here */) = 0;
    };

    accessible_impl* m_impl;

public:
    accessible()
    {
        m_impl = accessible_impl::create_impl(/* params to determine the exact type needed */);

        if (m_impl)
        {
            m_impl->initialize(/* ... */);  // add any initialization checking you need
        }
    }

    virtual ~accessible()
    {
        if (m_impl)
        {
            delete m_impl;
        }
    }

    /* Other functionality of accessible, which may or may not use the impl class */
};

方法 (2) 使用工厂模式为accessible类提供适当的实现(它将提供与您的base类相同的接口)。这里的主要好处之一是您可以在构造过程中进行初始化,accessible从而能够accessible_impl安全地使用虚拟成员。

于 2009-01-15T22:33:28.853 回答
3

对于 C++,标准的第 12.7 节第 3 段涵盖了这种情况。

总而言之,这是合法的。它将解析为正在运行的构造函数类型的正确函数。因此,使您的示例适应 C++ 语法,您将调用BaseObject::LoadState(). 您无法访问ChildObject::LoadState(),并且尝试通过指定类和函数来执行此操作会导致未定义的行为。

抽象类的构造函数在 10.4 节第 6 节中介绍。简而言之,它们可以调用成员函数,但在构造函数中调用纯虚函数是未定义的行为。不要那样做。

于 2009-01-15T20:32:38.240 回答
3

如果您有一个如您的帖子中所示的类,它XElement在构造函数中使用一个,那么唯一XElement可能来自的地方是派生类。那么为什么不直接在已经具有XElement.

您的示例可能缺少一些改变情况的基本信息,或者根本不需要使用来自基类的信息链接到派生类,因为它刚刚告诉了您确切的信息。

IE

public class BaseClass
{
    public BaseClass(XElement defintion)
    {
        // base class loads state here
    }
}

public class DerivedClass : BaseClass
{
    public DerivedClass (XElement defintion)
        : base(definition)
    {
        // derived class loads state here
    }
}

那么你的代码真的很简单,而且你没有任何虚拟方法调用问题。

于 2009-01-15T22:48:53.957 回答
3

对于 C++,请阅读 Scott Meyer 的相应文章:

永远不要在构造或销毁期间调用虚函数

ps:注意文章中的这个异常:

这个问题几乎肯定会在运行前变得明显,因为 logTransaction 函数在 Transaction 中是纯虚拟的。除非它已被定义(不太可能,但 可能),否则程序不会链接:链接器将无法找到 Transaction::logTransaction 的必要实现。

于 2009-01-16T10:51:45.290 回答
1

通常,您可以通过使用更贪婪的基本构造函数来解决这些问题。在您的示例中,您将 XElement 传递给 LoadState。如果您允许在基本构造函数中直接设置状态,那么您的子类可以在调用构造函数之前解析 XElement。

public abstract class BaseObject {
   public BaseObject(int state1, string state2, /* blah, blah */) {
      this.State1 = state1;
      this.State2 = state2;
      /* blah, blah */
   }
}

public class ChildObject : BaseObject {
   public ChildObject(XElement definition) : 
      base(int.Parse(definition["state1"]), definition["state2"], /* blah, blah */) {
   }
}

如果子类需要做大量工作,它可以卸载到静态方法。

于 2009-01-15T20:33:50.017 回答
1

在 C++ 中,从基类中调用虚函数是完全安全的——只要它们不是纯函数——有一些限制。但是,您不应该这样做。使用非虚拟函数更好地初始化对象,这些函数使用注释和适当的名称(如initialize)显式标记为此类初始化函数。如果它甚至在调用它的类中被声明为纯虚拟,则行为是未定义的。

调用的版本是从构造函数中调用它的类之一,而不是某些派生类中的某些覆盖器。这与虚函数表没有太大关系,但更多的是因为该函数的覆盖可能属于尚未初始化的类。所以这是被禁止的。

在 C# 和 Java 中,这不是问题,因为没有在进入构造函数主体之前完成的默认初始化之类的事情。在 C# 中,我认为唯一在体外完成的事情就是调用基类或同级构造函数。但是,在 C++ 中,当在进入派生类的构造函数主体之前处理构造函数初始化器列表时构造这些成员时,该函数的覆盖器对派生类成员所做的初始化将被撤消。

编辑:由于有评论,我认为需要澄清一下。这是一个(人为的)示例,假设允许调用 virtuals,并且调用将导致最终覆盖器的激活:

struct base {
    base() { init(); }
    virtual void init() = 0;
};

struct derived : base {
    derived() {
        // we would expect str to be "called it", but actually the
        // default constructor of it initialized it to an empty string
    }
    virtual void init() {
        // note, str not yet constructed, but we can't know this, because
        // we could have called from derived's constructors body too
        str = "called it";
    }
private:
    string str;
};

这个问题确实可以通过改变 C++ 标准并允许它来解决——调整构造函数的定义、对象生命周期等等。必须制定规则来定义str = ...;尚未构造的对象的含义。并注意它的效果如何取决于谁打电话init。我们得到的特性并不能证明我们必须解决的问题。因此,C++ 只是在构造对象时禁止动态调度。

于 2009-01-15T22:53:14.603 回答