C++ 按值返回对象那些事

前言

       某年某月的某一天,组里新来了一个工作多年的专家工程师。领导让其在我当前负责的模块上做一些优化工作,很快专家提出来很多C++语法上的修改意见。比如:

1
2
3
4
5
6
vector<string> test()
{
vector<string> v;
... // do somthing
return v;
}

       建议改成:

1
2
3
4
void test(vector<string> &v)
{
... // do somthing
}

       其理由是按值返回STL容器对象,会产生拷贝。
       我内心万马奔腾:
       如果我们是C++98,说这个意见,或许还能理解。但现在是2021年,项目用的C++版本是C++11,这个修改却并不正确!
       即便是C++98,编译器其实也对此有NRVORVO的优化,避免拷贝,只要你不去主动关闭优化,基本都能享受到。
       类似的问题在StackOverflow上早有讨论

NRVO、RVO 与 Copy Elision

       我再来稍微展开一下,C++11开始当按值返回的时候,自动尝试使用move语义,而非拷贝语义,被称为copy elision(复制消除)。而在C++11之前有RVO(返回值优化)或NRVO(具名返回值优化),C++11以后也同样存在。都能提高C++函数返回时的效率,减少冗余的拷贝。举个例子这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <vector>
#include <iostream>
using namespace std;
vector<int> test(int n)
{
vector<int> v;
for(int i = 0; i < n; ++i)
{
v.push_back(i);
}
cout << &v << endl;
return v;
}
int main()
{
vector<int> v = test(10);
cout << &v << endl;
return 0;
}

       使用C++98和C++11分别编译:

1
2
g++ test.cpp -std=c++98 -o 98.out
g++ test.cpp -std=c++11 -o 11.out

       分别运行:

1
2
3
4
5
6
7
./98.out
0x7ffc680bf490
0x7ffc680bf490
./11.out
0x7ffc5e871300
0x7ffc5e871300

       可以看出函数内的临时对象和函数外接收这个返回值的对象是同一个地址,也就是说没有产生拷贝构造。但是按C++11之前标准这里应该是拷贝构造,这一优化就是NRVO,当然这属于编译器厂商们自己做的优化(即使不开O1、O2这种优化,也会默认做),是非标的。注意这并不是C++11标准要求的copy elision
       另外提一句什么是RVO呢?如果是返回没有名字的匿名对象,编译器对其做同样的优化就是RVO。比如:

1
2
3
4
5
6
7
8
vector<int> test()
{
    int x = 0;
    int y = 0;
    int z = 0;
    ... // 修改了x,y,z的值
    return {x, y, z}
}

       来回到之前的例子,我们关闭NRVO来看看,给g++加上一个参数-fno-elide-constructors即可。

1
2
g++ test.cpp -std=c++98 -fno-elide-constructors -o 98.out
g++ test.cpp -std=c++11 -fno-elide-constructors -o 11.out

       再执行看看:

1
2
3
4
5
6
7
./98.out
0x7ffc0988eac0
0x7ffc0988eb00
./11.out
0x7fff39efc750
0x7fff39efc790

       去掉NRVO后,可以看到二者不是同一个对象了。但其实对于C++11的代码而言,这其中仍然有copy elision,也就是说会自动执行move语义,我们改下测试代码:

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
#include <vector>
#include <iostream>
using namespace std;
vector<int> test(int n)
{
vector<int> v;
for(int i = 0; i < n; ++i)
{
v.push_back(i);
}
    cout << "obj stack addr: "<< &v << " in test" <<endl;
    cout << "obj data  addr: "<< v.data() << " in test" <<endl;
return v;
}
int main()
{
vector<int> v = test(10);
    cout << "obj stack addr: "<< &v << " in main" <<endl;
    cout << "obj data  addr: "<< v.data() << " in main" <<endl;
return 0;
}

       然后重新携带-fno-elide-constructors参数分别编译执行。

1
2
3
4
5
6
7
8
9
10
11
./98.out
obj stack addr: 0x7ffc1301c090 in test
obj data addr: 0x55b81763af20 in test
obj stack addr: 0x7ffc1301c0d0 in main
obj data addr: 0x55b81763b380 in main
./11.out
obj stack addr: 0x7ffeb4acac30 in test
obj data addr: 0x556ecd26ef20 in test
obj stack addr: 0x7ffeb4acac70 in main
obj data addr: 0x556ecd26ef20 in main

       可以看出,尽管C++11去掉了NRVO以后,main函数中的对象v和test函数中的对象v不是同一个。但他们中的data()指向的数据地址是同一个。也就是说C++11开始,你用函数按值返回一个STL容器,即使没有显式地加move,也会自动按move语义走,进行数据指针的修改,而不会拷贝全部的数据。
       当然copy elision并不是只针对STL容器类型啦,所有有move语义的对象类型都可以。但当没有move语义时,如果去掉NRVO还是会执行拷贝的。
       再看个自定义类型的代码:

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <vector>
#include <iostream>
using namespace std;
class A
{
public:
    A()
{
        cout << this << " construct " <<endl;
        _data = new int[size];
    }
    A(const A& a)
{
        cout << this << " copy from " <<&a <<endl;
        _data = new int[a._len];
        for(size_t i = 0; i < a._len; i++)
{
            this->_data[i] = a._data[i];
        }
    }
    ~A()
{
        if(_data)
{
            delete[] _data;
        }
    }
    bool push_back(int e)
{
        if(_len == size)
{
            return false;
        }
        _data[_len++] = e;
        return true;
    }
    intdata()
{
        return _data;
    }
    size_t length()
{
        return _len;
    }
private:
    static const int size = 100;
    int_data = nullptr;
    size_t _len = 0;
};
test(int n)
{
    A a;
    for(int i = 1; i <= n; i++)
    {
        a.push_back(i);
    }
    cout << "obj stack addr: "<< &a << " in test" <<endl;
    return a;
}
int main()
{
    A a = test(10);
    cout << "obj stack addr: "<< &a << " in main" <<endl;
return 0;
}

       去掉NRVO用C++11编译。

1
g++ test.cpp -std=c++11 -fno-elide-constructors -o 11.out

       执行:

1
2
3
4
5
6
./11.out
0x7ffcdca8fe80 construct
obj stack addr: 0x7ffcdca8fe80 in test
0x7ffcdca8fec0 copy from 0x7ffcdca8fe80
0x7ffcdca8feb0 copy from 0x7ffcdca8fec0
obj stack addr: 0x7ffcdca8feb0 in main

       可以看到由于我们自定义的类型A没有move语义,所以这里调用了拷贝构造函数,并且调用了两次。第一次是在test函数内从具名的对象a,拷贝到临时变量作为返回值。第二次是从该返回值拷贝到main函数中的对象a。
       我们来给他加上move构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
...
    A(A&& a)
 {
        cout << this << " move data from " << &a <<endl;
        _data = a._data;
        a._data = nullptr;
        // 或使用交换
        // swap(_data, a._data);
    }
...

       重新编译:

1
g++ test.cpp -std=c++11 -fno-elide-constructors -o 11.out

       然后运行:

1
2
3
4
5
0x7ffe84ad74c0 construct
obj stack addr: 0x7ffe84ad74c0 in test
0x7ffe84ad7510 move data from 0x7ffe84ad74c0
0x7ffe84ad7500 move data from 0x7ffe84ad7510
obj stack addr: 0x7ffe84ad7500 in main

       可以看调用到了move构造函数。

总结

       听完专家的一系列修改意见之后,我觉得还是我自己优化更靠谱一些。这些语法上的问题,其实能优化的我基本都优化过了,没办法从语法上再拿到太多性能增益了。我感觉还是要从策略与逻辑入手,去寻找优化点。很快,一个月内,我连续两次给这个模块的耗时做了提升,999分位减少了60ms。接着我继续做该模块的负责人,专家被安排到其他“人力不足”的模块去帮忙了。
       但自此我还是免不得多了一个习惯,在按值返回容器的函数上加一个注释:

1
2
3
4
5
6
7
// It's OK in C++11!
vector<string> test()
{
vector<string> v;
... // do somthing
return v;
}

文章目录
  1. 1. 前言
  2. 2. NRVO、RVO 与 Copy Elision
  3. 3. 总结