0%

再探C++对象构造

​ 我们在构造C++对象时,经常会使用两种不同的声明语法;那这两种声明语法对于g++编译器来说到底有什么差异吗,其构造逻辑又会有什么不同吗?一直心存疑惑,做个实验一探究竟。

不同的对象创建初始化方法

​ 首先,来定义一个简单的C++类;分别在构造函数、Copy构造函数、Copy赋值操作符中打印不同的语句,以跟踪不同的行为逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Example {
public:
Example() :data(0) {
std::cout << "Default Constructor." << std::endl;
}

Example(int foo) :data(foo) {
std::cout << "Constructor called: " << foo << std::endl;
}

Example(const Example& p) {
std::cout << "Copy Constructor" << std::endl;
this->data = p.data;
}

Example& operator=(const Example& p) {
std::cout << "Copy Assignment: " << p.data << std::endl;
this->data = p.data;
return *this;
}

public:
int data;
};

​ 然后,以3种不同的方法来构造对象,代码如下:

1
2
3
4
5
6
7
//第1种:
Example test1(55);
//第2种:
Example test2 = Example(77);
//第3种:
Example test3;
test3 = Example(99);

我的理解

​ 根据语义直接理解,我猜想的3种方法各自的逻辑应该如下:

1
2
3
4
5
6
7
- 第1种:
- 直接调用带参数构造函数创建对象`test1`
- 第2种:
- 首先调用带参数构造函数创建一个临时对象
- 调用Copy构造函数,使用临时对象初始化`test2`
- 第3种:
- 应该与第2种方式行为相同(不管第2种是什么)

​ 那这些猜想是否正确呢,只有实践才能证明。

实际运行结果

​ 使用g++编译器编译运行,编译命令:g++ -o example Example.cpp -O2,运行输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
第1种输出:
--------------------
Constructor called: 55

第2种输出:
--------------------
Constructor called: 77

第3种输出:
--------------------
Default Constructor.
Constructor called: 99
Copy Assignment: 99

​ 输出结果与我最初猜想的不一样,起初我怀疑是编译优化级别导致的,分别使用了-O1 and -O3选项来编译,但输出结果是相同的。

​ 那么先来分析下实际的输出结果:

1
2
3
4
5
6
- 第1种,没有问题,与猜想的一致:直接调用带参数构造函数创建对象`test1`
- 第2种,直接用了带参数构造函数创建`test2`,没有创建临时对象再调用Copy构造
- 第3种,与实际的第2种行为并不相同,实际行为是:
- 首先,调用默认构造函数创建`test3`
- 然后,调用带参数构造函数创建临时对象
- 最后,调用Copy赋值操作符,使用临时对象对`test3`赋值

​ 为什么会出现这种结果,就不得不提编译器的拷贝优化技术

拷贝优化

拷贝优化是编译器的一种优化技术,编译器会在特定情况下(比如,使用函数返回值初始化对象或将临时以值方式传参)优化掉Copy操作(Copy构造函数调用)。

​ g++编译器默认开启拷贝优化以提高效率,所以对第2种方法进行了优化,没有创建临时对象和Copy赋值。如何来验证我们的想法,那就需要关闭编译器拷贝优化。

​ 通过编译选项-fno-elide-constructors关闭拷贝优化,编译命令:g++ -o example Example.cpp -O2 -fno-elide-constructors

​ 关闭拷贝优化后的运行输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
第1种输出:
--------------------
Constructor called: 55

第2种输出:
--------------------
Constructor called: 77
Copy Constructor

第3种输出:
--------------------
Default Constructor.
Constructor called: 99
Copy Assignment: 99

​ 关闭编译器的拷贝优化之后,第2种方法的行为与开启优化相比发了变化:先创建临时对象,然后调用Copy构造初始化目标对象。关闭拷贝优化导致多了一次Copy构造函数调用,执行效率打了折扣。

​ 其实,C++标准对第2种方法的实现有规定:

C++标准允许编译器使用两种方式来执行第2种方法:

  1. 使其行为和第1种方法完全相同;
  2. 允许调用构造函数创建一个临时对象,然后将该临时对象复制到test2中,然后销毁临时对象;

​ 如此看来,g++编译器对标准规定的两种方式都支持,取决于拷贝优化是否开启;但C++是注重效率的编程语言,因此编译器的拷贝优化默认开启

小结一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//第1种:
//1次调用带参构造函数
Example test1(55);

//第2种:
//取决于编译器拷贝优化是否开启:
//开启:与第1种相同
//关闭:1次构造,1次Copy构造;先生产临时对象,再调用Copy构造初始化test2
Example test2 = Example(77);

//第3种:
//2次构造,1次Copy赋值,效率低:
//1.调用默认构造函数初始化test3
//2.调用带参构造生成临时对象
//3.调用Copy赋值操作将临时对象复制到test3
Example test3;
test3 = Example(99);
  • 在编译器拷贝优化开启的情况下:
    • 第1种和第2种方法实现相同
  • 在编译器拷贝优化关闭的情况下:
    • 第1种和第2种实现实现不同
    • 第2种会引入一次Copy构造函数调用,效率变低
  • 无论是否开启拷贝优化,对第3种方法没有影响:
    • 在本文的情况里,第3种与第2种逻辑等价,但编译器并没有将第3种优化为第2种
    • 实际程序种应该避免第3种写法
    • 需要进一步的探究…

初始化和赋值

再看下面两个语句的不同:

1
2
Example test2 = Example(77);
test1 = Example(88);
  • 第一条语句是初始化,调用构造函数创建新的对象;
  • 第二条语句是赋值,总是调用构造函数创建临时对象,然后将临时对象通过Copy赋值操作符复制给已经存在的另一个对象;

从输出结果中看到,Copy赋值操作符被调用了:

1
2
3
4
Constructor called: 77
--------------------
Constructor called: 88
Copy Assignment: 88

C++11列表初始化

C++11可以使用列表初始化对象,只要提供与某个构造函数的参数列表匹配的内容,并用{}将他们括起来:

1
2
3
Example test3 = {33};
Example test4 {44};
Example *test5 = new Example{5};