类的成员变量的初始化细节
首先,来看两个问题:
- 类的构造函数中,成员变量的列表初始化是如何实现的?
- 为什么列表初始化效率上优于在构造函数中为成员变量赋值?
(后文中,将 "在构造函数中为成员变量赋值" 简称为 "构内赋值"。)
这两个问题从何而来
通常,当你搜索为什么列表初始化优于构内赋值时,基本上所有的博文都会告诉你:"列表初始化使得成员变量在被定义时绑定初值;而在构造函数内赋值,成员变量会先在定义时被初始化为0,然后再被赋值为指定的初值。"总之,意思是,列表初始化相比于构内赋值少了一次对内存的写入。至于这样的说法是否正确?为什么会这样?列表初始化和构内赋值的实现细节是什么样的?很少有人分析。所以,本着"实践出真知"的道理,我们在这本篇博文中做了一些列的实验,并详细的讲述了为什么初始化列表由于构内赋值?。
列表初始化 和 构内赋值 的实现细节
首先,先将提出的两个问题回答一下:
- 类的列表初始化是通过成员变量的拷贝构造函数实现的。
- 列表初始化相比于构内赋值,减少了一半的函数调用,减少了一半的内存写入。
列表初始化和构内赋值的具体的流程图如下:
构内赋值 的实现细节
构内赋值分为两步实现:
- 调用各级成员的默认构造函数
- 调用各级成员的赋值函数
首先,我们来解释一下什么是"各级成员"?如下,类 A 中包含类型为 B 的成员变量 b,而 B 类型还可能包含类型为 C 的成员变量 c,如此递推。直至递推到基础类型(比如 int)。对于类 A 而言,b, c, d ... 就是它的各级成员。
C++
class C{
D d;
};
class B{
C c;
};
class A{
B b;
};
我们通过如下代码来测试构内赋值是如何实现的:
C++
#include<iostream>
class Test0{
int a;
public:
Test0():a(0){
std::cout<< "0默认构造\n";
}
Test0(const Test0& t1):a(t1.a){
std::cout<< "0拷贝构造\n";
}
Test0(Test0&& t1):a(t1.a){
std::cout<< "0移动构造\n";
}
void operator= (const Test0& t1){
std::cout<< "0赋值函数\n";
a = t1.a;
}
};
class Test1{
Test0 t;
public:
Test1(){
std::cout<< "1默认构造\n";
}
Test1(const Test1& t1):t(t1.t){
std::cout<< "1拷贝构造\n";
}
Test1(Test1&& t1):t(t1.t){
std::cout<< "1移动构造\n";
}
void operator= (const Test1& t1){
std::cout<< "1赋值函数\n";
t = t1.t;
}
};
class Test2{
private:
Test1 t1;
public:
Test2(const Test1& t1){
std::cout<< "2构造\n";
this->t1 = t1;
}
};
int main(){
Test1 t1; \\ main 函数第一行代码
std::cout<< "---------------\n";
Test2 t2(t1); \\ main 函数第三行代码
}
在上面的代码中,我们定义了三个类,并依次包含,最底层的类 Test0 包含了一个基础类型 int。在 main 函数中,我们首先默认构造了一个 Test1 类型的对象 t1,而后将 t1 传入到 Test2 的构造函数中。Test2 的构造函数,是通过构内赋值实现的。我们运行上述代码,结果如下:
0默认构造
1默认构造
---------------
0默认构造
1默认构造
2构造
1赋值函数
0赋值函数
可以看到,main 函数的第一行代码通过默认构造函数构建对象 t1,从输出的第 1、2 行可以看出,t1 及其各级成员的构造函数自下而上的运行,即:int 的默认构造 -> Test0 的默认构造 -> Test1 的默认构造。默认构造函数,会定义变量,并初始化为 0。
main 函数的第三行我们定义变量 t2,将 main 函数第一行定义的 t1 传入其构造函数。从输出的第 4、5、6 行可以看出,t2 的成员变量 t1 首先经过了默认构造,然后才进入到 t2 的构造函数中。而后在 t2 的构造函数中,我们将 main 函数第一行定义的 t1 赋值给 t2 的成员变量 t1。从输出的 7、8 行可以看出,这个赋值操作自上而下的调用了成员的赋值函数,即: Test1 的赋值函数 -> Test0 的赋值函数 -> int 的赋值函数。
至此完成了构内赋值。整个过程的资源分析如下:
- 调用了各级成员的默认构造
- 向基础类型成员的内存中写入 0
- 调用了各级成员的赋值函数
- 向基础类型成员的内存中写入初值
列表初始化的实现细节
我们通过如下代码来观察列表初始化的实现细节:
C++
#include<iostream>
class Test0{
int a;
public:
Test0():a(0){
std::cout<< "0默认构造\n";
}
Test0(const Test0& t1):a(t1.a){
std::cout<< "0拷贝构造\n";
}
Test0(Test0&& t1):a(t1.a){
std::cout<< "0移动构造\n";
}
void operator= (const Test0& t1){
std::cout<< "0赋值函数\n";
a = t1.a;
}
};
class Test1{
Test0 t;
public:
Test1(){
std::cout<< "1默认构造\n";
}
Test1(const Test1& t1):t(t1.t){
std::cout<< "1拷贝构造\n";
}
Test1(Test1&& t1):t(t1.t){
std::cout<< "1移动构造\n";
}
void operator= (const Test1& t1){
std::cout<< "1赋值函数\n";
t = t1.t;
}
};
class Test2{
private:
Test1 t1;
public:
Test2(const Test1& t1):t1(t1){
std::cout<< "2构造\n";
}
};
int main(){
Test1 t1;
std::cout<< "---------------\n";
Test2 t2(t1);
}
相比于构内赋值的实现细节中的代码,我们将 Test2 的构造函数改为列表初始化。代码的运行结果如下:
0默认构造
1默认构造
---------------
0拷贝构造
1拷贝构造
2构造
观察输出的 4,5 行,列表初始化通过调用各级成员的拷贝构造函数来完成。这种调用是自下而上的,即:int 的拷贝构造 -> Test0 的拷贝构造 -> Test1 的拷贝构造。基础类型的成员经历了一次内存写入。
观察输出的 4, 5, 6 行,列表初始化在进入构造函数的函数体之前完成。
列表初始化的资源分析如下:
- 调用了各级成员的拷贝构造函数
- 向基础类型成员的内存中写入初值
列表初始化 与 构内赋值 所用资源比较
构内赋值的资源分析如下:
- 调用了各级成员的默认构造
- 向基础类型成员的内存中写入 0
- 调用了各级成员的赋值函数
- 向基础类型成员的内存中写入初值
列表初始化的资源分析如下:
- 调用了各级成员的拷贝构造函数
- 向基础类型成员的内存中写入初值
可见,列表初始化比构内赋值减少了一半的资源调用和一半的内存写入。因此列表初始化由于构内赋值。
看来,大多数博文中说的不完全对,他们只说对了内存,却没有分析函数的调用次数。