ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++move()函数案例详解

C++move()函数案例详解 前言std::move是 C11 里被误解最多的一个函数。它的名字带着动词看起来像把对象搬走于是很多人以为调用它之后原对象就被清空了、内存就被转移了、后续代码会立刻看到变化。这些理解都不对。std::move什么也不做它只是一次强制类型转换。它把一个左值lvalue转换成右值引用rvalue reference仅此而已。真正搬运数据的动作发生在移动构造函数move constructor或移动赋值运算符move assignment operator里而这两个函数是你自己或标准库写的。还有第二个常见误解以为写了std::move就一定走移动。事实上如果类型没有可用的移动构造函数重载解析会退回拷贝构造std::move白写如果对象带conststd::move得到的是const T绑不到移动构造的T参数上同样退化成拷贝。这些情况编译器通常一声不吭。本文从值类别讲起说清std::move的本质然后用可编译的例子逐个演示什么时候真的走移动、noexcept如何影响std::vector的扩容、返回值优化RVO与std::move的冲突最后是九个真会踩的坑。一、std::move 的本质一次强制类型转换std::move声明在utility中形如以 cppreference 与标准头文件为准// C14 起 template class T constexpr std::remove_reference_tT move(T t) noexcept;它在 C11 里没有constexpr写的是typename std::remove_referenceT::type。函数体几乎可以理解为return static_caststd::remove_reference_tT(t);调用std::move(x)后得到的是一个绑定到x的右值引用。没有任何数据被复制、移动或销毁x本身也没有被改动。它唯一的贡献是在重载解析阶段改变了实参的值类别让编译器在拷贝构造和移动构造之间更倾向后者。要理解这件事需要先知道值类别value category类别中文典型来源能否绑定到Tlvalue左值具名变量、*ptr、v[0]否xvalue将亡值std::move(x)、static_castT(x)是prvalue纯右值字面量、返回非引用的函数调用是注意表中的关键一行具名变量的表达式一律是左值哪怕它的类型是右值引用。#include utility int rref 42; // rref 的类型是 int // 但表达式 rref 本身是左值 // 想让 rref 再次以右值身份参与重载解析还得再 move 一次 int y std::move(rref);这条规则是为什么移动构造函数里必须再写一次std::move的答案——成员的名字是左值。二、什么时候真的走移动移动构造与移动赋值的典型签名是T(T other) noexcept; // 移动构造 T operator(T other) noexcept; // 移动赋值重载解析时右值实参优先匹配T参数没有可用的移动版本时才会落到const T。下面这个类会在每次调用时打印方便看清到底走了哪条路#include cstddef #include iostream #include utility class Buffer { public: explicit Buffer(std::size_t n) : size_(n), data_(n ? new int[n] : nullptr) { std::cout 普通构造 size n \n; } Buffer(const Buffer other) : size_(other.size_), data_(other.size_ ? new int[other.size_] : nullptr) { for (std::size_t i 0; i size_; i) data_[i] other.data_[i]; std::cout 拷贝构造 size size_ 深拷贝\n; } Buffer(Buffer other) noexcept : size_(other.size_), data_(other.data_) { other.size_ 0; other.data_ nullptr; // 关键源对象置空 std::cout 移动构造 size size_ 只搬指针\n; } Buffer operator(Buffer other) noexcept { if (this ! other) { // 自赋值保护 delete[] data_; // 先释放自己的资源 size_ other.size_; data_ other.data_; other.size_ 0; other.data_ nullptr; } std::cout 移动赋值\n; return *this; } ~Buffer() { delete[] data_; } std::size_t size() const { return size_; } private: std::size_t size_; int* data_; }; int main() { std::cout 1) 拷贝\n; Buffer a(4); Buffer b(a); // a 是左值 → 拷贝构造 std::cout 2) 移动\n; Buffer c(std::move(a)); // 强制转成右值 → 移动构造 std::cout 3) 移动后的源对象仍然是有效对象\n; std::cout a.size() a.size() \n; // 我们自己的实现置成了 0 std::cout 4) 纯右值直接触发移动不用手写 std::move\n; Buffer d(Buffer(8)); // C17 起这里连移动都会被省略 return 0; }关键在移动构造里那两行other.size_ 0; other.data_ nullptr;。移动构造偷走了源对象的指针如果不把源对象的指针置空两个对象会在析构时各自delete[]同一块内存——double free 是未定义行为标准不保证任何行为实际表现通常是堆损坏后随机崩溃。三、noexcept 如何影响 std::vector 的扩容这是std::move最实用、也最容易被忽视的一条std::vector在扩容时要把旧元素搬到新缓冲区。这个搬运过程如果不能提供强异常安全保证strong exception safety guarantee就宁可拷贝。标准库的做法是使用一个叫std::move_if_noexcept的辅助函数如果元素的移动构造函数不是noexcept且该类型可拷贝构造那么就返回左值即走拷贝否则返回右值走移动。原因很直白扩容时旧缓冲区里的元素已经搬走了一部分如果此时移动构造函数抛异常已经搬走的元素处于源对象已被破坏的状态无法回滚。拷贝则不会破坏源对象抛异常时直接释放新缓冲区、保持原样即可。下面这段代码可以直接跑用输出来验证#include iostream #include utility #include vector struct ThrowingMove { ThrowingMove() default; ThrowingMove(const ThrowingMove) { std::cout 拷贝\n; } ThrowingMove(ThrowingMove) { std::cout 移动未标 noexcept\n; } }; struct NoThrowMove { NoThrowMove() default; NoThrowMove(const NoThrowMove) { std::cout 拷贝\n; } NoThrowMove(NoThrowMove) noexcept { std::cout 移动noexcept\n; } }; int main() { std::cout ThrowingMove 扩容时\n; std::vectorThrowingMove a; a.reserve(1); a.push_back(ThrowingMove{}); // 不扩容 a.push_back(ThrowingMove{}); // 扩容搬移旧元素 std::cout NoThrowMove 扩容时\n; std::vectorNoThrowMove b; b.reserve(1); b.push_back(NoThrowMove{}); b.push_back(NoThrowMove{}); // 扩容搬移旧元素 return 0; }你会看到ThrowingMove的扩容路径走的是拷贝而NoThrowMove走的是移动。两者的差别只在noexcept一个关键字上。请注意std::vector每次扩容的增长倍数growth factor是实现定义的——libstdc 与 MSVC STL 习惯用 2 倍libc 常用约 1.5 倍这些都不是标准规定。上面代码用reserve(1)保证第二次push_back必然触发一次扩容从而稳定复现这个行为而不是依赖具体的倍数。四、返回值不要写 return std::move(x)这是一条反直觉但很明确的建议返回局部变量时直接return x;不要return std::move(x);。原因是 C11 起return一个局部变量时编译器会先按右值做重载解析这被称为隐式移动implicit move所以return x;与return std::move(x);在选择移动构造这一点上是等价的。但std::move(x)把返回值变成了一个表达式结果破坏了编译器做拷贝消除copy elision的条件。具体来说NRVOnamed return value optimization具名返回值优化return x;时编译器可以直接把x构造在调用者的接收位置上一次拷贝/移动都不做。而return std::move(x);得到的右值引用表达式不能被省略因为要绑定引用多半会真的调用一次移动构造。C17 的强制拷贝消除只保证prvalue的情况例如return Buffer(8);直接返回临时对象NRVO 仍然只是被允许的优化不是强制的——不过 GCC、Clang、MSVC 在常规优化级别下都会做。#include iostream #include utility struct Tracer { Tracer() { std::cout 构造\n; } Tracer(const Tracer) { std::cout 拷贝构造\n; } Tracer(Tracer) noexcept { std::cout 移动构造\n; } }; Tracer good() { Tracer local; return local; // ✅ 允许 NRVO多数编译器一次都不构造 } Tracer bad() { Tracer local; return std::move(local); // ❌ 阻止 NRVO至少多一次移动构造 } int main() { std::cout good():\n; Tracer a good(); std::cout bad():\n; Tracer b bad(); }std::move用在参数和赋值上是正确且必要的例如把已有的对象转移进容器#include iostream #include string #include utility #include vector int main() { std::vectorstd::string v; std::string s 一段较长的字符串拷贝一次不便宜; v.push_back(std::move(s)); // ✅ 把 s 的内容转移进 vector // 此后 s 处于「有效但未指定状态」不要假设它为空 std::cout v[0] v[0] \n; // std::cout s; // ⚠️ 合法但打印出什么由实现决定不要依赖 s 重新赋值是安全的; // ✅ 重新赋值总是安全 std::cout s s \n; return 0; }常见坑点1. 以为std::move之后源对象一定被清空。标准只要求移动后的源对象处于有效但未指定状态valid but unspecified state你能做的只有赋值或销毁读取它的值是允许的但结果不可预期。std::string s hello; std::string t std::move(s); // ❌ 不要依赖 s.empty() 为真标准的移动构造只承诺「有效」 if (s.empty()) { /* 可能是真也可能不是取决于实现 */ } // ✅ 只做「赋值」或「销毁」或干脆不再使用 s new value;2. 对返回的局部变量写return std::move(x);。见上一节这会挡住 NRVO反而更慢。3. 对const对象用std::move。得到的是const T而移动构造的参数是Tconst T绑不上去只能退化为拷贝而且编译器通常不报警。const std::string cs const 对象; std::string a std::move(cs); // ❌ 实际调用的是拷贝构造cs 是 const std::string ms 可变对象; std::string b std::move(ms); // ✅ 真正走移动构造4. 类型只有拷贝构造却写了std::move。struct OnlyCopy { OnlyCopy() default; OnlyCopy(const OnlyCopy) { /* 只有拷贝 */ } }; OnlyCopy x; OnlyCopy y(std::move(x)); // ❌ 编译通过但走的是拷贝构造move 白写顺带一个更隐蔽的版本如果你只声明了拷贝构造函数编译器就不会为你生成移动构造函数移动成员被隐式删除此时std::move同样退化为拷贝。5.std::move之后继续使用对象。语法上合法对象仍然有效但值未指定。如果对象是容器size()、empty()的结果都在未指定范围内逻辑上不能依赖。std::vectorint v{1, 2, 3}; std::vectorint w std::move(v); // ❌ v.size() 的结果未指定不要拿它做逻辑判断 // ✅ 若还需要 v就重新赋值 v {4, 5, 6}; // ⚠️ 也别把 std::move 的结果存起来r 只是 v 的右值引用 // 没有移动任何东西且会一直绑定着 v auto r std::move(v);6. 移动构造函数里忘记把源对象置空。这是 double free 的经典来源且 UB。// ❌ 源对象的指针没置空两个对象都会 delete[] Buffer(Buffer other) noexcept : size_(other.size_), data_(other.data_) {} // ✅ 搬走之后立刻断掉源对象的持有关系 Buffer(Buffer other) noexcept : size_(other.size_), data_(other.data_) { other.size_ 0; other.data_ nullptr; }7. 移动赋值运算符没做自赋值保护。// ❌ 若 other 就是 this先 delete[] 自己的 data_ // 再把自己已经释放的指针赋回给自己UB Buffer operator(Buffer other) noexcept { delete[] data_; data_ other.data_; return *this; } // ✅ 先判断 Buffer operator(Buffer other) noexcept { if (this ! other) { /* ... */ } return *this; }8. 在成员初始化列表里写std::move顺序搞错。成员按声明顺序初始化跟初始化列表里写的顺序无关。如果前一个成员要用已被搬走的另一个成员会读到未指定状态。#include string // ❌ 成员按「声明顺序」初始化a_ 先b_ 后。 // 初始化列表把 b_ 写在前面并不能改变顺序 // 这里 b_ 是从一个还没构造的 a_ 移动来的 —— 读未初始化对象是 UB struct Bad { std::string a_; std::string b_; Bad(std::string x) : b_(std::move(a_)), a_(std::move(x)) {} }; // ✅ 保持初始化列表顺序与声明顺序一致 struct Good { std::string a_; std::string b_; Good(std::string x) : a_(std::move(x)), b_(a_) {} // b_ 需要副本时明确拷贝 };总结问题正确认识std::move做了什么只做static_cast到右值引用不改动任何数据谁真正搬运数据移动构造函数 / 移动赋值运算符何时退化为拷贝类型无移动构造、对象是const、移动构造不可用移动后源对象状态有效但未指定只能赋值或销毁与noexcept的关系移动构造非noexcept且类型可拷贝时容器扩容改用拷贝返回值写法return x;不要return std::move(x);适用位置转发已有对象的实参、把对象转移进容器、swap类操作不适用位置返回局部变量、对const对象、std::move的返回值本身一句话结论std::move是给编译器看的意图声明而不是运行时动作。它把我以后不再需要这个对象的内容了编码进类型系统从而让重载解析替你选到移动语义。真正把指针搬走、把源对象置空的永远是你自己写的移动构造函数。想验证本文的行为把上面的示例保存成.cpp直接编译运行即可输出会如实告诉你每一步走了拷贝还是移动。g -stdc17 -Wall -Wextra -O0 move_demo.cpp -o move_demo
返回列表