ARTICLE DETAIL

资讯详情

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

C++ 移动语义:std::move 其实什么都没搬

C++ 移动语义:std::move 其实什么都没搬 std::move不移动任何东西它只做一次类型转换把左值转成右值引用。真正搬数据的是移动构造函数。搞懂「值类别 → 重载决议 → 移动构造」这条链移动语义就再也不会用错。1. 引子先看一段让很多人第一次怀疑人生的代码#include iostream #include string #include utility int main() { std::string a hello world, this is a long string; std::string b a; // 拷贝 std::string c std::move(a); // 移动 std::cout b b \n; std::cout c c \n; std::cout a.size() a.size() \n; std::cout b.data() c.data() ? (b.data() c.data() ? true : false) \n; }b hello world, this is a long string c hello world, this is a long string a.size() 0 b.data() c.data() ? false三个问题std::move(a)之后a为什么还活着还能size()、还能析构。既然叫 movea里的数据到底去哪了b和c内容一样凭什么说一个是拷贝、一个是移动答案一句话std::move是个类型转换不是搬运工。它把a转成std::string 然后编译器按这个类型重新选了一次重载选中了移动构造函数 —— 搬数据的活是这个函数干的。2. 先分清左值和右值C 里每个表达式都有两个属性类型type和值类别value category。 移动语义完全建立在值类别之上所以这一节不能跳。值类别通俗理解例子左值lvalue有名字、有地址、会活下去x、arr[0]、*p纯右值prvalue临时的、用完就没了42、x y、f()返回的非引用将亡值xvalue快死的对象资源可以抢std::move(x)、static_castT(x)引用分三种绑定规则不一样#include iostream int main() { int x 42; int lr x; // 左值引用绑左值 int rr 42; // 右值引用绑右值临时值 const int cr 42; // const 左值引用也能绑右值而且会延长临时值寿命 // int bad 42; // 编译错误非常量左值引用不能绑到右值上 std::cout lr rr cr \n; }42 42 42官方文档Value categories — cppreference关键记忆点右值引用本身是个左值。#include cstdio #include utility void probe(int) { std::puts(绑到 左值引用); } void probe(int) { std::puts(绑到 右值引用); } void demo(int rr) { std::puts(--- 在 demo 内部传 rr ---); probe(rr); // rr 有名字 - 是左值 - 调 int 版本 probe(std::move(rr)); // 显式转回右值 - 调 int 版本 } int main() { probe(1); // 字面量是右值 int x 1; probe(x); // 有名字的变量是左值 demo(2); }绑到 右值引用 绑到 左值引用 --- 在 demo 内部传 rr --- 绑到 左值引用 绑到 右值引用这就是完美转发perfect forwarding必须写std::forwardT(x)的原因 形参一旦有了名字它就退化成左值了。官方文档std::forward — cppreference3. std::move 的真相标准库里的std::move实现基本就长这样template class T constexpr std::remove_reference_tT move(T t) noexcept { return static_caststd::remove_reference_tT(t); }没有内存分配、没有数据搬运只有一次static_cast。a (std::string, 左值) │ │ std::move(a) ← 只改类型不改值 ▼ std::string 右值引用仍指向同一个 a │ │ 重载决议重新选一次 ▼ 移动构造函数 std::string(std::string) │ ▼ 真正把 a 内部的指针「偷」走a 变成空壳用std::is_same_v直接验证「它只是个类型转换」#include cstdio #include string #include type_traits #include utility int main() { std::string s abc; std::printf(move(s) 的类型是 string ? %s\n, std::is_same_vdecltype(std::move(s)), std::string ? 是 : 不是); std::printf(调用 move 前后s 还是 \%s\\n, s.c_str()); }move(s) 的类型是 string ? 是 调用 move 前后s 还是 abc调用std::move(s)之后s的值没变。数据被搬走是因为接着发生了移动构造/移动赋值。官方文档std::move — cppreference4. 什么时候真的会调用移动移动构造不是随便就会触发的。看一段能自己数数的代码#include cstdio #include utility struct Counted { static int copies; static int moves; Counted() default; Counted(const Counted) { copies; std::puts( 拷贝构造); } Counted(Counted) noexcept { moves; std::puts( 移动构造); } Counted operator(const Counted) { copies; return *this; } Counted operator(Counted) noexcept { moves; return *this; } }; int Counted::copies 0; int Counted::moves 0; Counted make() { return Counted{}; } // C17 起直接在调用点构造零拷贝零移动 int main() { std::puts(a); Counted a; std::puts(b a 左值 - 拷贝); Counted b a; std::puts(c std::move(a) 右值 - 移动); Counted c std::move(a); std::puts(d make() C17 保证省略); Counted d make(); std::printf(合计: copies%d moves%d\n, Counted::copies, Counted::moves); (void)b; (void)c; (void)d; }a b a 左值 - 拷贝 拷贝构造 c std::move(a) 右值 - 移动 移动构造 d make() C17 保证省略 合计: copies1 moves1两点值得记make()返回临时对象在 C17 之前要一次移动C17 起是保证的拷贝省略guaranteed copy elision一次构造函数都不调。所以「函数返回局部变量」时别画蛇添足写return std::move(x);—— 会阻止 NRVO反而变慢。官方文档Copy elision — cppreference再看向量扩容。这是移动语义在实战里最值钱的场景#include cstdio #include utility #include vector struct T { T() default; T(const T) { std::puts( 拷贝构造); } T(T) noexcept { std::puts( 移动构造); } }; int main() { std::vectorT v; v.reserve(2); std::puts(push_back #1); v.push_back(T{}); std::puts(push_back #2); v.push_back(T{}); std::puts(push_back #3 —— 超过容量触发扩容); v.push_back(T{}); }push_back #1 移动构造 push_back #2 移动构造 push_back #3 —— 超过容量触发扩容 移动构造 移动构造 移动构造第 3 次时容量从 2 涨到 4旧的两个元素被移动到新内存新的临时对象也被移动进去一共 3 次移动。 全程没有一次深拷贝。如果没有移动构造这里就是 2 次完整拷贝。5. 移动构造该怎么写移动构造的四条规矩形参是T非 constnoexcept—— 不是可选项见下面第 5 节的坑把源对象的资源接过来然后把源对象置空自赋值检查移动赋值才需要下面是一个最小的String能看清「偷指针」和「真分配」的区别// movable_string.cpp — 编译: g -stdc17 -Wall -O2 movable_string.cpp -o ms #include cstdio #include cstring #include utility class String { public: String() default; explicit String(const char* s) { len_ std::strlen(s); data_ new char[len_ 1]; std::memcpy(data_, s, len_ 1); std::printf( [普通构造] 分配并复制 %zu 字节\n, len_ 1); } // 拷贝构造真分配、真复制O(n) String(const String other) { len_ other.len_; data_ new char[len_ 1]; std::memcpy(data_, other.data_, len_ 1); std::printf( [拷贝构造] 分配并复制 %zu 字节\n, len_ 1); } // 移动构造只偷指针O(1) String(String other) noexcept : data_(other.data_), len_(other.len_) { other.data_ nullptr; other.len_ 0; std::printf( [移动构造] 只偷指针一次内存都没分配\n); } // 移动赋值先释放自己的资源再接管对方的 String operator(String other) noexcept { if (this ! other) { delete[] data_; data_ other.data_; len_ other.len_; other.data_ nullptr; other.len_ 0; } std::printf( [移动赋值]\n); return *this; } ~String() { delete[] data_; } const char* c_str() const { return data_ ? data_ : (空); } std::size_t size() const { return len_; } private: char* data_ nullptr; std::size_t len_ 0; }; int main() { std::puts(1) 构造); String a(hello move semantics); std::puts(2) 拷贝构造); String b a; std::puts(3) 移动构造); String c std::move(a); std::printf(a %s (size%zu)\n, a.c_str(), a.size()); std::printf(b %s (size%zu)\n, b.c_str(), b.size()); std::printf(c %s (size%zu)\n, c.c_str(), c.size()); }1) 构造 [普通构造] 分配并复制 21 字节 2) 拷贝构造 [拷贝构造] 分配并复制 21 字节 3) 移动构造 [移动构造] 只偷指针一次内存都没分配 a (空) (size0) b hello move semantics (size20) c hello move semantics (size20)两种构造的开销差异画出来拷贝构造 移动构造 a: [ptr]──► hello move semantics a: [ptr]──┐ len20 │ new char[21] memcpy(dst, a.ptr, 21) ▼ 指针直接交出去 c: [ptr]──► hello move semantics b: [ptr]──► hello move semantics len20 len20 a: [ptr]nullptr, len0 ← 置空 ▲ 两份独立内存O(n) ▲ 一份内存换了主人O(1)官方文档Move constructors — cppreference 官方文档C.64: A move operation should move and leave its source in a valid state — C Core Guidelines6. 四个必踩的坑坑 1对const对象用std::move静默退化成拷贝#include cstdio #include utility struct S { S() default; S(const S) { std::puts(拷贝构造); } S(S) noexcept { std::puts(移动构造); } }; int main() { S a; const S ca a; std::puts(--- std::move(a) ---); S b std::move(a); std::puts(--- std::move(ca) ---); S c std::move(ca); (void)b; (void)c; }--- std::move(a) --- 移动构造 --- std::move(ca) --- 拷贝构造std::move(ca)得到的是const S而移动构造要的是S绑不上只好回退到拷贝构造。 编译器一声不吭性能就这么没了。const成员、const返回值的容器都是重灾区。坑 2移动构造没标noexceptstd::vector扩容时要把旧元素搬到新内存。如果移动构造可能抛异常搬一半失败就没法回滚vector为了保住强异常保证会放弃移动、改用拷贝。vector 扩容时的选择 移动构造是 noexcept ? ├── 是 ──► 移动元素O(1) 搬家快 └── 否 ──► 拷贝元素O(n) 深拷贝慢← 你以为在移动其实在拷贝所以noexcept不是装饰是性能开关。坑 3移动之后继续用被移动的对象移动后对象处于「有效但未指定valid but unspecified」状态。 标准只保证你能安全地析构它、能给它重新赋值不保证它的值是什么。std::string在 libstdc 上会变成空串但换个实现就未必。不要写std::printf(%s, a.c_str())这种依赖移动后内容的代码。官方文档Moved-from state of library types — cppreference坑 4return std::move(local)std::string f() { std::string s abc; // return std::move(s); // 错阻止 NRVO多一次移动 return s; // 对编译器直接原地构造 }只有当返回的是函数形参或成员变量不属于 NRVO 范围时std::move才有意义。7. 一张速查表场景会调用什么备注T b a;拷贝构造a是左值T b std::move(a);移动构造a被搬空T b f();通常什么都不调C17 保证拷贝省略return local;NRVO别加std::movev.push_back(std::move(x));移动构造比push_back(x)省一次拷贝std::move(constObj)拷贝构造静默退化最坑v.push_back(T{})触发扩容移动构造需noexcept否则退化为拷贝8. 延伸阅读Value categories —— 值类别的权威定义这篇第 2 节的理论基础std::move —— 注意它的实现只有一行static_castMove constructors —— 什么时候会被隐式声明、什么时候被抑制Copy elision —— C17 保证省略解释「为什么返回局部变量不用 move」C Core Guidelines · Rc-move-semantic —— 移动语义的使用规范Compiler Explorer —— 把上面任意一段贴进去能直接看到生成的汇编本知识库内的相关篇目《移动构造与移动赋值把资源偷过来》 —— 聚焦「怎么正确实现移动操作」而非 std::move 本身。《RVO 与 NRVO为什么 return std::move(x) 是反优化》 —— 复制省略是标准允许改变程序可观察行为的少数情况之一。《手写一个安全的 String 类把深浅拷贝讲透》 —— 从一个会 double free 的浅拷贝反例入手9. 一句话总结std::move是类型转换移动构造才是搬运工noexcept是让搬运工上岗的许可证。
返回列表