前言移动语义和智能指针是 C11 之后最重要的两项资源管理改进它们经常被放在一起讲因为解决的问题是同一个避免不必要的拷贝并让所有权表达得更清楚。移动语义让对象可以转移内部资源而不是复制一份智能指针让谁负责释放从注释变成类型系统的一部分。需要先纠正三个高频误解。第一std::move不移动任何东西它只是一个类型转换把表达式变成右值引用真正发生移动的是随后调用的移动构造函数或移动赋值运算符。第二std::unique_ptr不能拷贝只能移动这是它表达独占所有权的方式不是缺陷。第三std::shared_ptr的控制块是线程安全的但它指向的对象不是多线程读写同一对象仍然需要同步。本文按值类别与移动、移动语义的边界、unique_ptr、shared_ptr 与 weak_ptr的顺序展开代码以 C17 为基准。一、值类别与右值引用C 的表达式按值类别value category分为三类类别含义例子lvalue左值有身份、可取地址通常不能隐式移动变量名、*p、a[i]prvalue纯右值没有身份就是计算出来的值42、a b、返回值的函数调用xvalue将亡值有身份但资源可以被转移std::move(x)、返回右值引用的函数调用左值和将亡值合称泛左值glvalue纯右值和将亡值合称右值rvalue。右值引用T可以绑定到右值从而让编译器选中移动构造函数。// 适用于 C17#include string#include utilityvoid take(std::string s) { (void)s; } // 只接受右值void take(const std::string s) { (void)s; } // 接受左值和右值int main() {std::string a hello;take(a); // 调用 const 版本take(std::move(a)); // 调用 版本a 之后处于有效但未指定状态take(world); // 临时对象调用 版本}std::move(a)的等价写法是static_caststd::string(a)。它不读取、不修改a只是在编译期改变表达式的值类别。至于移动是否真的发生取决于目标类型有没有可用的移动构造函数。移动构造与移动赋值一个典型的移动构造函数窃取源对象的资源再把源对象置为可安全析构的状态// 适用于 C17#include algorithm#include cstddef#include utilityclass Buffer {public:explicit Buffer(std::size_t n): data_(new int[n]()), size_(n) {}~Buffer() { delete[] data_; }Buffer(const Buffer other): data_(new int[other.size_]), size_(other.size_) {std::copy(other.data_, other.data_ size_, data_);}Buffer operator(const Buffer other) {if (this ! other) {Buffer tmp(other);swap(tmp);}return *this;}Buffer(Buffer other) noexcept: data_(other.data_), size_(other.size_) {other.data_ nullptr; // 源对象不再拥有资源other.size_ 0;}Buffer operator(Buffer other) noexcept {if (this ! other) {delete[] data_; // 释放自己原有的资源data_ other.data_;size_ other.size_;other.data_ nullptr;other.size_ 0;}return *this;}void swap(Buffer other) noexcept {std::swap(data_, other.data_);std::swap(size_, other.size_);}std::size_t size() const { return size_; }private:int* data_ nullptr;std::size_t size_ 0;};移动构造只复制两个字段没有分配和逐元素拷贝因此对持有大块内存的类型代价从 O(n) 降到 O(1)。这不是编译器优化而是语义上的资源转移。noexcept在这里不是装饰。std::vector扩容时要在移动已有元素和拷贝已有元素之间选择如果移动构造函数没有声明noexcept而拷贝构造函数可用标准库通常会选择拷贝以保证扩容失败时原容器不被破坏强异常保证。给移动操作标noexcept容器才会放心地移动。被移动对象的状态标准规定被移动之后的对象处于有效但未指定valid but unspecified状态。它仍然可以被销毁、被赋值但它的值不可依赖。上面的Buffer把指针置空、大小置零这是良好的实现习惯让对象处于一个明确可用的空状态而不是留下悬垂指针。不要写这样的代码// 适用于 C17std::string a hello;std::string b std::move(a);// 错误a 的值未指定不要假设它一定是空串// std::cout a.size();标准库的std::string通常会把被移动对象置空但这是实现行为不是可移植的保证。二、移动语义的边界const 对象无法被移动对const对象使用std::move得到的是const T它无法绑定到T类型的移动构造函数只能匹配拷贝构造函数。因此移动退化为拷贝且不会有任何警告。// 适用于 C17const std::string a hello;std::string b std::move(a); // 实际调用拷贝构造如果希望函数真正接受并移动参数参数不应是const T按值传递再std::move进成员是常见的做法。不要对局部返回值写 std::move// 适用于 C17Buffer make_buffer(std::size_t n) {Buffer b(n);return b; // NRVO 或隐式移动}Buffer make_buffer_bad(std::size_t n) {Buffer b(n);return std::move(b); // 阻止 NRVO强制一次移动}C17 起返回纯右值时强制省略拷贝guaranteed copy elision返回具名局部变量时编译器通常会做具名返回值优化NRVO直接在调用方的存储中构造对象连移动都不需要。写std::move(b)反而把表达式变成将亡值抑制了 NRVO只剩一次移动。两者结果相同但后者更慢。移动不等于免费移动一个std::array会逐元素移动移动一个std::string在小字符串优化SSO生效时也可能复制缓冲区因为短字符串直接存在对象内部没有可窃取的堆内存。这些细节由实现决定标准只规定语义。不要假设移动一定是常数时间对具体类型应以标准库实现和测量为准。三、unique_ptr独占所有权std::unique_ptrT表达唯一所有权同一时刻只有一个unique_ptr拥有对象它不可拷贝、可移动。对象在unique_ptr销毁时被删除删除器可以自定义。// 适用于 C17#include cstdio#include memory#include utilitystruct Widget {int id 0;};void use(Widget w) { (void)w; }int main() {auto w std::make_uniqueWidget();w-id 7;std::unique_ptrWidget owner std::move(w);// 此时 w 为空owner 拥有对象if (w) {use(*w); // 不会执行}use(*owner);// 自定义删除器管理 FILEauto closer [](std::FILE* f) {if (f) {std::fclose(f);}};std::unique_ptrstd::FILE, decltype(closer) file(std::fopen(data.txt, r), closer);if (!file) {return 1;}// file 析构时调用 closer自动关闭文件return 0;}要点用std::make_uniqueT(args...)创建C14 起避免裸new异常安全更好转移所有权必须std::move拷贝构造和拷贝赋值被删除get()返回裸指针但不转移所有权release()放弃所有权并返回裸指针需要自己删除reset()销毁当前对象并接管新指针默认删除器用delete所以unique_ptrT不能管理new[]分配的数组数组要用std::unique_ptrT[]它会调用delete[]自定义删除器会让unique_ptr变大需要存储删除器默认删除器通常与裸指针同宽但标准不保证具体大小。unique_ptr可以作为函数参数表达接管所有权作为返回值表达转移所有权比文档里的注释可靠得多。四、shared_ptr 与 weak_ptr引用计数与控制块std::shared_ptrT通过控制块control block共享所有权。控制块里保存强引用计数、弱引用计数、删除器和分配器。每次拷贝shared_ptr增加强计数销毁减少强计数归零时对象被删除弱计数也归零时控制块本身被释放。// 适用于 C17#include memory#include string#include vectorstruct Room;struct Session {std::string user;std::weak_ptrRoom room; // 弱引用不拥有 Room};struct Room {std::string name;std::vectorstd::shared_ptrSession sessions;};void join(const std::shared_ptrRoom room,std::shared_ptrSession session) {session-room room; // weak_ptr 由 shared_ptr 赋值room-sessions.push_back(std::move(session));}std::shared_ptrRoom room_of(const Session s) {return s.room.lock(); // 提升为 shared_ptr失败返回空}int main() {auto room std::make_sharedRoom();room-name general;auto s std::make_sharedSession();s-user alice;join(room, s);if (auto r room_of(*s)) {// r 与 room 指向同一对象且在此期间保证对象存活}return 0;}weak_ptr不增加强引用计数因此不会阻止对象销毁。它没有operator*和operator-必须调用lock()得到一个shared_ptr才能访问对象。lock()成功说明对象仍然存活失败返回空的shared_ptr。这个检查并提升是原子的不会出现检查通过后对象又被销毁的窗口。循环引用如果两个对象互相持有shared_ptr强计数永远不会归零对象永远不会销毁// 适用于 C17#include memorystruct Node {std::shared_ptrNode next;// std::shared_ptrNode prev; // 错误与 next 形成环std::weak_ptrNode prev; // 正确一条边用弱引用};这是shared_ptr最典型的泄漏来源。设计双向链表、父子关系、观察者列表时必须明确哪条边是拥有、哪条边是观察。通常让父持有子的shared_ptr子持有父的weak_ptr。不要用同一裸指针构造两个 shared_ptr// 适用于 C17Widget* raw new Widget();std::shared_ptrWidget a(raw);std::shared_ptrWidget b(raw); // 错误两个独立控制块// a 和 b 各自认为自己是唯一所有者最终 double free正确做法是用std::make_sharedWidget()或者先构造一个shared_ptr其余全部拷贝它。std::make_shared还会把对象和控制块合并为一次分配减少分配次数代价是只要有weak_ptr存在整块内存包括对象存储就要等到弱计数归零才能释放。shared_ptr的引用计数操作是原子的多个线程可以安全地拷贝、销毁不同的shared_ptr实例但通过它们访问同一个对象不是线程安全的。两个线程同时写*sp仍然是数据竞争需要互斥量或原子操作保护对象本身。常见坑点❌ 认为std::move(x)会移动x✅ 它只是把表达式转换为右值引用真正移动发生在移动构造/赋值被调用时。❌ 对const对象使用std::move期待移动✅ 得到const T只能匹配拷贝构造移动退化为拷贝。❌ 移动之后继续读取源对象的值✅ 被移动对象处于有效但未指定状态只能销毁或重新赋值。❌return std::move(local);以为能加速返回✅ 它阻止 NRVO通常更慢直接return local;即可。❌ 把unique_ptr赋给另一个unique_ptr而不std::move✅ 拷贝被删除必须显式转移所有权。❌ 用std::shared_ptrT(new T)和另一个shared_ptr共享同一裸指针✅ 会产生两个控制块并 double free统一用make_shared或拷贝已有shared_ptr。❌ 用shared_ptr管理互相引用的对象✅ 强计数永不归零导致泄漏至少一条边改用weak_ptr。❌ 认为shared_ptr线程安全就等于对象访问线程安全✅ 只有控制块的计数是原子的对象本身仍需同步。总结主题关键结论std::move只是类型转换不执行移动移动构造窃取资源源对象置为有效但未指定状态noexcept影响容器扩容时选择移动还是拷贝返回值直接返回局部变量不要包std::moveunique_ptr独占、不可拷贝、可移动、零额外管理成本shared_ptr共享所有权控制块计数原子对象访问不原子weak_ptr观察而不拥有用lock()安全提升循环引用用weak_ptr打破否则内存泄漏移动语义的核心是所有权转移智能指针的核心是所有权表达。把两者放在一起理解就会发现它们服务于同一个目标让资源的生命周期由类型和值语义决定而不是由散落各处的new/delete决定。真正的难点不在语法而在于每次设计接口时回答清楚一个问题这个对象归谁所有转移时是复制还是交出。
阅读完成 · 觉得有帮助?