假设我有两个 C++ 类:
class A
{
public:
A() { fn(); }
virtual void fn() { _n = 1; }
int getn() { return _n; }
protected:
int _n;
};
class B : public A
{
public:
B() : A() {}
virtual void fn() { _n = 2; }
};
如果我编写以下代码:
int main()
{
B b;
int n = b.getn();
}
人们可能会认为 n
设置为 2。
原来 n
设置为 1。为什么?
从构造函数或析构函数调用虚函数是危险的,应尽可能避免。所有 C++ 实现都应该调用在当前构造函数的层次结构级别定义的函数版本,而不是进一步。
C++ FAQ Lite 在第 23.7 节中非常详细地介绍了这一点。我建议阅读该内容(以及常见问题解答的其余部分)以进行跟进。
摘抄:
[...] 在构造函数中,虚拟调用机制被禁用,因为尚未发生从派生类的覆盖。对象是从基础向上构建的,“在派生之前的基础”。 [...] 销毁是“在基类之前的派生类”完成的,因此虚函数的行为就像在构造函数中一样:仅使用本地定义 - 并且不调用覆盖函数以避免触及(现已销毁的)派生类部分的对象。
编辑大部分更正(感谢 litb)
在大多数 OO 语言中,从构造函数调用多态函数是灾难的根源。遇到这种情况时,不同的语言会有不同的表现。
基本问题是,在所有语言中,Base 类型必须在 Derived 类型之前构造。现在,问题是从构造函数调用多态方法意味着什么。您期望它的表现如何?有两种方法:在基层调用方法(C++ 风格)或在层次结构底部的未构造对象上调用多态方法(Java 方式)。
在 C++ 中,基类将在进入自己的构造之前构建其虚方法表版本。此时,对虚拟方法的调用将最终调用该方法的基本版本或生成一个纯虚拟方法,以防它在该层次结构的该级别没有实现。在 Base 完全构建完成后,编译器将开始构建 Derived 类,并将覆盖方法指针以指向下一层层次结构中的实现。
class Base {
public:
Base() { f(); }
virtual void f() { std::cout << "Base" << std::endl; }
};
class Derived : public Base
{
public:
Derived() : Base() {}
virtual void f() { std::cout << "Derived" << std::endl; }
};
int main() {
Derived d;
}
// outputs: "Base" as the vtable still points to Base::f() when Base::Base() is run
在 Java 中,编译器将在构造的第一步,在进入 Base 构造函数或 Derived 构造函数之前,构建等效的虚拟表。含义是不同的(而且对我来说更危险)。如果基类构造函数调用在派生类中被覆盖的方法,则调用实际上将在派生级别处理,调用未构造对象上的方法,从而产生意外结果。在构造函数块中初始化的派生类的所有属性尚未初始化,包括“最终”属性。在类级别定义了默认值的元素将具有该值。
public class Base {
public Base() { polymorphic(); }
public void polymorphic() {
System.out.println( "Base" );
}
}
public class Derived extends Base
{
final int x;
public Derived( int value ) {
x = value;
polymorphic();
}
public void polymorphic() {
System.out.println( "Derived: " + x );
}
public static void main( String args[] ) {
Derived d = new Derived( 5 );
}
}
// outputs: Derived 0
// Derived 5
// ... so much for final attributes never changing :P
如您所见,调用多态(C++ 术语中的虚拟)方法是常见的错误来源。在 C++ 中,至少您可以保证它永远不会在尚未构造的对象上调用方法...
原因是 C++ 对象从内到外像洋葱一样构造。基类在派生类之前构造。因此,在制作 B 之前,必须先制作 A。当 A 的构造函数被调用时,它还不是 B,所以虚函数表仍然有 A 的 fn() 副本的条目。
C++ FAQ Lite 很好地涵盖了这一点:
本质上,在调用基类构造函数期间,对象还不是派生类型,因此调用的是基类型的虚函数实现,而不是派生类型的。
您的问题的一种解决方案是使用工厂方法来创建您的对象。
为您的类层次结构定义一个公共基类,其中包含一个虚方法 afterConstruction():
class Object { public: virtual void afterConstruction() {} // ... };
定义工厂方法:
template< class C > C* factoryNew() { C* pObject = new C(); pObject->afterConstruction(); return pObject; }
像这样使用它:
class MyClass : public Object { public: virtual void afterConstruction() { // do something. } // ... }; MyClass* pMyObject = factoryNew();
其他答案已经解释了为什么 virtual
函数调用在从构造函数调用时不能按预期工作。相反,我想提出另一种可能的解决方法,以从基类型的构造函数中获取类似多态的行为。
通过向基类型添加模板构造函数,以便始终将模板参数推断为派生类型,可以了解派生类型的具体类型。从那里,您可以调用该派生类型的 static
成员函数。
此解决方案不允许调用非 static
成员函数。虽然执行是在基类型的构造函数中,但派生类型的构造函数甚至没有时间检查它的成员初始化列表。正在创建的实例的派生类型部分尚未开始对其进行初始化。而且由于非static
成员函数几乎肯定会与数据成员交互,因此想要 从基类型的构造函数调用派生类型的非static
成员函数是不寻常的。
这是一个示例实现:
#include <iostream>
#include <string>
struct Base {
protected:
template<class T>
explicit Base(const T*) : class_name(T::Name())
{
std::cout << class_name << " created\n";
}
public:
Base() : class_name(Name())
{
std::cout << class_name << " created\n";
}
virtual ~Base() {
std::cout << class_name << " destroyed\n";
}
static std::string Name() {
return "Base";
}
private:
std::string class_name;
};
struct Derived : public Base
{
Derived() : Base(this) {} // `this` is used to allow Base::Base<T> to deduce T
static std::string Name() {
return "Derived";
}
};
int main(int argc, const char *argv[]) {
Derived{}; // Create and destroy a Derived
Base{}; // Create and destroy a Base
return 0;
}
这个例子应该打印
Derived created
Derived destroyed
Base created
Base destroyed
构造 Derived
时,Base
构造函数的行为取决于正在构造的对象的实际动态类型。
C++ Standard (ISO/IEC 14882-2014) 说:
成员函数,包括虚函数 (10.3),可以在构造或销毁 (12.6.2) 期间调用。当从构造函数或从析构函数直接或间接调用虚函数时,包括在类的非静态数据成员的构造或销毁期间,并且调用适用的对象是正在构造的对象(称为 x)或破坏,调用的函数是构造函数或析构函数类中的最终覆盖者,而不是在派生更多的类中覆盖它。如果虚函数调用使用显式类成员访问 (5.2.5) 并且对象表达式引用 x 的完整对象或该对象的基类子对象之一,但不是 x 或其基类子对象之一,则行为未定义.
所以,不要从试图调用正在构造或销毁的对象的构造函数或析构函数调用 virtual
函数,因为构造的顺序从 base 到 derived 开始,而析构函数的顺序从派生到基类。
因此,尝试从正在构造的基类调用派生类函数是危险的。同样,对象以与构造相反的顺序被销毁,因此尝试从析构函数调用更派生类中的函数可能会访问已经存在的资源被释放。
正如已经指出的那样,对象是在构造时创建的。在构造基础对象时,派生对象还不存在,因此虚函数覆盖无法工作。
但是,如果您的 getter 返回常量,或者可以在静态成员函数中表示,则可以通过使用 静态多态性 而非虚函数的多态 getter 来解决此问题,此示例使用 CRTP (https://en.wikipedia.org/wiki/Curiously_recurring_template_pattern) .
template<typename DerivedClass>
class Base
{
public:
inline Base() :
foo(DerivedClass::getFoo())
{}
inline int fooSq() {
return foo * foo;
}
const int foo;
};
class A : public Base<A>
{
public:
inline static int getFoo() { return 1; }
};
class B : public Base<B>
{
public:
inline static int getFoo() { return 2; }
};
class C : public Base<C>
{
public:
inline static int getFoo() { return 3; }
};
int main()
{
A a;
B b;
C c;
std::cout << a.fooSq() << ", " << b.fooSq() << ", " << c.fooSq() << std::endl;
return 0;
}
通过使用静态多态性,基类知道要调用哪个类的 getter,因为信息是在编译时提供的。
Base<T>
只是一个辅助类,而不是可用于运行时多态性的通用接口类型(例如异构容器)。这些也很有用,只是不适用于相同的任务。有些类既继承自作为运行时多态的接口类型的基类,又继承自作为编译时模板帮助器的基类。
你知道 Windows 资源管理器的崩溃错误吗?! “纯虚函数调用……”同样的问题……
class AbstractClass
{
public:
AbstractClass( ){
//if you call pureVitualFunction I will crash...
}
virtual void pureVitualFunction() = 0;
};
因为函数 pureVitualFunction() 没有实现,并且该函数在构造函数中被调用,所以程序将崩溃。
vtables 由编译器创建。一个类对象有一个指向它的 vtable 的指针。当它开始生命时,该 vtable 指针指向基类的 vtable。在构造函数代码的末尾,编译器生成代码以将 vtable 指针重新指向该类的实际 vtable。这确保调用虚函数的构造函数代码调用这些函数的基类实现,而不是类中的覆盖。
C::C
的主体中,虚函数调用转到 C
覆盖器,而不是任何基类版本。
T::T(params)
的主体内,动态类型是 T
。 vptr 将反映:它将指向 T 的 vtable。您不同意吗?
首先,创建对象,然后我们将它的地址分配给指针。构造函数在对象创建时被调用,用于初始化数据成员的值。指向对象的指针在对象创建后进入场景。这就是为什么,C++ 不允许我们将构造函数设为 virtual 。另一个原因是,没有什么像指向构造函数的指针,它可以指向虚拟构造函数,因为虚函数的一个属性是它只能被指针使用。
虚函数用于动态赋值,因为构造函数是静态的,所以我们不能让它们成为虚拟的。
作为补充,调用尚未完成构造的对象的虚函数将面临同样的问题。
例如,在对象的构造函数中启动一个新线程,并将对象传递给新线程,如果新线程在对象完成构造之前调用该对象的虚函数会导致意外结果。
例如:
#include <thread>
#include <string>
#include <iostream>
#include <chrono>
class Base
{
public:
Base()
{
std::thread worker([this] {
// This will print "Base" rather than "Sub".
this->Print();
});
worker.detach();
// Try comment out this code to see different output.
std::this_thread::sleep_for(std::chrono::seconds(1));
}
virtual void Print()
{
std::cout << "Base" << std::endl;
}
};
class Sub : public Base
{
public:
void Print() override
{
std::cout << "Sub" << std::endl;
}
};
int main()
{
Sub sub;
sub.Print();
getchar();
return 0;
}
这将输出:
Base
Sub
sleep_for
不同步线程,因此您在构造和销毁期间都会在 this->Print()
上竞争。其次,这有崩溃的风险,因为工作人员需要 this
仍然存在(它是一个成员函数),但不能保证这一点。如果您没有像 getchar()
这样的任意等待,则 Sub
实例可以在线程打印之前轻松到达其生命周期的末尾。依赖 detach()
的解决方案几乎总是被破坏。
为了回答运行该代码时会发生什么/为什么,我通过 g++ -ggdb main.cc
对其进行了编译,并使用 gdb 逐步完成。
主.cc:
class A {
public:
A() {
fn();
}
virtual void fn() { _n=1; }
int getn() { return _n; }
protected:
int _n;
};
class B: public A {
public:
B() {
// fn();
}
void fn() override {
_n = 2;
}
};
int main() {
B b;
}
在 main
设置断点,然后进入 B(),打印 this
ptr,进入 A()(基本构造函数):
(gdb) step
B::B (this=0x7fffffffde80) at main2.cc:16
16 B() {
(gdb) p this
$27 = (B * const) 0x7fffffffde80
(gdb) p *this
$28 = {<A> = {_vptr.A = 0x7fffffffdf80, _n = 0}, <No data fields>}
(gdb) s
A::A (this=0x7fffffffde80) at main2.cc:3
3 A() {
(gdb) p this
$29 = (A * const) 0x7fffffffde80
显示 this
最初指向在 0x7fffffffde80 的堆栈上构造的派生 B obj b
。下一步是进入基 A() ctor 并且 this
变为 A * const
到相同的地址,这是有道理的,因为基 A 正好在 B 对象的开头。但它仍然没有被构建:
(gdb) p *this
$30 = {_vptr.A = 0x7fffffffdf80, _n = 0}
更进一步:
(gdb) s
4 fn();
(gdb) p *this
$31 = {_vptr.A = 0x402038 <vtable for A+16>, _n = 0}
_n 已经初始化,它的虚函数表指针包含 virtual void A::fn()
的地址:
(gdb) p fn
$32 = {void (A * const)} 0x40114a <A::fn()>
(gdb) x/1a 0x402038
0x402038 <_ZTV1A+16>: 0x40114a <_ZN1A2fnEv>
因此,在给定活动 this
和 _vptr.A
的情况下,下一步通过 this->fn() 执行 A::fn() 是完全合理的。再走一步,我们又回到了 B() ctor:
(gdb) s
B::B (this=0x7fffffffde80) at main2.cc:18
18 }
(gdb) p this
$34 = (B * const) 0x7fffffffde80
(gdb) p *this
$35 = {<A> = {_vptr.A = 0x402020 <vtable for B+16>, _n = 1}, <No data fields>}
基地A已经建成。请注意,存储在虚函数表指针中的地址已更改为派生类 B 的 vtable。因此,对 fn() 的调用将通过 this->fn() 选择派生类覆盖 B::fn() 给定活动 this
和 _vptr.A
(取消注释 B() 中对 B::fn() 的调用以查看此内容。)再次检查存储在 _vptr.A 中的 1 个地址显示它现在指向派生类覆盖:
(gdb) p fn
$36 = {void (B * const)} 0x401188 <B::fn()>
(gdb) x/1a 0x402020
0x402020 <_ZTV1B+16>: 0x401188 <_ZN1B2fnEv>
通过查看此示例,并查看具有 3 级继承的示例,似乎随着编译器向下构造基本子对象,this*
的类型和 _vptr.A
中的相应地址发生变化以反映当前正在构造的子对象, - 所以它指向最派生的类型。因此,我们希望从 ctors 中调用的虚函数为该级别选择函数,即,与非虚函数相同的结果。对于 dtors 也是如此,但相反。并且 this
在构造成员时成为成员的指针,因此它们也可以正确调用为为它们定义的任何虚函数。
我在这里没有看到虚拟关键字的重要性。 b 是静态类型变量,其类型由编译器在编译时确定。函数调用不会引用 vtable。 b 被构造的时候,它的父类的构造函数被调用,这就是为什么 _n 的值被设置为 1 的原因。
b
的构造函数调用基 f()
,而不是它的派生覆盖。变量 b
的类型与此无关。
B*
或 `B&` 访问时才启用虚拟调度,那您就错了。
b.getN()
,因为它知道真正的类型,&直接从 B
分派到版本。但这只是 as-if 规则所允许的。一切仍然必须如同虚拟表被使用&跟着信。在 A
构造函数中也是如此:即使(可能不可能)它被内联了 B
ctor,虚拟调用仍然必须as-if它只有base A
vtable 可供使用。
this
)调用吗?
b.getn()
是非虚拟的。 b
是一个静态类型的对象,将调用为其类型定义的任何 getn()
。但是在成员函数内部,包括构造函数,所有成员函数调用都是通过隐式 this
指针进行的,因此如果它是多态类,则它们都是虚函数调用。解决对基类实现的虚拟 fn()
调用的原因和基本原理 - 即使它发生在派生对象的整体构造期间 - 在其他答案中进行了解释。
在对象的构造函数调用期间,虚函数指针表并未完全建立。这样做通常不会给您预期的行为。在这种情况下调用虚函数可能有效,但不能保证,应避免可移植并遵循 C++ 标准。
不定期副业成功案例分享