对象的拷贝与移动
返回值优化
如果没有编译器的返回值优化,我们在设计函数时,返回一个对象并使用,往往带来多余的拷贝,影响程序的性能。例如设计一个函数从文件中读取大量的点信息:
std::vector<Point> getPointsFromFile(std::ifstream file) {
std::vector<Point> result;
while(!file.eof()) {
Point &pos = result.emplace_back();
file >> pos.x >> pos.y; // 这里只为了说明性能问题,不考虑代码安全
}
return result;
}
// 使用者上下文
std::vector<Point> points = getPointsFromFile(file);
process(points);
在没有编译器返回值优化的情况下,例如使用如下g++命令编译:
g++ ./main.cpp -fno-elide-constructors
result被构造填充,然后被拷贝到返回值临时对象,再由返回值临时对象拷贝到points。发生了两次拷贝,如果文件中存储着大量的点,拷贝操作将非常耗时。
如果将-fno-elide-constructors选项去掉,g++默认开启返回值优化:
g++ ./main.cpp
那么points和result实际是同一个对象,不存在任何拷贝。返回值优化的编译技术已经很成熟,目前常用版本的编译器即使使用-O0的默认优化选项,返回值优化也是默认打开的。
然而编译器的返回值优化并不是万能的,例如下面的场景:
std::vector<Point> getPoints(std::ifstream file) {
std::vector<Point> leftAreaPoints;
std::vector<Point> rightAreaPoints;
return isLeft ? leftAreaPoints : rightAreaPoints
}
由于具体使用哪一个对象返回需要到运行时才能确定,编译器无法进行优化。如何才能设计一个合适的类,既能功能正常,又能在对象拷贝或移动时性能最佳,请接着看浅拷贝与深拷贝
浅拷贝与深拷贝
如果一个类有指针成员变量,就需要特别小心得对待拷贝构造函数。例如下面的代码:
class HasPtrMem {
public:
HasPtrMem(): m_data(new int(0) {}
~HasPtrMem() { delete m_data; }
int *m_data;
}
int main() {
HasPtrMem a;
HasPtrMem b = a; // 使用编译器默认生成的拷贝构造函数
cout << *a.m_data << endl;
cout << *b.m_data << endl;
} // 因重复释放m_data而崩溃
因为HasPtrMem类没有自定义拷贝构造函数,拷贝构造新对象时会使用编译器默认生成的拷贝构造函数。默认的拷贝构造函数只会将m_data的地址拷贝给新的对象,就像这样:
HasPtrMem(const HasPtrMem& other) {
m_data = other.m_data;
}
当离开main函数时,对象a和b分别进行析构,m_data地址处的内存会被释放两次,从而导致程序崩溃。这种拷贝方式就是浅拷贝,问题的场景也是c++编程中经典的问题。最佳的解决方案是使用深拷贝,代码实现如下:
HasPtrMem(const HasPtrMem& other) {
m_data = new int(*other.m_data);
}
这样对象间都有申请自己独立的内存空间,避免了不同对象操作相同内存带来的问题。然而大块内存的申请释放往往很耗时,对于一些特殊的场景可以进一步优化来提升性能,请接着看对象的移动、Copy On Write
对象的移动
如果关闭了返回值优化,有这样一段代码:
HasPtrMem getTemp() {
return HasPtrMem();
}
int main() {
HasPtrMem a = getTemp();
}
getTemp函数中的HasPtrMem对象被构造出来后,先被拷贝到返回值临时变量,返回值临时变量再被拷贝到变量a。这里的两次拷贝,伴随着内存的申请、拷贝、释放,消耗大量的性能,相较于“拷贝”,我们更需要“剪切”。
在c++11引入了移动语义,对于将“临时变量”赋值给新变量的场景,可以出发移动构造函数,进而完成“剪切”操作。这里的“临时变量”有了一个专业而完整的定义:右值。
评论区