尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++11移动语义与完美转发:从右值引用到性能优化实战

C++11移动语义与完美转发:从右值引用到性能优化实战 1. 为什么移动语义成了C11最值得学的特性很多朋友学C11一开始注意力会被lambda、智能指针这些带感的特性吸引但我自己用下来的体会是看似不起眼的移动语义和完美转发才是真正每天都在影响代码性能和设计方式的东西。lambda顶多让你少写几十行函数对象而移动语义这玩意能把某些场景的性能直接拉高一个数量级。先看一段最普通的代码std::vectorstd::string v1; v1.push_back(hello); v1.push_back(world); std::vectorstd::string v2 v1; // 拷贝每个字符串都复制一遍 std::vectorstd::string v3 std::move(v1); // 移动内部指针直接交接在C11之前v2 v1这种事没得选老老实实把元素逐个复制。但很多时候我们拷贝完根本不会再碰原对象比如函数返回一个局部vector、临时对象插入容器这种场景下拷贝纯属浪费。移动语义干的事就是把这些马上要销毁的资源直接搬走省去复制开销。适合移动的资源本质上是那些里面有指针/句柄/文件描述符的对象。复制一个std::vectorstd::string要逐层深拷贝而移动只需要把内部那三个指针begin、end、capacity从源对象拷到目标对象再把源对象指针置空。对于100万个字符串的vector拷贝和移动的时间差大概是几十毫秒和几纳秒的差距。这个特性不光是性能问题它还解决了一个C历史上的尴尬想返回一个体积大的对象要么靠编译器的RVO返回值优化赌一把要么就得接受一次拷贝。有了移动语义即使编译器不做返回值优化性能也不会崩。后面我会详细拆这个。2. 左值、右值与右值引用先把这个概念高清楚学习移动语义之前建议先把值类别value category搞明白。这不是学院派抠概念因为移动构造函数的触发条件、std::move的行为、甚至你写的代码能不能编译通过全都取决于编译器怎么看待一个表达式。C11把表达式分成三类核心值类别类别典型例子能不能取地址能不能被移动左值lvalue具名变量、数组元素、*p能不能直接移动需要std::move纯右值prvalue字面量、临时对象、函数返回的非引用值不能能天然匹配移动失效值xvaluestd::move(obj)的结果能能明确表示可以拆了你三者合起来glvalue包含左值和失效值右值包含纯右值和失效值。这里最容易被忽视的是右值引用只能绑定右值左值引用只能绑定左值const T例外它可以绑定右值但那是为了兼容老代码。用两个例子加深记忆int a 42; // a是左值因为它在内存里有固定位置 int r a; // 编译错误无法将左值绑定到右值引用 int r2 42; // 正确42是纯右值绑定到r2没问题为什么设计成这样因为右值引用代表这个对象即将销毁你可以拿走它的资源。一个具名的左值理论上你还可能继续用它所以编译器禁止你直接把它交给移动操作。你非要移动也行用std::move明确表态我保证不再用这个变量了。有个细节值得说常引用const T能绑定右值但无法触发移动语义因为移动需要修改源对象把指针置空而const禁止修改。所以那种老式的传const引用避免拷贝写法在C11里依然有用但它和移动是两个维度的事。前者解决的是别复制进来后者解决的是把资源搬走。3. 移动构造函数和移动赋值手写并验证一下理论说完了写一个实际类来验证。这里用一个简化版的StringBuf内部持有一块动态分配的内存用来演示移动和拷贝的区别class StringBuf { public: // 构造函数分配内存 explicit StringBuf(size_t len) : size_(len), data_(new char[len]) { std::cout 构造分配了 len 字节\n; } // 拷贝构造深拷贝 StringBuf(const StringBuf other) : size_(other.size_), data_(new char[other.size_]) { std::copy(other.data_, other.data_ size_, data_); std::cout 拷贝构造重新分配了内存\n; } // 移动构造精髓所在 StringBuf(StringBuf other) noexcept : size_(other.size_), data_(other.data_) { other.size_ 0; other.data_ nullptr; std::cout 移动构造直接搬走了资源\n; } // 拷贝赋值 StringBuf operator(const StringBuf other) { if (this other) return *this; delete[] data_; size_ other.size_; data_ new char[size_]; std::copy(other.data_, other.data_ size_, data_); std::cout 拷贝赋值重新分配了内存\n; return *this; } // 移动赋值 StringBuf operator(StringBuf other) noexcept { if (this other) return *this; delete[] data_; size_ other.size_; data_ other.data_; other.size_ 0; other.data_ nullptr; std::cout 移动赋值直接搬走了资源\n; return *this; } ~StringBuf() { delete[] data_; } private: size_t size_; char* data_; };几个关键细节拆开讲第一为什么移动构造函数要标记noexcept。标准库容器vector、deque等扩容时有一个重要的异常安全承诺如果元素拷贝构造会抛异常容器必须保证扩容失败时原内容不变。但如果移动构造函数不声明noexcept标准库无法保证移动过程中不会抛异常——一旦真的抛了原来的元素已经被搬走了内存处于半损坏状态那整个容器就没法用了。所以std::vector扩容时会用std::move_if_noexcept来做选择能保证不抛异常就移动否则就保守地拷贝。注意你自定的移动构造如果可能抛异常就不要冒然用移动老老实实用拷贝否则会把容器搞挂。第二移动后源对象必须处于可析构状态。我在移动构造里把other.data_置空、other.size_置零目的就是让源对象析构时不崩溃。这是一条隐性契约源对象可以被销毁也可以被赋值但你不能假定它保留原值。所以移动一个对象之后千万别再读它的内容除非你的移动操作本身就保证了源对象仍有有效数据比如某些场景下移动后置默认值。写个测试验证移动的触发时机StringBuf makeStringBuf(size_t len) { StringBuf tmp(len); return tmp; // 临时对象编译器优先尝试移动或NRVO } int main() { StringBuf a(100); StringBuf b std::move(a); // 显式调用移动构造 StringBuf c b; // 左值拷贝构造 std::vectorStringBuf vec; vec.push_back(StringBuf(200)); // 临时对象移动构造 vec.push_back(StringBuf(300)); // 扩容时在堆上移动旧元素 return 0; }再看一个vector扩容时移动和拷贝的鲜明对比。第一次push_back插入一个元素第二次push_back触发扩容旧元素要么被拷贝到新内存要么被移动过去。如果你在移动构造里做计数能看到输出顺序是移动构造而不是拷贝构造。这就是noexcept带来的区别——不信你把移动构造的noexcept去掉再跑一次容器会走拷贝路径。4. std::move是怎么回事一个最容易被误解的工具很多人以为std::move做了什么底层操作把对象移动了。其实它干的活就一件把左值转换成右值引用。它的实现本质上就是一次static_cast。// 标准库实现的大致样子C11 templatetypename T constexpr typename std::remove_referenceT::type move(T t) noexcept { return static_casttypename std::remove_referenceT::type(t); }关键点在于std::move本身不产生任何运行时指令不搬数据不修改任何东西。它只是告诉编译器嘿这个左值我授权你把它当右值处理后续真正的资源转移发生在移动构造函数或移动赋值函数里。所以这个函数命名非常有迷惑性它真正的含义是move-eligible cast可移动转换。我见过不少新手的误解以为std::move会释放什么资源、以为移动完源对象自动清理。不是的——移动完的源对象状态由移动构造函数决定通常是被置空但也可以保留旧值如果你想的话。那什么时候用std::move最常见的两类场景场景一把左值移入容器或智能指针。std::string s 需要移动的字符串; std::vectorstd::string v; v.push_back(std::move(s)); // 明确表示我不再使用s了 // 此后s处于有效但未指定状态通常为空场景二类内转移成员资源。class Wrapper { public: Wrapper(Wrapper other) noexcept : payload_(std::move(other.payload_)) {} // 把成员转移过来 private: std::string payload_; };这里有个细节other.payload_是左值它有名字所以要靠std::move把它变成右值引用才能触发std::string的移动构造函数。如果你直接写payload_(other.payload_)编译器会走拷贝构造——这就和你的意图背道而驰了。这也是为什么很多人说移动构造函数里最容易忘加std::move。这里顺带提一下std::move和内存序的关系。最近有人问我C11内存序是不是专门为原子操作准备的这确实是个好问题。简单来说C11的内存序memory_order确实主要配合原子操作使用用于控制多线程之间的可见性和重排序约束。移动语义和内存序看似不相关但有一个交汇点当你把一个对象从一个线程移动到另一个线程比如通过std::future返回一个大对象如果多线程同时访问移动后的对象还是需要同步机制。移动操作本身不提供任何线程安全保证它只是资源所有权的转移。默认的seq_cst内存序能确保移动操作和后续读写之间的顺序关系但这属于另外一个层面的问题暂时不展开。5. 引用折叠与完美转发C11模板设计的灵魂说完移动必须聊完美转发。因为在这套体系里移动语义解决了怎么把资源高效转移而完美转发解决了怎么把参数原样传递给下一个函数——这两个东西都依赖右值引用但思路完全不同。想象这个场景你写了一个通用工厂函数把参数转发给构造函数templatetypename T, typename Arg T create(Arg arg) { return T(std::forwardArg(arg)); }为什么参数要写成Arg为什么内部要用std::forward而不是std::move这里面的核心就是引用折叠reference collapsing规则。C11有一条铁律右值引用绑定左值是不被允许的。但模板推导有个例外当模板参数是T转发引用也叫universal reference时推导规则会让它根据实参来适配实参类型推导出的T最终参数类型左值类型为intintint折叠后右值类型为intintintconst左值类型为const intconst intconst int正式规则是using T int; T - int // 左值引用右值引用折叠为左值引用 using T int; T - int // 右值引用右值引用折叠为右值引用 using T int; T - int // 都是左值引用还是左值引用所以T这个形态在模板里既能接左值又能接右值前提是T的推导要发生。一旦T被显式指定比如std::vectorint那它就只能绑定右值了这是很多人容易搞混的点。现在问题来了即使参数类型是右值引用在函数体内部只要这个参数有名字它就是左值。看这个例子templatetypename T void outer(T arg) { inner(arg); // 糟糕arg在这里是左值永远触发拷贝 inner(std::move(arg)); // 强制变成右值但左值实参也被强制转换了 }如果我们只传右值进来问题不大但如果我们传左值进来内部还想保持左值语义就不能用std::move。正确做法是自适应如果外部传了右值内部就按右值转发如果外部传了左值内部就按左值转发。std::forward干的就是这个活。templatetypename T void outer(T arg) { inner(std::forwardT(arg)); // 完美转发 }std::forward的实现原理和std::move类似也是条件转换templatetypename T T forward(typename std::remove_referenceT::type param) { return static_castT(param); }当T推导为intT折叠为int返回左值引用当T推导为intT就是int返回右值引用。就这么简单——根据推导出的模板参数自动选择转发方向。为什么不用按值传参呢你把T改成T arg也会发生拷贝因为按值传参本身就是要拷一份新对象。对于大型对象每次都拷贝性能直接拉胯。转发引用加std::forward才能真正做到零额外拷贝地把参数传给构造函数。再强调一遍区分std::move是无条件转换std::forward是有条件转换。每当你在模板里写std::forwardT时想一下我正在保留实参的原始值类别把它继续向下传递。而std::move通常用于你已经明确知道这个左值不会再用了。6. 完美转发的实际应用场景不用想得太玄乎完美转发听着抽象但它其实是用在非常日常的代码里。最有代表性的典型场景是工厂函数和代理类。6.1 通用工厂函数写一个返回对象的工厂函数支持多参数构造函数且能做到零拷贝templatetypename T, typename... Args std::unique_ptrT make_unique(Args... args) { // 这是C14标准库的实现逻辑 return std::unique_ptrT(new T(std::forwardArgs(args)...)); } // 使用 auto p make_uniquestd::string(hello, 5); // 构造string的前5个字符 auto v make_uniquestd::vectorint(10, 42); // 10个42注意展开写法std::forwardArgs(args)...每个参数都保持它原来的值类别。如果外部传了右值字符串内部构造时就能移动传了左值就老实拷贝。如果这里不处理完美转发最糟糕的写法是这样的templatetypename T, typename Arg T badCreate(Arg arg) { // 按值传参永远有一次拷贝 return T(arg); // 又是一次拷贝这里其实应该用std::forward }你看加一个转发引用和std::forward就省掉了一次不必要的拷贝这对字符串、vector、map这种堆上分配内存的类型意义很大。6.2 装饰器/代理模式再比如一个记录函数耗时的代理templatetypename Func, typename... Args auto logCall(Func f, Args... args) - decltype(std::forwardFunc(f)(std::forwardArgs(args)...)) { auto start std::chrono::steady_clock::now(); auto result std::forwardFunc(f)(std::forwardArgs(args)...); auto end std::chrono::steady_clock::now(); std::cout 耗时: std::chrono::duration_caststd::chrono::milliseconds(end - start).count() ms\n; return result; }这里Func也是转发引用既能传函数指针也能传lambda右值。把参数原封不动地传进去函数对象和参数的值类别都保持不变。没有完美转发你写这种通用封装时会非常憋屈想传右值进去却被按左值处理结果就是多层拷贝嵌套或者干脆编译不了。6.3 构造函数转发容器的实例再写一个常见的实际案例用完美转发构造一个缓存池class ConnectionPool { public: // 支持把任意参数转发给Connection构造函数 templatetypename... Args std::shared_ptrConnection acquire(Args... args) { return std::make_sharedConnection(std::forwardArgs(args)...); } };这个设计的核心价值在于不管Connection的构造函数有多少个参数、参数类型是左值还是右值、是拷贝语义还是移动语义acquire都能原封不动地把这些参数传给构造函数同时不额外产生任何拷贝。这种灵活性在C11之前想都不敢想——要么提供一堆重载函数要么用void*这种不安全的接口而且还不解决临时对象传入的问题。7. 实战中的几个坑截图级别的血泪教训理论讲完聊点实战中容易翻车的细节。坑一移动后继续使用源对象std::string s important data; std::string t std::move(s); std::cout s.size(); // 合法但大概率是0或未定义值不要依赖移动之后的源对象处于有效但未指定状态。有效意味着可以安全析构、可以赋值。未指定意味着别读它的值别假设它为空也别假设它保留旧数据。这是标准库的类型string、vector等普遍遵守的规则。坑二自移动赋值std::vectorint v(100, 1); v std::move(v); // 编译器不报错但行为未定义标准库的容器自移动赋值是未定义行为C11时期C14之后标准库容器自移动是允许的且保持有效状态但你自己的类如果写移动赋值没检查this other就可能导致先delete[]再访问已释放内存。所以上面自定义StringBuf里的那个if (this other) return *this;不是可有可无的防御是必须。坑三移动构造函数没写对结果悄悄用了拷贝class Foo { public: Foo(Foo other) noexcept : ptr_(std::exchange(other.ptr_, nullptr)) {} private: int* ptr_; };如果移动构造不声明noexceptvector扩容时就会优先走拷贝构造函数因为有异常安全要求你的移动优化没生效程序还表现得像老代码一样。怎么验证在移动构造函数里打印日志跑一段触发vector扩容的代码看输出。我见过不少项目明明写了移动构造但忘了标noexcept性能优化静默失败排查半天。坑四类中有裸指针时析构和移动必须配合什么时候编译器会隐式生成移动构造条件是类没有拷贝构造、拷贝赋值、移动赋值、析构函数即四大特殊成员都没有自定义。注意只要你自己定义了析构函数编译器就不会自动生成移动构造。所以如果你写了析构函数释放资源又不提供移动构造那返回对象时只能走拷贝。这个规则很隐蔽很多人定义了析构之后以为移动语义还有效结果代码里跑的全是拷贝。解决方案有两种要么手动补齐移动构造和移动赋值要么用 default显式要求生成。如果你确实需要析构而且类内部没有资源需要深拷贝管理比如是一个RAII包装器可以使用class Foo { public: ~Foo(); Foo(const Foo) delete; Foo(Foo) default; Foo operator(const Foo) delete; Foo operator(Foo) default; };坑五const T是什么碰都别碰void bad(const std::string s); // 几乎没有任何用处const T绑定右值但没有修改权移动构造又是需要修改源的所以这个类型除了阻止某些重载解析基本派不上用场。如果你在代码里见到它大概率是新手写的或者想当然了。坑六局部变量返回时要用std::move吗std::string foo() { std::string result hello std::to_string(42); // 要不要写成 return std::move(result)? return result; // 推荐这个 }现代编译器GCC、Clang、MSVC对局部变量返回有强制的RVO/NRVO优化。即使编译器不应用返回值优化标准库也规定这个局部变量会被隐式移动。如果你写了return std::move(result)反而阻止编译器使用NRVO因为它已经变成了一个另一个对象还可能造成多余的移动。所以这个场景就别画蛇添足了。8. 用一个实验验证移动语义的收益最后给出一个实际测试的代码大家自己跑一跑感受移动语义和完美转发配合起来的效果。我这里模拟一个生成一个大容器并传参的场景#include iostream #include vector #include string #include chrono std::vectorstd::string generateData(size_t n) { std::vectorstd::string result; result.reserve(n); for (size_t i 0; i n; i) { result.emplace_back(100, a (i % 26)); } return result; // 局部返回NRVO或移动不会拷贝 } templatetypename Container void processData(Container c) { // 完美转发c可能是左值引用也可能是右值引用 auto local std::forwardContainer(c); std::cout 处理了 local.size() 个元素\n; } int main() { auto t1 std::chrono::steady_clock::now(); auto data generateData(100000); // 十万个字符串 auto t2 std::chrono::steady_clock::now(); std::cout 生成耗时: std::chrono::duration_caststd::chrono::microseconds(t2 - t1).count() us\n; processData(std::move(data)); // 右值传入完美转发保持移动语义 // 错误示范processData(data); 左值传入内部会拷贝 return 0; }如果你把generateData的返回类型改成一个不接受移动的类对比一下耗时差距非常直观。这就是移动语义加完美转发的完整闭环一个负责在造完不用的场景高效转移资源一个负责在转发参数的场景保持值类别不丢失。两个特性互相配合让C模板库的性能和高灵活性成为可能。我自己在实际项目中用得最多的组合是std::unique_ptr只能移动不能拷贝配合完美转发去实现工厂模式、链式调用和代理层。移动语义解决资源所有权转移完美转发解决参数传递透明性这两板斧下来代码里的多余拷贝基本绝迹。最后分享一个调试技巧在大型项目里排查这里到底发生了几次拷贝时最直接的办法是在类的拷贝构造和移动构造里加日志或断点重跑触发路径观察输出顺序。C的优化经常让人出乎意料NRVO、内联展开都可能影响实际行为日志实测比脑补靠谱得多。
返回列表