关于构造函数

一、默认的构造函数

比如一段代码

1
2
3
4
5
class Foo {
public:
int val;
Foo* p_next;
};

首先明确默认构造函数是程序需要?还是编译器需要?

  • 如果是程序需要,那就是程序员的责任,比如将数据成员置为 0
  • 如果是编译器需要,那么编译器生成的构造函数只执行编译器所需的行动。如上不会将两个用户数据成员 val 和 p_next 置为 0

对于一个类,如果没有任何用户定义的构造函数,那么就会有一个默认构造函数被隐式的声明出来,但我们认为他“对于用户”而言是“无用”的。不过也有“有用”的时候。如下有 4 种情况是有用的:

1. 带有默认构造函数的成员类对象

如果一个类没有任何构造函数,但他内含一个成员对象,并且这个成员对象有默认构造函数。那么这个类的隐式的默认构造函数就是“有用的”。

1
2
class Foo { public: Foo(); Foo(int) ... };
class Bar { public: Foo foo; char* str };

此时编译器生成的 Bar 的默认构造函数会调用 Foo 的构造函数,但是并不会初始化 Bar::str。将 Bar::foo 初始化是编译器的责任,而将 Bar::str 初始化是程序猿的责任。

如果构造函数被程序猿显式的定义了,并且如果这个类中含有一个或多个数据成员(类对象),那么这个类的每一个构造函数都必须调用这些数据成员的默认构造函数,编译器会扩张这个类已经定义的构造函数,在其中安插一些代码,使这个类对象在进行构造时,先调用成员对象的构造函数。

同时,C++语言要求以成员对象在类中的声明顺序来调用各个构造函数。这一点由编译器完成,他会为类的每一个构造函数插入代码,以成员对象的声明顺序调用每一个成员对象所关联的默认构造函数。并且这些代码会插入在用户代码之前。

2. 带有默认构造函数的基类

如果一个子类没有构造函数,但他的基类有默认构造函数。那么这个子类的默认构造函数就是“有用的”。他会调用基类的默认构造函数(按照他们的声明顺序)。

如果程序猿对这个子类提供了多个构造函数,但没有默认构造函数,编译器会为每个用户定义的构造函数加入代码(调用基类的默认构造函数)。

但注意,编译器不会在生成一个新的默认构造函数,因为已经有用户提供的构造函数了。

如果同时存在带有默认构造函数的成员类对象,那么顺序为先调用基类的默认构造函数,在调用成员类对象的默认构造函数。

3. 带有一个虚函数的类

类声明(或继承)了一个虚函数。由于没有用户定义的构造函数,那么编译器生成的默认构造函数需要详细的记录一些信息。

  • 编译器会生成虚表,放置类的虚函数地址
  • 在每一个类对象中,编译器会生成一个虚表指针,放置虚表地址

为了让这个机制生效,编译器必须为每一个类对象的虚表指针设置初始值,用以指向合适的虚表地址。对于类所定义的每一个构造函数,编译器都会插入一些代码来做这个事情。

对于没有构造函数的类,编译器会生成默认构造函数,用以初始化每一个类对象的虚表指针。

4. 带有一个虚基类的类

子类继承的基类当中,有一个或多个虚基类。编译器必须让虚基类在每一个子类对象的位置,能够在执行器准备妥当。

对于类所定义的每一个构造函数,编译器会插入哪些“允许每一个虚基类的执行期存取操作”的代码。

如果类没有声明任何构造函数,编译器必须为他生成一个默认构造函数。

5. 总结

以上 4 种情况,编译器生成的默认构造函数是有用的。

其他情况,且用户没有定义任何构造函数,那么我们说他拥有的是一个“隐式的”、“无用的” 默认构造函数,实际上他们并不会生成。

编译器生成的默认构造函数中,只有基类对象和成员类对象会被初始化。其他的非静态数据成员(如整数、整数指针、整数数组等)都不会被初始化。这些初始化操作对程序而言或许有需要,但对编译器则没有必要。

二、初始化链表

在如下情况下,为了让程序可以有较高的效率,必须使用 member initialization list:

  1. 当初始化一个引用成员(reference member)时
  2. 当初始化一个 const member 时
  3. 当调用一个 base class 的构造,而他拥有一组参数时
  4. 当调用一个 member class 的构造,而他拥有一组参数时

为什么这么说呢?看一个例子,展示一些低效率

1
2
3
4
5
6
class Word {
String name_;
Word() {
name_ = "0";
}
};

这段代码本身从语法角度来看没有问题,但是效率低。因为编译器会重写如上的构造函数,以完成正确的代码逻辑

1
2
3
4
5
6
Word::Word() {
name_::String(); // 调用 String 的默认构造
String temp = String("0"); // 产生临时性对象
name_.String::operator=(temp); // "memberwise" 地拷贝 name_
temp.String::~String(); // 摧毁临时性对象
}

如果我们使用成员初始化链表,那将会是一个明显有效率的实现:

1
2
3
Word::Word : name_("0") { } 
会被编译器转换为:
Word::Word() { name_.String::String("0"); }

我们可以看到效率提高了很多,少了一个临时性对象的产生和销毁,和对象的拷贝操作

我们再来看初始化列表的一些规则:

  • 编译器会一一操作初始化列表,以适当的顺序在构造函数之内插入初始化操作,并且在任何显式的用户代码之前。
  • 初始化列表中初始化顺序是由类中的成员声明顺序决定的,不是由初始化列表中的排列顺序决定的

请牢记第二点,否则会造成一些 BUG,如下例子:

1
2
3
4
5
6
7
class X {
int i;
int j;
X (int val) : j(val), i(j) {}
};
// 在初始化列表中 i(j) 其实比 j(val) 更早执行,所以这个初始值就是非预期的
// 当然希望编译器发出一个警告,不过只有 g++ 做到了

再来看一个例子:

1
X::X (int val) : i( xfoo(val) ), j ( val ) { }

xfoo() 是 X 的一个成员函数。这种写法很糟糕,我们并不知道 xfoo() 函数对对象的依赖性有多高。明显属于对象都没有构造完全,却去使用对象的成员函数了。