ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++右值引用与移动语义:深拷贝性能瓶颈的终极优化方案

C++右值引用与移动语义:深拷贝性能瓶颈的终极优化方案 前阵子做内部工具压测碰到了一个很有意思的瓶颈。一块解析逻辑算法复杂度明明不高压测曲线却难看得很。连续抓了几次CPU profile发现时间全耗在构造函数和内存拷贝上。再往下挖罪魁祸首就是临时对象反复触发深拷贝。这个问题在C98时代几乎是死结而C11引入右值引用之后处理方式发生了根本变化。很多C开发者对右值引用的印象停留在多了一个符号或者std::move用来转一下类型但真正让它有价值的场景恰恰是深拷贝优化——把复制一份数据变成移交一份数据。这篇文章我就从深拷贝的性能问题讲起一层层拆开右值引用、移动语义、完美转发背后的设计逻辑再附上我在实际项目里踩过的坑和检查心得。1. 深拷贝的性能账临时对象到底浪费了什么1.1 一个最简单的字符串类藏着多少拷贝先来看一个最基础的场景。假设我们自己实现了一个迷你字符串类内部维护一块堆内存#include cstring class MyString { public: MyString(const char* s) : size_(s ? strlen(s) : 0) , data_(nullptr) { if (size_) { data_ new char[size_ 1]; memcpy(data_, s, size_ 1); } } // 拷贝构造深拷贝一份数据 MyString(const MyString rhs) : size_(rhs.size_) , data_(nullptr) { if (size_) { data_ new char[size_ 1]; memcpy(data_, rhs.data_, size_ 1); } } // 拷贝赋值先释放旧资源再深拷贝 MyString operator(const MyString rhs) { if (this ! rhs) { delete[] data_; size_ rhs.size_; data_ nullptr; if (size_) { data_ new char[size_ 1]; memcpy(data_, rhs.data_, size_ 1); } } return *this; } ~MyString() { delete[] data_; } private: size_t size_; char* data_; };这个类从功能上讲是自洽的构造、拷贝、析构都照顾到了。但性能问题很扎眼——每次拷贝都要new[]一次然后把数据原封不动复制一遍。问题在于当你的代码里出现临时对象的时候复制内容可能完全是多余的。常见的临时对象场景有三个按值传参、按值返回、容器操作。拿按值传参举例void process(MyString s) { // 处理s } MyString a(hello); process(a); // 拷贝a process(world); // 构造临时MyString(world)再拷贝临时对象第二行调用里world先隐式构造成一个临时MyString然后临时对象被拷贝进参数s函数结束后临时对象和s再依次析构。这里最亏的就是那次深拷贝临时对象马上就要销毁了还要把它的内存复制一份给s临时对象自己的那份内存再释放掉。一拷贝一释放等于白干。1.2 编译器能帮你省掉什么时候省不掉什么时候你可能听说过返回值优化Return Value Optimization, RVO它能帮我们省掉返回时的拷贝。比如MyString makeString() { return hello; } MyString s makeString();在C98时代编译器就有能力把hello直接构造到s的内存里省掉拷贝甚至省掉临时对象。但注意这里只是优化编译器有义务保证省掉它也不会改变程序语义析构调用的次数之类可能受影响但C98对这些边角的处理比较宽松。问题在于不是所有拷贝都能被这种重命名优化消除。看这个场景std::vectorMyString v; v.push_back(MyString(hello)); v.push_back(MyString(world));push_back接收的是一个临时对象。在C98里vector内部会把这个临时对象拷贝到它管理的存储空间里然后临时对象再析构。更糟糕的是如果vector恰好发生了扩容旧的存储空间里已经存在的那些元素还得逐个拷贝到新空间那些元素本身可不是临时对象编译器没法自动豁免。这种情况下深拷贝是很必要的吗从逻辑角度是。但从资源归属角度不是。临时对象和容器里的元素都持有各自的内存它们确实是独立的对象。可问题是如果临时对象的资源能直接转手给容器里新创建的那个对象谁都不用复制谁也不用重复释放。C98做不到因为语言层面没有一个机制让你安全地表达把我的内存拿走反正我马上死了。1.3 深拷贝必须存在但无脑深拷贝会成为性能瓶颈再往深一层想深拷贝这个机制本身没有错。两个独立对象天然需要独立的数据浅拷贝让两个对象共用一个资源块析构时就是双重释放这是灾难。所以深拷贝是安全的默认行为这个定位完全正确。真正错误的是把深拷贝用在了没必要的地方——对象即将消亡资源可以移交的场景。C11解决这个问题的方式不是废除拷贝而是提供另一套动作移动。移动的本质就是移交资源而右值引用正是为了支持这套动作而设计的语言基础。这套设计的精妙之处在于它会以类型系统的形式把你的意图固化到代码里。编译器可以确定一个表达式是左值还是右值可以确定一个对象是即将消亡的临时对象还是生命周期很长的具名对象。有了这些信息你就能写出只对即将消亡的对象生效的构造方式而普通对象继续走深拷贝互不干扰。2. 右值引用的本质专门盯上临时对象2.1 左值右值先回到C98的概念想理解右值引用绕不开左值lvalue和右值rvalue这对概念。C98时代就有这两个词只是当时它们对程序员的实际影响不大大家最常感受到的可能是引用只能绑定左值除非加const。用大白话定义左值有名字、可以取地址、生命周期通常持续到作用域结束。比如int x 1;里的x。右值临时产生、通常没有名字、不能取地址、在当前完整表达式结束时销毁。比如x 1这个表达式的值、MyString(temp)构造出来的临时对象。在C98里T只能绑定左值const T可以绑定右值——这就是为什么const引用能传临时对象但也正因是const你无法修改临时对象。在那时无法修改临时对象不是什么大问题因为根本没有要修改它的场景。C11的变化是语言专门引入了一种新的引用类型T。它只能绑定右值而且绑定后你可以在上面做修改。这个允许修改即将消亡的对象的能力就是移动语义的钥匙。2.2 右值引用的声明姿势与普通引用区分开最简单的区分方式int x 42; int ref x; // OK左值引用绑定左值 // int ref2 42; // 不OK左值引用不能绑定右值 int rref 42; // OK右值引用绑定右值 // int rref2 x; // 不OK右值引用不能绑定左值int rref 42;这里字面量42是一个右值rref是它在当前作用域里的具名替身。它绑定了临时值使得该值的生命周期延长到rref的生命周期结束。这个特性本身是有用的但右值引用真正的价值不在于延长生命周期而在于让重载决议区分左值和右值。有了T重载函数就能区分实参是左值还是右值void func(MyString s); // 左值版本 void func(MyString s); // 右值版本当调用func(temp)时temp是右值会优先匹配右值版本。右值版本里你可以安全地掠夺s的资源因为传进来的是一个即将销毁的对象。这是编译期强制保证的不是靠程序员自觉。理解这一点再看移动构造函数就不觉得难了。2.3 最迷惑人的细节右值引用变量本身是左值这里必须强调一个很多新手栽过的坑被绑定的东西声明之后它的名字就变成了左值。int rref 42; int* p rref; // 可以取地址说明rref具有左值属性更直接地说rref有名字、可取地址它本质上是一个被命名为 rref 的左值只是它内部保存的值来自一个右值。当你写func(rref)时重载决议会把它当左值处理走func(MyString)那个版本。这在移动构造函数里体现得淋漓尽致。看一个标准写法MyString(MyString rhs) noexcept : data_(rhs.data_) , size_(rhs.size_) { rhs.data_ nullptr; rhs.size_ 0; }参数rhs本身是绑定到右值的引用但函数体内rhs是一个具名变量属性是左值。可我们并不想拷贝rhs.data_指向的那块内容只想把指针rhs.data_的值直接拿过来——这不需要std::move直接读取指针值就行。之后把rhs.data_置空是为了避免两个对象管理同一块内存导致双重释放。换句话说移动构造函数里你做的事情不是移动参数而是掠夺参数持有的资源。能让掠夺合法化靠的是调用方告诉你这个对象马上死了你可以放心拿——这就是右值引用在调用点表达的信息。3. 移动构造函数和移动赋值运算符的完整实现3.1 移动构造函数从复制内容到移交指针回到前面的MyString补上移动构造MyString(MyString rhs) noexcept : size_(rhs.size_) , data_(rhs.data_) { rhs.size_ 0; rhs.data_ nullptr; }逐行理解size_(rhs.size_)直接复制长度不需要重新计算。data_(rhs.data_)直接把堆内存指针拿过来。这里标量复制不是memcpy大片数据。rhs.size_ 0; rhs.data_ nullptr;把源对象置为空状态。因为源对象随后会被析构如果仍持有指向同一块内存的指针析构时delete[] data_会和移动后的对象冲突造成双重释放。这里有一个很有意思的对比移动操作实际做的事情代码量上和指针浅拷贝几乎一样。但语义完全不同。浅拷贝是两个对象共享同一块内存程序员需要手工想办法避免双重释放移动是所有权从源对象转移到目标对象源对象变为空壳这是受语言规则保护的合法动作。移动构造函数的参数为什么是MyString因为在调用点a b或MyString x std::move(y)时std::move(y)产生一个右值编译器用重载决议选出移动构造版本。而普通左值会走拷贝构造行为不变保证了原有代码的安全性。3.2 移动赋值运算符先清理旧资源再接管新资源移动赋值更讲究顺序MyString operator(MyString rhs) noexcept { if (this ! rhs) { delete[] data_; data_ rhs.data_; size_ rhs.size_; rhs.data_ nullptr; rhs.size_ 0; } return *this; }关键点是先delete[] data_释放目标对象自己原来的资源避免内存泄漏然后把rhs.data_接管过来再把源对象置空。顺序上最危险的情况是data_ rhs.data_之后源对象又析构但因为我们把rhs.data_置空了析构时delete nullptr是安全的。if (this ! rhs)这个检查一开始觉得多余后来在真实项目管理中发现很有必要。自移动虽然理论上不该出现但模板泛型代码里一个std::swap或其他工具可能不经意间把对象移动给自己。标准库规定自移动后对象处于有效但未指定状态可我们自己的类最好还是防御一下。3.3 noexcept不是可有可无的修饰移动构造和移动赋值建议总是标noexcept。这不是强迫症它直接影响标准库容器性能。以std::vector扩容为例。容量不够时vector需要把旧存储区里的元素搬移到新存储区。它有两个选择如果元素的移动构造不抛异常noexcept就调用移动构造搬移。如果移动构造可能抛异常vector会退回到拷贝构造。原因是异常安全拷贝构造如果中途失败旧存储区里的元素依然完整vector可以保持旧状态而移动构造如果搬了一半抛异常旧元素的资源已经一团糟无法回滚。标准库容器为了基本异常安全保证宁可走得慢一点也不敢冒险。所以别小看一个noexcept。定义了移动构造但没标noexcept在vector扩容、std::rethrow_if_nested这类场景里编译器会保守地选择更慢的路径。这也是为什么我在项目中规定凡是自己管理资源的类移动构造和移动赋值一律noexcept。3.4 三法则升级成五法则记牢了C98有三法则如果一个类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个通常三个都需要自定义。C11把它扩展成五法则加入移动构造函数和移动赋值运算符。容易出现的问题在部分定义上。举个例子class A { public: A(const A) { /* 自定义拷贝构造 */ } // 没有声明移动构造 private: int* ptr; };这种情况下编译器不会自动生成移动构造。因为标准规定用户声明拷贝构造函数后移动构造函数不会被隐式生成。结果是你用std::move(a)传给一个按A接收的函数时实际执行的是拷贝构造。行为没错性能可能不如预期。反过来也有坑。如果只定义了移动构造没定义拷贝构造那么拷贝构造会被delete代码里一旦出现拷贝意图就会编译失败。这有时是好事比如unique_ptr就是只可移动不可拷贝但如果是自己写的工具类要注意接口变化对既有代码的破坏。项目里我的习惯是对资源管理类五法则要么全部明确写出要么用 default和 delete把意图写清楚不留模糊区。4. std::move的本质它只是把左值“伪装”成右值4.1 源码拆解std::move什么都没移动std::move这个名字非常容易误导人不少新手以为调用它之后资源就飞到目标对象去了。实际上它只是一个强制类型转换template typename T constexpr std::remove_reference_tT move(T t) noexcept { return static_caststd::remove_reference_tT(t); }它做的事情就是把传入的东西强制转成右值引用仅此而已。真正的移动动作发生在移动构造函数或移动赋值运算符内部——是那里面的一句句指针交接语句不是std::move自己。理解这一点很重要。很多人写std::move(s)后立刻访问s发现数据还在就疑惑是不是没移动成功。移动是否发生取决于接收方是否触发了移动语义的构造函数/赋值函数。如果接收方是个拷贝接口那std::move(s)也只是给它传了个右值走拷贝路径s自然毫发无损。4.2 该用std::move的地方资源转移的开关典型场景之一是给容器装填具名局部对象MyString create() { MyString tmp(something); return tmp; // 通常不需要 moveNRVO 更香 } std::vectorMyString vec; MyString s(hello); vec.push_back(std::move(s)); // 表示s 的数据我不要了请直接接管 s.clear(); // 合法但之后不要依赖 s 的数据push_back(std::move(s))之后s大概率变为空状态但标准只说它是有效但未指定。所以用完std::move的对象不要访问它的内容除非马上重置或覆盖。unique_ptr同样依赖移动语义std::unique_ptrWidget p1 std::make_uniqueWidget(); std::unique_ptrWidget p2 std::move(p1); // 所有权转移unique_ptr没有拷贝构造强行拷贝会编译报错std::move是唯一合法的转移方式。这种设计从源头防止了资源所有权的意外复制。4.3 不该用std::move的地方比你想的更多第一函数返回局部对象时别写return std::move(local)。现代编译器普遍支持命名返回值优化NRVO可以直接在调用方内存里构造结果省掉移动甚至拷贝。如果你写std::move(local)反而把返回值的右值属性坐实了NRVO 没法直接省略移动还会制造一次额外的移动操作。换句话说返回局部对象什么都不做往往最优。第二对const对象用std::move是白费力气const MyString s(hello); MyString t(std::move(s)); // 实际调用的是拷贝构造因为移动构造需要修改源对象MyString绑定到const MyString会失败编译器退而选择const MyString拷贝构造。结果是复制了一份数据std::move在这里完全没起到优化作用还让读者误以为数据被转移了。第三对内置类型或微小对象用std::move没有意义。int、double、指针这些类型拷贝和移动的成本一样std::move只会让代码更绕。这种过度使用在大规模代码库里会降低可读性。5. 模板里的转发引用与完美转发std::forward的价值5.1 万能引用转发引用不是一个语法错觉模板参数推导下T不再等于右值引用而是被标准称为转发引用也叫万能引用。判断方法很简单如果T是一个模板参数且类型是T那它就是转发引用否则就是普通的右值引用。template typename T void wrapper(T arg) { // 转发引用 // ... }当你调用wrapper(x)x是左值时T被推导为MyStringarg实际类型是MyString当你调用wrapper(std::move(x))时T推导为MyStringarg实际类型是MyString。一套代码同时服务左值和右值这就是“万能”的含义。5.2 引用折叠规则一张表记清楚大家可能好奇T和遇到一起怎么处理C11 定义了引用折叠规则模板参数T推导参数实际类型折叠结果TT TTT TTT TTT T简单记忆两个引用中只要有一个是左值引用结果就是左值引用只有两个都是右值引用时结果才是右值引用。5.3 std::forward的作用把转发引用的“身份”保住转发引用确实能绑定左值和右值但函数体内arg作为一个具名变量永远是左值。问题来了如果你在wrapper内部想把实参继续往下传template typename T void wrapper(T arg) { target(arg); // 这里 arg 是左值右值信息丢了 }如果调用方传的是一个右值到target(arg)这步就会被当成左值处理导致target选择左值版本拷贝而不是移动。解决方式是用std::forwardtemplate typename T void wrapper(T arg) { target(std::forwardT(arg)); }std::forwardT(arg)的规则是如果T被推导为左值引用std::forwardT返回左值引用如果T推导为非引用类型即实参是右值std::forwardT返回右值引用。它就是把转发引用在入口处接收到的左右值身份原样传递下去。std::forward的源码实现看起来是层层条件编译但说白了它就是按模板参数做了一次有条件的std::move。在泛型代码里它比到处写std::move更安全准确因为它保住了调用方的语义。6. 实战中我踩过的坑和实用检查清单6.1 自移动模板代码里出现的幽灵项目里写过一个通用swap工具模板展开后出现了a std::move(a)这种怪调用。移动赋值如果没有this ! rhs检查就会先delete[] data_再执行data_ rhs.data_——此时data_已经是悬垂指针后面再接管就全乱了。从那以后凡是自定义了移动赋值的类我都会保留自移动检查。虽然标准库对自移动有宽松保证但自己的类老老实实防御总没错。尤其是模板泛型代码参数类型不可控宁可在每个移动赋值里多写一行。6.2 vector扩容时的移动优化需要三个条件同时满足我最初以为定义移动构造后std::vector就会自动用移动结果测试发现push_back还是走了深拷贝。排查之后确认了三个条件缺一不可移动构造存在且被正确定义移动构造标了noexcept被移动的对象确实是右值或通过std::move转换。第二点最容易漏。标准库的std::vector扩容策略是move_if_noexcept——只有noexcept才敢放心移动。我的MyString一开始没标noexcept扩容时全走拷贝路径白白多出大量分配和复制。补上noexcept后重新压测扩容耗时降了一个数量级。6.3 移动后的对象状态约定适度别依赖细节标准对被移动后对象只保证一件事有效但未指定。这意味着你可以在它上面做赋值、重新构造、调用不依赖具体值的操作但不能假设它一定是空、一定能打印出某个前缀、一定有特定容量。我在团队里的约定是std::move之后的源对象只允许被重新赋值或析构。若业务代码需要访问它那就明确要求重新构造不要依赖标准之外的实现细节。6.4 实战检查清单写资源管理类时的五个自检项每个自定义移动语义的类提交前按这套检查过一遍[ ] 移动构造和移动赋值是否都标了noexcept[ ] 移动赋值里是否有自移动检查[ ] 移动后的源对象是否被置为空状态保证析构安全[ ] 拷贝构造/拷贝赋值/析构/移动构造/移动赋值五法则是否完整明确[ ] 调用std::move的每个点接收方是否真的触发了移动路径写在最后。关于右值引用和深拷贝优化我个人最深的体会是右值引用不是让你多记一个语法符号而是改变了我们思考资源归属的方式。过去写类的时候第一反应是拷贝要安全现在我会再多问一句这里能不能转移一旦代码库里的资源管理类全部按移动语义整理过编译器会在类型系统层面把资源转移路径守住遇到该省掉的深拷贝时它不会再让你做额外功。把移动构造函数和移动赋值写好、标对noexcept再把std::move用在真正需要转移的地方这套工具就能在日常项目中反复帮你省下真实的时间和内存。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进