C++ Annotated Reference Manual (ARM) 指出:默认构造函数(default constructor)在需要的时候会被编译器产生出来。它实际上点出了两个常见的误解:
- 任何class如果没有定义默认构造函数,编译器会为它合成一个出来。
编译器合成出来的额默认构造函数会显示设定class内每一个成员变量的默认值。
考虑下面的代码:class Foo { public: int val; Foo* pNext; }; void fool_bar() { // 程序要求bar的member都被清0 Fool bar; if(bar.val || bar.pNext) { // do something } // ... }
该例中正确的程序语义是希望
Foo
有一个默认构造函数将它的成员初始化为0。但是,这是程序的要求,而不是编译器的需要,因此并不会有一个默认构造函数被合成出来。如果需要一个构造函数为成员初始化,需要设计了Foo
的你承担责任,提供一个显示的构造函数为成员设置初值。
C++标准给出了更明确的说法:
对于class X,如果没有任何用户声明的构造函数,会有一个默认构造函数被隐式地(implicit)声明出来。一个被隐式声明出来的构造函数将是一个trivial的构造函数。
此时class X
有可能是个trivial类,如下:#include <type_traits> #include <iostream> class Foo { public: int i; }; class Bar { public: Bar() {} }; int main() { std::cout << std::boolalpha; std::cout << std::is_trivial<Foo>::value << '\n'; std::cout << std::is_trivial<Bar>::value << '\n'; }
输出
true
false
有四种情况non-travial默认构造函数会被合成出来:成员对象拥有默认构造函数
即使一个类没有任何构造函数,如果它拥有一个成员对象,该对象拥有一个默认构造函数,该类的implicit构造函数也是non-trivial的,编译器需要为它合成出一个默认构造函数。该合成操作只会在constructor真正被需要调用的时候才会发生。考虑下面的例子:
class Foo { public: Foo(); Foo(int); }; class Bar { public: Foo foo; char* str; }; void foo_bar() { Bar bar; if(bar.str) { // ... } }
编译器会被
Bar
合成一个默认构造函数,函数体内包含必要的代码去调用Foo
的默认构造函数初始化成员对象Bar::foo
。但编译器并不会初始化Bar::str
,因为没有需要,这是程序员的责任。合成后的额默认构造函数可能像下面的代码:inline Bar::Bar() { foo.Foo::Foo(); }
如果你为默认构造函数提供了
str
的初始化://根据程序语义,你需要为`str`初始化,编译器并不为此负责。 Bar::Bar() { str = nullptr; }
此时,编译器会扩张已经存在的默认构造函数,在已有的函数体的基础上加上一些代码,调用成员对象的默认构造函数,使得
Bar::Bar()
看起来像这样:Bar::Bar() { foo.Foo::Foo(); str = nullptr; }
如果类包含多个成员对象并且都需要初始化,则编译器会按照成员对象在类中的声明顺序调用各个constructor,扩张它的默认构造函数。
基类拥有默认构造函数
这一点和上一点类似,分为两种情况:
- 如果子类没有提供任何构造函数但它的基类有默认构造函数,子类的默认构造函数也是non-trivial的并被编译器合成出来。合成出来的默认构造函数并调用基类的默认构造函数以便对基类成员进行初始化。
如果子类中定义了多个构造函数,但不包括默认构造函数,编译器会扩张每一个构造函数,将所有默认构造函数需要的代码加进去。注意,此时编译器仅仅是扩张每个已经定义好的构造函数,并不会有一个默认构造函数被合成出来,因为此时的程序语义是,你并不想为子类提供带明显形参的构造函数。因此下面的代码不会通过编译。
class Base { public: Base() {}; }; class Derive :public Base { public: Derive(int i) {}; //去掉这行,编译通过 }; Derive d; // 'Derive': no appropriate default constructor available
拥有虚函数的类
考虑下面一个多态的例子:
class Widget { public: virtual void flip() = 0; // ... }; void flip(const Widget& widget) { widget.flip(); } // Bell和Whistle均继承自Widget并实现了flip() void foo() { Bell b; Whistle w; flip(b); flip(w); }
在运行期,需要知道
b
和w
绑定的flip()
,而编译器会提前在构造函数内进行下面的扩张:- 产生一个虚函数表(vtbl)。
产生一个虚表指针(vptr)。
这使得widget.flip()
看起来像这样:void flip(const Widget& widget) { // 此处《深入探索C++对象模型》给出的伪代码为 // (*widget.vfptr[1])(&widget); // 虚表指针不同编译器定义的名称可能不一样,flip()在virtual table的索引可能不一样 // 没能理解的是为什么要对widget解引用 (widget.__vfptr[0])(&widget); }
为了是多态发挥作用,编译器需要为每一个
Widget
实例的vptr和vtbl设定初值,这些动作会在每个定义的构造函数里完成。如果没有声明任何构造函数,就会像第一点所说的,合成默认构造函数初始化vptr和vtbl。拥有虚基类的子类
考虑一个菱形继承:
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(const A* pa) { pa->i = 1024; } main() { foo(new A); foo(new B); }
编译器无法知道
foo()
中经过pa
获取的X::i
的实际偏移位置,因为pa
指向A
和C
都有可能。因此需要在执行期准备好虚基类在其派生对象中的内存位置。cfront的做法是在子类所继承的虚基类中安插一个指针来完成,所有经过引用或指针访问虚基类的操作都可以通过它实现。这使得foo()
看起来像这样:void foo(const A* pa) { pa->__vbcX->i = 1024; }
其中
__vbcX
即为编译器产生的指针,指向虚基类X
。和vptr一样,__vbcX
也是在对象构造期间被初始化的,编译器会合成一个默认构造函数或扩张已有的构造函数完成这一操作。总结
所以总共有四种情况,编译器会为声明构造函数的类合成一个默认构造函数。再强调一遍,被合成出的构造函数(implicit non-trivial default constructors)只是为了满足编译器诸如初始化成员对象、基类对象、虚函数机制、虚基类机制的需要,而非程序员的要求。对于其他的非静态的类成员,合成的构造函数不会对它们进行任何动作,对它们操作的应该是程序员。
0 条评论