C++
September 9, 2023

关于C++函数返回值的拷贝优化问题

版权声明:本文为博主原创文章,转载请注明原文出处!

作者:阿振

写作时间:2023-09-09 08:00:26


在传统C++程序中,如果函数的返回值是一个对象的话,可能需要对函数中的局部对象进行拷贝。如果该对象很大的话,则程序的效率会降低。

在C++ 11以后,出现的移动语义(Move Semantic)及拷贝优化(Copy Elision)都是解决这个问题的方法。

本文试图以一个最简单的例子来说明这个问题。

案例

下面来看一个简单的例子(这里的BigObj类的实例假设是一个需要很大存储空间的大对象):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <iostream>

using std::cout;
using std::endl;


class BigObj
{
public:
BigObj()
{
cout << "这是默认构造函数" << endl;
}

BigObj(const BigObj& that)
{
cout << "这是拷贝构造函数" << endl;
}

BigObj(BigObj&& that)
{
cout << "这是移动构造函数" << endl;
}

~BigObj()
{
cout << "这是析构函数" << endl;
}
};


BigObj fun()
{
BigObj obj = BigObj();
return obj;
}

int main()
{
BigObj obj = fun();
return EXIT_SUCCESS;
}

拷贝优化

运行该程序,我们会得到如下输出:

1
2
这是默认构造函数
这是析构函数

可以发现fun()函数在返回BigObj对象的时候没有进行拷贝,这是由于编译期帮我们做了拷贝优化。

移动语义

但是编译器堆函数返回值的拷贝优化并不是能完全实现的,有一些特殊情况下会失效。所以比较保险的做法是定义移动构造函数,当没有拷贝优化的时候可以通过移动语义避免低效的拷贝。

我们可以通过-fno-elide-constructors关闭编译器的拷贝优化,下面是对应的cmake文件:

1
2
3
4
5
6
7
cmake_minimum_required(VERSION 3.26)
project(CxxTutorial)

set(CMAKE_CXX_STANDARD 23)
#SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-elide-constructors")

add_executable(CxxTutorial main.cpp)

通过配置关闭拷贝优化以后,我们执行上面的程序,输出结果如下:

1
2
3
4
这是默认构造函数
这是移动构造函数
这是析构函数
这是析构函数

可以看到关闭拷贝优化以后,在定义了移动构造函数的时候,函数返回零时对象的时候会调用移动构造函数,转义所有权,减少数据拷贝。但是移动构造也会生成一个新的对象,所以输出结果中会调用两次析构函数,第一次析构函数是析构了函数中定义的零时对象,第二次是析构了函数返回值返回后的对象。

那如果我们没有定义移动构造函数,而且编译期也没有进行拷贝优化程序的运行会是怎么样的呢?

注释掉上面的移动构造函数,我们可以看到输出结果如下:

1
2
3
4
这是默认构造函数
这是拷贝构造函数
这是析构函数
这是析构函数

这个结果是在预料之中的,没有拷贝优化,没有移动构造函数的情况下,程序会调用拷贝构造函数。假设这个对象是一个大对象,则拷贝过程会花费一些时间,降低了程序的执行效率。而使用移动语义的话,直接转义对象的所有权,效率会高一些。

结论

对于C++函数返回一个大对象的时候,在编译器能进行拷贝优化的时候,会优先进行返回值的拷贝优化。如果不能进行拷贝优化,在有定义移动构造函数的时候,则会调用移动构造函数进行返回值对象所有权转义,减少不必要的拷贝。最后,这两种情况失效的时候,才会调用拷贝构造函数进行对象的深拷贝。

有了上述结论,我们在写程序的时候最佳实践是函数返回值可以直接返回函数体内定义的零时对象,但是我们需要在定义该对象的时候实现移动构造函数。这样就可以保证函数的返回值要么有编译器拷贝优化,要么会调用移动构造函数减少拷贝开销。