我知道从基类构造函数调用虚方法可能很危险,因为子类可能不是有效状态。 (至少在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);
答案 0 :(得分: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.
答案 1 :(得分:10)
在C ++中,在基类构造函数中调用虚方法只会调用该方法,就好像派生类还不存在一样(因为它没有)。这意味着调用在编译时被解析为它应该在基类(或它派生的类)中调用的任何方法。
使用GCC测试,它允许您从构造函数调用纯虚函数,但它会发出警告,并导致链接时错误。标准似乎未定义此行为:
“可以从抽象类的构造函数(或析构函数)调用成员函数;直接或间接地为虚拟函数创建虚拟调用( class.virtual )的效果从这样的构造函数(或析构函数)创建(或销毁)的对象是未定义的。“
答案 2 :(得分:4)
使用C ++,虚拟方法通过vtable路由到正在构造的类。因此,在您的示例中,它将生成纯虚方法异常,因为在构造BaseObject时,根本没有要调用的LoadStateCore方法。
如果函数不是抽象的,但根本什么都不做,那么你经常会让程序员摸不着头脑,试图记住为什么函数实际上没有被调用。
因此你无法在C ++中这样做...
答案 3 :(得分:4)
对于C ++,在派生构造函数之前调用基本构造函数,这意味着虚拟表(包含派生类的重写虚函数的地址)尚不存在。出于这个原因,它被认为是非常危险的事情(特别是如果函数在基类中是纯虚拟的......这将导致纯虚拟异常)。
有两种解决方法:
(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)使用Factory模式为accessible
类提供适当的实现(它将提供与base
类相同的接口)。这里的一个主要好处是,您可以在构建accessible
期间初始化,以便能够安全地使用accessible_impl
的虚拟成员。
答案 4 :(得分:3)
对于C ++,标准第12.7节第3段涵盖了这种情况。
总结一下,这是合法的。它将解析正在运行的构造函数类型的函数。因此,使您的示例适应C ++语法,您将调用BaseObject::LoadState()
。您无法访问ChildObject::LoadState()
,并尝试通过指定类以及函数导致未定义的行为来执行此操作。
抽象类的构造函数在10.4节第6节中介绍。简而言之,它们可以调用成员函数,但在构造函数中调用纯虚函数是未定义的行为。不要那样做。
答案 5 :(得分:3)
如果你的帖子中显示了一个类,它在构造函数中占用XElement
,那么XElement
可能来自的唯一地方就是派生类。那么为什么不在已经有XElement
的派生类中加载状态。
您的示例中缺少一些可以改变情况的基本信息,或者根本不需要使用基类中的信息链接到派生类,因为它刚刚告诉您确切的信息。
即
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
}
}
然后您的代码非常简单,并且您没有任何虚拟方法调用问题。
答案 6 :(得分:3)
对于C ++,请阅读Scott Meyer的相应文章:
Never Call Virtual Functions during Construction or Destruction
ps:在文章中注意这个例外:
几乎可以肯定这个问题 在运行之前变得明显 因为logTransaction函数是 在交易中纯虚拟。除非它 已定义( 不太可能,但 可能 )程序不会链接:链接器将无法找到Transaction :: logTransaction的必要实现。
答案 7 :(得分: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 */) {
}
}
如果子类需要做很多工作,它可以卸载到静态方法。
答案 8 :(得分:1)
在C ++中,从基类中调用虚函数是完全安全的 - 只要它们是非纯 - 有一些限制。但是,你不应该这样做。使用非虚函数更好地初始化对象,非虚函数使用注释和适当的名称(如initialize
)显式标记为此类初始化函数。如果它甚至在调用它的类中声明为纯虚拟,则行为是未定义的。
被调用的版本是在构造函数中调用它的类之一,而不是某些派生类中的某些覆盖。这与虚函数表没什么关系,但更多的是因为该函数的重写可能属于尚未初始化的类。所以这是禁止的。
在C#和Java中,这不是问题,因为在进入构造函数体之前没有进行默认初始化。在C#中,我认为在体外完成的唯一事情是调用基类或兄弟构造函数。但是,在C ++中,当在进入派生类的构造函数体之前处理构造函数初始化程序列表时构造这些成员时,由该函数的覆盖器对派生类的成员进行的初始化将被撤消。
修改:由于发表评论,我认为需要进行一些澄清。这是一个(人为的)示例,让我们假设它将被允许调用虚拟,并且调用将导致激活最终的覆盖:
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 ++只是在构造对象时禁止动态调度。