前言移动语义和智能指针是 C++11 之后最重要的两项资源管理改进,它们经常被放在一起讲,因为解决的问题是同一个:避免不必要的拷贝,并让所有权表达得更清楚。移动语义让对象可以"转移"内部资源而不是复制一份;智能指针让"谁负责释放"从注释变成类型系统的一部分。需要先纠正三个高频误解。第一,std::move不移动任何东西,它只是一个类型转换,把表达式变成右值引用;真正发生移动的是随后调用的移动构造函数或移动赋值运算符。第二,std::unique_ptr不能拷贝,只能移动,这是它表达独占所有权的方式,不是缺陷。第三,std::shared_ptr的控制块是线程安全的,但它指向的对象不是,多线程读写同一对象仍然需要同步。本文按"值类别与移动、移动语义的边界、unique_ptr、shared_ptr 与 weak_ptr"的顺序展开,代码以 C++17 为基准。一、值类别与右值引用C++ 的表达式按值类别(value category)分为三类:类别含义例子lvalue(左值)有身份、可取地址,通常不能隐式移动变量名、*p、a[i]prvalue(纯右值)没有身份,就是计算出来的值42、a + b、返回值的函数调用xvalue(将亡值)有身份,但资源可以被转移std::move(x)、返回右值引用的函数调用左值和将亡值合称泛左值(glvalue),纯右值和将亡值合称右值(rvalue)。右值引用T&&可以绑定到右值,从而让编译器选中移动构造函数。// 适用于 C++17#include <string>#include <utility>void 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_cast<std::string&&>(a)。它不读取、不修改a,只是在编译期改变表达式的值类别。至于移动是否真的发生,取决于目标类型有没有可用的移动构造函数。移动构造与移动赋值一个典型的移动构造函数"窃取"源对象的资源,再把源对象置为可安全析构的状态:// 适用于 C++17#include <algorithm>#include <cstddef>#include <utility>class 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把指针置空、大小置零,这是良好的实现习惯:让对象处于一个明确可用的空状态,而不是留下悬垂指针。不要写这样的代码:// 适用于 C++17std::string a = "hello";std::string b = std::move(a);// 错误:a 的值未指定,不要假设它一定是空串// std::cout << a.size();标准库的std::string通常会把被移动对象置空,但这是实现行为,不是可移植的保证。二、移动语义的边界const 对象无法被移动对const对象使用std::move,得到的是const T&&,它无法绑定到T&&类型的移动构造函数,只能匹配拷贝构造函数。因此移动退化为拷贝,且不会有任何警告。// 适用于 C++17const std::string a = "hello";std::string b = std::move(a); // 实际调用拷贝构造如果希望函数真正接受并移动参数,参数不应是const T&;按值传递再std::move进成员,是常见的做法。不要对局部返回值写 std::move// 适用于 C++17Buffer 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,强制一次移动}C++17 起,返回纯右值时强制省略拷贝(guaranteed copy elision);返回具名局部变量时,编译器通常会做具名返回值优化(NRVO),直接在调用方的存储中构造对象,连移动都不需要。写std::move(b)反而把表达式变成将亡值,抑制了 NRVO,只剩一次移动。两者结果相同,但后者更慢。移动不等于免费移动一个std::array会逐元素移动;移动一个std::string在小字符串优化(SSO)生效时也可能复制缓冲区,因为短字符串直接存在对象内部,没有可窃取的堆内存。这些细节由实现决定,标准只规定语义。不要假设"移动一定是常数时间",对具体类型应以标准库实现和测量为准。三、unique_ptr:独占所有权std::unique_ptr<T>表达唯一所有权:同一时刻只有一个unique_ptr拥有对象,它不可拷贝、可移动。对象在unique_ptr销毁时被删除,删除器可以自定义。// 适用于 C++17#include <cstdio>#include <memory>#include <utility>struct Widget {int id = 0;};void use(Widget& w) { (void)w; }int main() {auto w = std::make_unique<Widget>();w->id = 7;std::unique_ptr<Widget> owner = std::move(w);// 此时 w 为空,owner 拥有对象if (w) {use(*w); // 不会执行}use(*owner);// 自定义删除器管理 FILEauto closer = [](std::FILE* f) {if (f) {std::fclose(f);}};std::unique_ptr<std::FILE, decltype(closer)> file(std::fopen("data.txt", "r"), closer);if (!file) {return 1;}// file 析构时调用 closer,自动关闭文件return 0;}要点:用std::make_unique<T>(args...)创建(C++14 起),避免裸new,异常安全更好;转移所有权必须std::move,拷贝构造和拷贝赋值被删除;get()返回裸指针但不转移所有权,release()放弃所有权并返回裸指针(需要自己删除),reset()销毁当前对象并接管新指针;默认删除器用delete,所以unique_ptr<T>不能管理new[]分配的数组;数组要用std::unique_ptr<T[]>,它会调用delete[];自定义删除器会让unique_ptr变大(需要存储删除器),默认删除器通常与裸指针同宽,但标准不保证具体大小。unique_ptr可以作为函数参数表达"接管所有权",作为返回值表达"转移所有权",比文档里的注释可靠得多。四、shared_ptr 与 weak_ptr引用计数与控制块std::shared_ptr<T>通过控制块(control block)共享所有权。控制块里保存强引用计数、弱引用计数、删除器和分配器。每次拷贝shared_ptr增加强计数,销毁减少;强计数归零时对象被删除,弱计数也归零时控制块本身被释放。// 适用于 C++17#include <memory>#include <string>#include <vector>struct Room;struct Session {std::string user;std::weak_ptr<Room> room; // 弱引用,不拥有 Room};struct Room {std::string name;std::vector<std::shared_ptr<Session>> sessions;};void join(const std::shared_ptr<Room>& room,std::shared_ptr<Session> session) {session->room = room; // weak_ptr 由 shared_ptr 赋值room->sessions.push_back(std::move(session));}std::shared_ptr<Room> room_of(const Session& s) {return s.room.lock(); // 提升为 shared_ptr,失败返回空}int main() {auto room = std::make_shared<Room>();room->name = "general";auto s = std::make_shared<Session>();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,强计数永远不会归零,对象永远不会销毁:// 适用于 C++17#include <memory>struct Node {std::shared_ptr<Node> next;// std::shared_ptr<Node> prev; // 错误:与 next 形成环std::weak_ptr<Node> prev; // 正确:一条边用弱引用};这是shared_ptr最典型的泄漏来源。设计双向链表、父子关系、观察者列表时,必须明确哪条边是"拥有"、哪条边是"观察"。通常让父持有子的shared_ptr,子持有父的weak_ptr。不要用同一裸指针构造两个 shared_ptr// 适用于 C++17Widget* raw = new Widget();std::shared_ptr<Widget> a(raw);std::shared_ptr<Widget> b(raw); // 错误:两个独立控制块// a 和 b 各自认为自己是唯一所有者,最终 double free正确做法是用std::make_shared<Widget>(),或者先构造一个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_ptr<T>(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决定。真正的难点不在语法,而在于每次设计接口时回答清楚一个问题:这个对象归谁所有,转移时是复制还是交出。
前言移动语义和智能指针是 C++11 之后最重要的两项资源管理改进,它们经常被放在一起讲,因为解决的问题是同一个:避免不必要的拷贝,并让所有权表达得更清楚。移动语义让对象可以"转移"内部资源而不是复制一份;智能指针让"谁负责释放"从注释变成类型系统的一部分。需要先纠正三个高频误解。第一,std::move不移动任何东西,它只是一个类型转换,把表达式变成右值引用;真正发生移动的是随后调用的移动构造函数或移动赋值运算符。第二,std::unique_ptr不能拷贝,只能移动,这是它表达独占所有权的方式,不是缺陷。第三,std::shared_ptr的控制块是线程安全的,但它指向的对象不是,多线程读写同一对象仍然需要同步。本文按"值类别与移动、移动语义的边界、unique_ptr、shared_ptr 与 weak_ptr"的顺序展开,代码以 C++17 为基准。一、值类别与右值引用C++ 的表达式按值类别(value category)分为三类:类别含义例子lvalue(左值)有身份、可取地址,通常不能隐式移动变量名、*p、a[i]prvalue(纯右值)没有身份,就是计算出来的值42、a + b、返回值的函数调用xvalue(将亡值)有身份,但资源可以被转移std::move(x)、返回右值引用的函数调用左值和将亡值合称泛左值(glvalue),纯右值和将亡值合称右值(rvalue)。右值引用T&&可以绑定到右值,从而让编译器选中移动构造函数。// 适用于 C++17#include <string>#include <utility>void 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_cast<std::string&&>(a)。它不读取、不修改a,只是在编译期改变表达式的值类别。至于移动是否真的发生,取决于目标类型有没有可用的移动构造函数。移动构造与移动赋值一个典型的移动构造函数"窃取"源对象的资源,再把源对象置为可安全析构的状态:// 适用于 C++17#include <algorithm>#include <cstddef>#include <utility>class 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把指针置空、大小置零,这是良好的实现习惯:让对象处于一个明确可用的空状态,而不是留下悬垂指针。不要写这样的代码:// 适用于 C++17std::string a = "hello";std::string b = std::move(a);// 错误:a 的值未指定,不要假设它一定是空串// std::cout << a.size();标准库的std::string通常会把被移动对象置空,但这是实现行为,不是可移植的保证。二、移动语义的边界const 对象无法被移动对const对象使用std::move,得到的是const T&&,它无法绑定到T&&类型的移动构造函数,只能匹配拷贝构造函数。因此移动退化为拷贝,且不会有任何警告。// 适用于 C++17const std::string a = "hello";std::string b = std::move(a); // 实际调用拷贝构造如果希望函数真正接受并移动参数,参数不应是const T&;按值传递再std::move进成员,是常见的做法。不要对局部返回值写 std::move// 适用于 C++17Buffer 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,强制一次移动}C++17 起,返回纯右值时强制省略拷贝(guaranteed copy elision);返回具名局部变量时,编译器通常会做具名返回值优化(NRVO),直接在调用方的存储中构造对象,连移动都不需要。写std::move(b)反而把表达式变成将亡值,抑制了 NRVO,只剩一次移动。两者结果相同,但后者更慢。移动不等于免费移动一个std::array会逐元素移动;移动一个std::string在小字符串优化(SSO)生效时也可能复制缓冲区,因为短字符串直接存在对象内部,没有可窃取的堆内存。这些细节由实现决定,标准只规定语义。不要假设"移动一定是常数时间",对具体类型应以标准库实现和测量为准。三、unique_ptr:独占所有权std::unique_ptr<T>表达唯一所有权:同一时刻只有一个unique_ptr拥有对象,它不可拷贝、可移动。对象在unique_ptr销毁时被删除,删除器可以自定义。// 适用于 C++17#include <cstdio>#include <memory>#include <utility>struct Widget {int id = 0;};void use(Widget& w) { (void)w; }int main() {auto w = std::make_unique<Widget>();w->id = 7;std::unique_ptr<Widget> owner = std::move(w);// 此时 w 为空,owner 拥有对象if (w) {use(*w); // 不会执行}use(*owner);// 自定义删除器管理 FILEauto closer = [](std::FILE* f) {if (f) {std::fclose(f);}};std::unique_ptr<std::FILE, decltype(closer)> file(std::fopen("data.txt", "r"), closer);if (!file) {return 1;}// file 析构时调用 closer,自动关闭文件return 0;}要点:用std::make_unique<T>(args...)创建(C++14 起),避免裸new,异常安全更好;转移所有权必须std::move,拷贝构造和拷贝赋值被删除;get()返回裸指针但不转移所有权,release()放弃所有权并返回裸指针(需要自己删除),reset()销毁当前对象并接管新指针;默认删除器用delete,所以unique_ptr<T>不能管理new[]分配的数组;数组要用std::unique_ptr<T[]>,它会调用delete[];自定义删除器会让unique_ptr变大(需要存储删除器),默认删除器通常与裸指针同宽,但标准不保证具体大小。unique_ptr可以作为函数参数表达"接管所有权",作为返回值表达"转移所有权",比文档里的注释可靠得多。四、shared_ptr 与 weak_ptr引用计数与控制块std::shared_ptr<T>通过控制块(control block)共享所有权。控制块里保存强引用计数、弱引用计数、删除器和分配器。每次拷贝shared_ptr增加强计数,销毁减少;强计数归零时对象被删除,弱计数也归零时控制块本身被释放。// 适用于 C++17#include <memory>#include <string>#include <vector>struct Room;struct Session {std::string user;std::weak_ptr<Room> room; // 弱引用,不拥有 Room};struct Room {std::string name;std::vector<std::shared_ptr<Session>> sessions;};void join(const std::shared_ptr<Room>& room,std::shared_ptr<Session> session) {session->room = room; // weak_ptr 由 shared_ptr 赋值room->sessions.push_back(std::move(session));}std::shared_ptr<Room> room_of(const Session& s) {return s.room.lock(); // 提升为 shared_ptr,失败返回空}int main() {auto room = std::make_shared<Room>();room->name = "general";auto s = std::make_shared<Session>();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,强计数永远不会归零,对象永远不会销毁:// 适用于 C++17#include <memory>struct Node {std::shared_ptr<Node> next;// std::shared_ptr<Node> prev; // 错误:与 next 形成环std::weak_ptr<Node> prev; // 正确:一条边用弱引用};这是shared_ptr最典型的泄漏来源。设计双向链表、父子关系、观察者列表时,必须明确哪条边是"拥有"、哪条边是"观察"。通常让父持有子的shared_ptr,子持有父的weak_ptr。不要用同一裸指针构造两个 shared_ptr// 适用于 C++17Widget* raw = new Widget();std::shared_ptr<Widget> a(raw);std::shared_ptr<Widget> b(raw); // 错误:两个独立控制块// a 和 b 各自认为自己是唯一所有者,最终 double free正确做法是用std::make_shared<Widget>(),或者先构造一个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_ptr<T>(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决定。真正的难点不在语法,而在于每次设计接口时回答清楚一个问题:这个对象归谁所有,转移时是复制还是交出。