编译器只在编译期需要的时候合成默认构造函数,而不是在用户需要的时候
文章目录
- 引入
- 编译器合成默认构造函数的四种情况
- 情况一 类中包含带有默认构造函数的类的成员对象
- 情况二 派生类的基类带有默认构造函数
- 情况三 类带有一个虚函数
- 情况四 派生自一个虚基类的类
- 参考资料
引入
如果有一个类 Foo
,其类的定义如下:
class Foo {
public:int val;Foo* next;
};
我们生成一个 Foo 类型的对象
Foo* bar = new Foo;
cout << bar->val << " " << bar->next << endl;
此时编译器会合成默认的构造函数吗,或者说编译会像我们希望的那样,初始化成员变量 val
和 next
吗,输出如下
可以看到并没有将 val
和 next
初始化。
编译器合成默认构造函数的四种情况
情况一 类中包含带有默认构造函数的类的成员对象
看下面这个case,类 Foo
的对象是类 Bar
的成员变量,且类 Foo
有默认的构造函数
class Foo {
public:Foo() : val(0), next(nullptr) { cout << "调用Foo的默认构造函数" << endl; }int val;Foo* next;
};class Bar {
public:Foo foo;int x;
};int main()
{Bar* bar = new Bar;cout << bar->x << endl;
}
输出如下:
可以看到调用了 Foo
的默认构造函数,说明此时编译期为 Bar
合成了默认构造函数,虽然这里的 x
也被初始化了,但是这是不能保证的,所以用户想保证 x
被初始化,得用户自己声明定义 Bar
的默认构造函数来完成这个操作,看VS2022下的汇编代码,这里的 Bar::__autoclassinit2
主要是把内存都初始化为 0,Bar::Bar
中调用了 Foo
的默认构造函数。
如果用户自定义了一个默认构造函数,但是只初始化了 x
会是什么结果
class Bar {
public:Bar() {cout << "调用Bar的默认构造函数" << endl;x = 0;}Foo foo;int x;
};
其结果如下,可以看到先调用了 Foo
的默认构造函数,说明编译器会在 Bar
的默认构造函数的代码前,先调用 Foo
的默认构造函数
可以看作编译器对 Bar
的默认构造函数进行了扩张
Bar::Bar()
{foo.Foo::Foo();// 省略输出x = 0;
}
如果有多个类成员对象都要构造器的初始化操作,那么会成员对象在类中的声明次序来调用各个类的默认构造器,比如我们有以下三个类:
class Dopey { public: Dopey() { cout << "调用Dopey的默认构造函数" << endl; } };
class Sneezy { public: Sneezy() { cout << "调用Sneezy的默认构造函数" << endl; } Sneezy(int x) { cout << "调用Sneezy的默认构造函数" << endl; m_x = x; } private: int m_x; };class Bashful { public: Bashful() {cout << "调用Bashful的默认构造函数" << endl; } };
以及包含上面三个类对象为成员变量的类 Snow_White
class Snow_White
{
public:Dopey dopey;Sneezy sneezy;Bashful bashful;private:int mumble;
};
如果 Snow_White
没有定义默认构造函数,那么会有一个默认构造函数被编译期合成出来,依次调用 Dopey
、Sneezy
、Bashful
的默认构造函数。
如果 Snow_White
定义了下面的默认构造函数
Snow_White::Snow_White() : sneezy(1024) { cout << "调用Snow_White的构造函数" << endl; mumble = 2048; };
它会被扩张为
Snow_White::Snow_White() : sneezy(1024)
{dopey.Dopey::Dopey();sneezy.Sneezy::Sneezy(1024);bashful.Bashful::Bashful();mumble = 2048;
}
代码输出结果
情况二 派生类的基类带有默认构造函数
如果一个没有任何构造器的类派生自一个带有默认构造器的基类,那么这个派生类的默认构造器需要被编译器和出来,它将调用上一层基类的默认构造器。
如果派生类还带有有默认构造函数的类对象作为成员变量,那么在所有基类构造函数被调用后,会调用这些成员变量的默认构造函数。
还是上面的例子,只不过让 Snow_White
派生自一个基类 Base
,Base
有一个默认的构造函数。
class Base {
public:Base() {cout << "调用Base的默认构造函数" << endl;}
};
class Snow_White : public Base
{
public:Snow_White() : sneezy(1024) { cout << "调用Snow_White的构造函数" << endl; mumble = 2048; };Dopey dopey;Sneezy sneezy;Bashful bashful;private:int mumble;
};
代码输出如下
情况三 类带有一个虚函数
因为虚函数涉及到要给该类的对象一个合适的指向其虚函数表的指针,所以如果没有默认的构造函数,则编译期会合成默认的构造函数,用以指定合适的虚函数指针。
比如有如下类 Widget
,其有一个虚函数 flip
,类 Bell
和 Whistle
都派生自 Widget
class Widget {
public:virtual void flip() const = 0;
};class Bell : public Widget
{
public:void flip() const override{cout << "Bell::flip()" << endl;}
};class Whistle : public Widget
{
public:
void flip() const override{cout << "Whistle::flip()" << endl;}
};;void flip(const Widget& widget)
{widget.flip();
}int main()
{Bell b;Whistle w;flip(b);flip(w);
}
下面两个扩张操作会在编译期间发生:
- 一个虚函数的表 vtbl 会被编译器产生出来(放在.rdata只读数据区),内放类的虚函数地址
- 在每一个类的对象中,一个额外的指针成员(虚函数表指针 vptr),会被编译器合成出来,指向类相关的虚函数表的地址
此外,widget.flip()
的虚拟引发操作(virtual invocation) 会被重新改写,以使用 widget
的vptr 和 vtbl 中的 flip()
条目
( *widget.vptr[ 1 ] )( &widget )
- 1 表示
flip()
在虚函数表中的固定索引 &widget
代表要交给被调用的某个flip()
函数实体的this
指针
为了让上述机制发挥功效,编译器必须为每一个 Widget
(或其派生类)的对象的 vptr 设定初值,放置适当的 vtbl 地址。对于类所定义的每一个构造器,编译器会安插一些代码来做这样的事情。对于未声明任何构造器的类,编译器会为它们合成一个默认的构造器,以便正确地初始化每一个类对象的 vptr。
情况四 派生自一个虚基类的类
Virtual base class 的实现法在不同的编译器之间有极大的差异。然而,每一种实现法的共通点在于必须使虚基类,在其每一个派生类对象中的位置,能够于执行期准备妥当。例如下面这段程序代码:
class X { public: int i; };
class A : public virtual X { public: int j; };
class B : public virtual X { public: double d; };
class C : public A, public B { public: int k; };void foo(A* const pa) { pa->i = 1024; }int main()
{foo(new A);foo(new C);
}
编译器无法固定住 foo()
之中经由 pa
而存取的 X::i
的实际偏移位置,因为 pa
的真正类型可以改变,编译器必须改变”执行存取操作“的那些码,使 X::i
可以延迟至执行期才决定下来。
原先 cfront 的做法是靠 “在派生类对象的每一个虚基类中安插一个指针” 来完成。所有 “经由引用或指针来存取一个虚基类” 的操作都可以通过相关指针来完成。在上面的例子中,foo()
可以被改写如下,以符合这样的实现策略:
void foo(A* const pa ) { pa->__vbcX->i = 1024; }
其中,__vbcX
表示编译器所产生的指针,指向虚基类 X
,而 __vbcX
是在对象构建期间被完成的。对于类所定义的每一个构造函数,编译器会安插那些 ”允许每一个虚基类执行期存取操作“ 的代码。如果类没有声明任何构造函数,编译器必须为它合成一个默认构造函数。
在VS2022可以看到类的内存布局:
类 A
的内存布局如下
类 B
的内存布局如下
类 C
的内存布局如下
我猜测这里中间空出来的字节就是为了存放 __vbcX
或其它类似的实现,这里的实现是通过vbtable,vbtable里存放着基类成员的实际偏移位置。
我们可以看到A
中构造函数的汇编代码如下,可以看到将 A::vbtable
的偏移地址赋给了 this
指针指向地址的首位置
监视 this
指针和两个成员变量的地址,可以看到有个 0x00de2140 的位置是被用来存放 vbtable 的偏移地址的,剩下用来存放变量
参考资料
《深度探索C++对象模型》—— Stanley B.Lippman著,侯捷译