裸指针(new/delete)的两大顽疾:忘记释放导致内存泄漏重复释放或异常路径跳过释放导致未定义行为。智能指针用 RAII 把“获取即初始化”贯彻到指针上——构造时持有资源,析构时自动释放,从而让资源管理与控制流解耦。

C++11 后标准库提供三类智能指针(均在 <memory>):std::unique_ptrstd::shared_ptrstd::weak_ptr,加上已废弃的 std::auto_ptr。理解它们的关键不是 API,而是所有权模型

所有权模型三问

选哪个智能指针,只需回答三个问题:

问题答案选择
资源是否独占?unique_ptr
是否需要共享?shared_ptr
是否需要观察但不拥有?weak_ptr

默认选 unique_ptr,需要共享才升级到 shared_ptr。绝大多数场景独占就够。

unique_ptr:独占所有权

unique_ptr 表达严格独占:同一时刻只有一个 unique_ptr 拥有该对象。它不可拷贝,只能移动。

1
2
3
std::unique_ptr<Widget> p1 = std::make_unique<Widget>(args...);
// std::unique_ptr<Widget> p2 = p1; // 错误:不可拷贝
std::unique_ptr<Widget> p3 = std::move(p1); // OK:所有权转移,p1 变 nullptr

为什么默认用 unique_ptr

  1. 零开销抽象:大小与裸指针相同(默认情况下),无引用计数开销。
  2. 可自定义删除器:能管理任意资源(文件、socket、C API 句柄)。
  3. 可高效转换为 shared_ptrstd::shared_ptr<T> sp = std::move(up); 直接移交,反之不行。
  4. 表达意图清晰:API 接收 unique_ptr 参数即声明“我要接管所有权”。

自定义删除器

unique_ptr 的删除器是类型的一部分,这让它能管理非内存资源且无运行时开销:

1
2
3
4
5
// 用 fclose 释放 FILE*
std::unique_ptr<FILE, decltype(&fclose)> fp(fopen("a.txt", "r"), &fclose);

// 简化写法:函数指针
std::unique_ptr<FILE, int(*)(FILE*)> fp(fopen("a.txt", "r"), &fclose);

注意:删除器类型不同,unique_ptr<FILE, DeleterA>unique_ptr<FILE, DeleterB> 是不同类型,不能互相赋值。这也是 shared_ptr 的优势之一——删除器不影响类型。

数组特化

unique_ptr<T[]> 提供了数组特化,重载了 operator[]

1
2
std::unique_ptr<int[]> arr = std::make_unique<int[]>(10);
arr[0] = 42;

现代 C++ 更推荐 std::vectorstd::array 替代裸数组,仅在对接 C API 或性能极端敏感时才用 unique_ptr<T[]>

shared_ptr:共享所有权

shared_ptr 允许多个指针共享同一对象,通过引用计数(reference counting)管理生命周期:每多一个 shared_ptr 指向对象,计数 +1;销毁一个,计数 -1;归零时释放对象。

控制块(Control Block)

每个被共享对象关联一个控制块,包含:

  • 强引用计数(use_count):指向对象的 shared_ptr 数量。
  • 弱引用计数(weak_count):指向控制块的 weak_ptr 数量 + 1(实现细节)。
  • 删除器分配器

控制块是 shared_ptr 开销的来源:原子计数 + 一次堆分配(控制块本身)。make_shared 能把对象和控制块合并为一次分配。

make_shared 的优势

1
2
auto sp1 = std::make_shared<Widget>(args...);   // 推荐:1 次分配
std::shared_ptr<Widget> sp2(new Widget(args...)); // 不推荐:2 次分配

make_shared 把对象和控制块分配在同一块内存,优势:

  1. 单次分配:性能更好,减少内存碎片。
  2. 局部性好:对象和控制块同址,缓存友好。
  3. 异常安全:避免 f(std::shared_ptr<T>(new T), g())g() 抛异常导致泄漏。

但有一个权衡:make_shared 导致对象内存延迟释放。只要还有 weak_ptr 存活,控制块就不销毁,对象内存(即使已析构)也无法归还,直到所有 weak_ptr 也消失。对大对象或需尽快归还内存的场景,改用 shared_ptr(new T)

shared_ptr 的线程安全性

一个常被混淆的点:shared_ptr线程安全是分层的:

  • 控制块计数:原子操作,线程安全。多线程拷贝/销毁不同 shared_ptr(指向同一对象)是安全的。
  • shared_ptr 对象本身:不是线程安全。多线程读写同一个 shared_ptr 变量需要加锁(或用 std::atomic<std::shared_ptr>,C++20)。
  • 指向的对象:与 shared_ptr 无关,由用户保证。
1
2
3
4
5
6
7
auto sp = std::make_shared<int>(0);

// 线程安全:各线程拷贝 sp 到自己的局部副本,操作副本
std::thread t1([&sp]{ auto local = sp; /* 读 local */ });

// 不安全:多线程写同一个 sp
// std::thread t2([&sp]{ sp = std::make_shared<int>(1); }); // 数据竞争,需加锁

智能指针与标准容器

标准容器存放的是。当这个值是智能指针时,容器管理的是“指针对象”的生命周期,而智能指针再管理“被指向对象”的生命周期:

  • std::vector<std::unique_ptr<T>>:容器元素不可拷贝,只能移动;删除元素会释放其拥有的对象。
  • std::vector<std::shared_ptr<T>>:容器元素可拷贝;拷贝会增加引用计数,删除元素只减少计数,不一定释放对象。
  • std::vector<T>:若不需要多态、可空、共享或稳定地址,优先直接存值,最简单也最 cache-friendly。

顺序容器:vector / deque / list

vector<unique_ptr<T>> 是最常见的“拥有一组多态对象”的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Shape {
virtual ~Shape() = default;
virtual void draw() const = 0;
};

struct Circle : Shape {
void draw() const override {}
};

std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>());
shapes.emplace_back(std::make_unique<Circle>());

for (const auto& shape : shapes) {
shape->draw();
}

因为 unique_ptr 不可拷贝,插入已有指针时必须显式移动:

1
2
auto p = std::make_unique<Circle>();
shapes.push_back(std::move(p)); // p 变 nullptr

vector 扩容时会移动其中的 unique_ptr。这只移动指针本身,不会移动被管理的对象,所以 widgets[i].get() 指向的对象地址不变;但指向容器元素本身的迭代器、引用、指针仍可能失效。

1
2
3
4
5
6
7
8
9
std::vector<std::unique_ptr<Widget>> widgets;
widgets.reserve(100); // 已知规模时减少扩容和迭代器失效

widgets.emplace_back(std::make_unique<Widget>());
Widget* raw = widgets.back().get();

widgets.emplace_back(std::make_unique<Widget>()); // 可能触发 vector 扩容
raw->use(); // OK:对象地址没变
// auto& slot = widgets[0]; // 若扩容,slot 这种元素引用会失效

deque<unique_ptr<T>> 适合两端频繁插入;list<unique_ptr<T>> 适合需要稳定迭代器/节点、频繁拼接或中间删除的场景。但 list 牺牲局部性,默认仍优先考虑 vector

shared_ptr 放进顺序容器时要注意“拷贝就是共享”:

1
2
3
4
5
std::vector<std::shared_ptr<Widget>> widgets;

auto w = std::make_shared<Widget>();
widgets.push_back(w); // use_count +1,w 和容器共享对象
widgets.push_back(std::move(w)); // 不增加计数,w 变 nullptr

如果只是遍历调用,不要为了“方便”把裸对象批量包成 shared_ptrshared_ptr 应表达真实共享所有权,而不是替代引用或裸指针观察。

关联容器:map / unordered_map

智能指针更常作为 map / unordered_map 的 value,而不是 key:

1
2
3
4
5
6
7
8
9
std::unordered_map<std::string, std::unique_ptr<Session>> sessions;

sessions.try_emplace("alice", std::make_unique<Session>());

if (auto it = sessions.find("alice"); it != sessions.end()) {
it->second->touch();
}

sessions.erase("alice"); // 若没有其他所有者,Session 被释放

少用 operator[] 插入 unique_ptr value,因为它会先默认构造一个空指针:

1
2
sessions["bob"] = std::make_unique<Session>();  // 可用,但中间会产生 nullptr slot
sessions.try_emplace("bob", std::make_unique<Session>()); // 更直接

shared_ptr 作为 value 时,erase 只表示“容器不再共享”:

1
2
3
4
5
std::unordered_map<std::string, std::shared_ptr<Session>> sessions;

auto s = std::make_shared<Session>();
sessions.emplace("alice", s); // use_count +1
sessions.erase("alice"); // use_count -1,s 仍让对象存活

把智能指针作为 key 要非常谨慎:默认比较的是指针地址,不是对象内容。

1
std::set<std::shared_ptr<Widget>> by_address; // 默认按 get() 的地址排序

如果想按对象内容排序,提供比较器,并保证对象中参与排序的字段在容器内不被修改:

1
2
3
4
5
6
7
8
9
struct WidgetLess {
bool operator()(const std::unique_ptr<Widget>& lhs,
const std::unique_ptr<Widget>& rhs) const {
return lhs->id() < rhs->id();
}
};

std::set<std::unique_ptr<Widget>, WidgetLess> widgets;
widgets.insert(std::make_unique<Widget>(1));

如果想按 shared_ptr所有权控制块比较(而不是裸地址),用 std::owner_less,常见于需要把 aliasing shared_ptrweak_ptr 放进有序容器的场景:

1
std::set<std::shared_ptr<Widget>, std::owner_less<std::shared_ptr<Widget>>> owners;

容器适配器:queue / stack / priority_queue

queuestack 可以存 unique_ptr,入队/入栈时移动所有权:

1
2
3
4
5
6
std::queue<std::unique_ptr<Task>> tasks;
tasks.push(std::make_unique<Task>());

auto task = std::move(tasks.front());
tasks.pop();
task->run();

priority_queue<unique_ptr<T>> 能存,但取出最大元素很别扭:top() 返回 const_reference,不能直接把 unique_ptr move 出来。若确实需要“按优先级拥有任务并取出”,通常用 std::vector<std::unique_ptr<T>> 配合 std::ranges::push_heap / std::ranges::pop_heap,或改用能自然共享的 shared_ptr

容器中的选择规则

场景推荐写法
同类型对象集合,无多态、不共享std::vector<T>
多态对象集合,容器独占对象std::vector<std::unique_ptr<Base>>
对象需要被多个模块共同持有std::vector<std::shared_ptr<T>>
容器只是索引/缓存,不应延长生命周期std::vector<std::weak_ptr<T>> 或裸指针/引用(需明确生命周期)
按 id 查找并独占对象std::unordered_map<Id, std::unique_ptr<T>>
按 id 查找并共享对象std::unordered_map<Id, std::shared_ptr<T>>

下面把“存”和“取”两个方向单独展开——它们的所有权语义最容易踩坑,且 unique_ptr(只能 move)与 shared_ptr(拷贝即共享)的写法截然不同。

存入容器:插入与所有权转移

1. 就地构造,最干净

make_unique/make_shared 直接写在插入点,没有中间变量,所有权一路移交,不存在“忘记 move”的问题:

1
2
3
std::vector<std::unique_ptr<Shape>> shapes;
shapes.emplace_back(std::make_unique<Circle>()); // 临时 unique_ptr 被移动进容器
shapes.push_back(std::make_unique<Square>()); // 同样 OK

2. 移动已有指针进容器

unique_ptr 不可拷贝,插入已存在的指针必须 std::move,源指针随即变 nullptr(与 [[cxx-move-semantics]] 一致):

1
2
3
auto p = std::make_unique<Circle>();
shapes.push_back(std::move(p)); // p == nullptr,对象归容器所有
// shapes.push_back(p); // 编译错误:unique_ptr 不可拷贝

3. emplace_back 不会帮你省掉 move

常见误解:以为 emplace_back 能“原地构造”从而避免移动。但容器元素类型本身就是 unique_ptr<T>emplace_back 收到的实参仍是 unique_ptr<T>,照样要移动一次指针。真正能省移动的是“原地构造被管理对象”——但那要求容器存值 T 而非 unique_ptr<T>。所以对智能指针容器,push_back(std::move(p))emplace_back(std::make_unique<T>()) 在移动次数上等价,选哪个看可读性。

4. vector 预先 reserve

unique_ptr/shared_ptr 本身可高效移动,但扩容会搬动整个元素数组、使指向容器元素的迭代器/引用失效(指向被管理对象的指针不受影响)。规模已知时先 reserve

1
2
3
4
std::vector<std::unique_ptr<Widget>> widgets;
widgets.reserve(N); // 避免反复扩容
for (int i = 0; i < N; ++i)
widgets.emplace_back(std::make_unique<Widget>(i));

5. map 插入:try_emplace 优先,按需用 insert_or_assign

operator[] 插入 unique_ptr value 会先默认构造一个 nullptr 槽、再 move 赋值,多一次无谓操作,且要求 value 可默认构造。try_emplace 只在 key 不存在时才构造,语义干净;insert_or_assign 则用于“存在就替换”:

1
2
3
4
5
6
7
8
9
10
std::unordered_map<std::string, std::unique_ptr<Session>> sessions;

// 不推荐:operator[] 先插入 nullptr 再 move 赋值,且要求 value 可默认构造
// sessions["alice"] = std::make_unique<Session>();

// 推荐:仅当 key 不存在时构造,不产生空槽、不要求默认构造
sessions.try_emplace("alice", std::make_unique<Session>());

// 若想“存在则替换、不存在则插入”,用 insert_or_assign(C++17)
sessions.insert_or_assign("alice", std::make_unique<Session>());

shared_ptr value 的插入同理,但多一个维度:emplace/try_emplace 的实参若直接传 make_shared 的结果(右值),是移动、计数不变;若传一个具名 shared_ptr 变量(左值),则拷贝、计数 +1。是否共享就在这一步决定,写代码时要看清传的是右值还是左值。

从容器取出:访问与所有权移交

“取”分两类:只观察、不改所有权,和把所有权拿走。两者写法不同,混用会出 bug。

1. 只观察:用引用,不要拷贝指针

遍历或按位访问时,用 const auto&(顺序容器)或 it->second(map)拿到智能指针的引用,再 -> 调用,全程不动计数:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 顺序容器:引用避免拷贝(unique_ptr 本就拷不动)
for (const auto& shape : shapes) {
shape->draw(); // shape 是 const unique_ptr<Shape>&
}

// 按下标访问:v[i] 返回引用
shapes[0]->draw();
shapes.at(0)->draw(); // 越界抛 std::out_of_range,比 [] 安全

// map 查找:find + it->second,不要用 operator[] 读
if (auto it = sessions.find("alice"); it != sessions.end()) {
it->second->touch(); // it->second 是 unique_ptr<Session>&
}

读 map 时切忌 sessions["alice"]->touch()operator[] 在 key 不存在时会插入一个空 nullptr 槽,既污染容器又解引用空指针。查找一律用 find/contains

需要把对象交给不接受智能指针的接口时,用 .get() 取裸指针,仅作观察用,不要 delete、不要存起来超过容器寿命:

1
2
void legacy_draw(const Shape*);   // 只借用,不持有
legacy_draw(shapes[0].get());

2. 从 vector 拿走所有权

std::move 把某个槽位的 unique_ptr 搬空,槽位留下 nullptr,对象所有权转出;之后通常要 erase 掉这个空槽:

1
2
3
auto p = std::move(shapes[i]);   // p 接管对象,shapes[i] == nullptr
shapes.erase(shapes.begin() + i);
p->draw();

但中间 erase 是 O(n)(后面元素逐个前移)。要 O(1) 删除且不在乎顺序时,用 swap-and-pop:把末尾元素交换到待删位置再 pop_back。对智能指针,交换的只是指针本身,被管理对象不动:

1
2
3
4
5
// O(1) 移除第 i 个,顺序不保证
std::swap(shapes[i], shapes.back());
auto p = std::move(shapes.back()); // 取走所有权,back() == nullptr
shapes.pop_back();
p->draw();

3. 从 map 拿走所有权:extract 节点句柄(C++17)

extract 把节点从容器中摘下并返回节点句柄,被管理的对象既不拷贝也不析构,可以直接把 unique_ptr move 出来:

1
2
3
4
5
auto node = sessions.extract("alice");   // 摘除节点,Session 仍存活
if (!node.empty()) {
std::unique_ptr<Session> sp = std::move(node.mapped()); // 接管所有权
sp->touch();
} // node 析构:此时 mapped() 已是空 unique_ptr,不释放任何对象;key 正常析构

extract 比“find + std::move(it->second) + erase”更直接:不留下空槽、不触发 rehash,还能把节点 insert 进另一个同类型 map 实现零拷贝迁移。

4. shared_ptr 的“取”要看清计数

vector<shared_ptr<T>> 取出,是观察还是共享,取决于写法:

1
2
3
4
5
std::vector<std::shared_ptr<Widget>> widgets;

const std::shared_ptr<Widget>& ref = widgets[0]; // 引用:计数不变,仅观察
auto copy = widgets[0]; // 拷贝:use_count +1,你也共享所有权
auto moved = std::move(widgets[0]); // 移动:计数不变,widgets[0] == nullptr

若调用方只需本次使用对象,传 const& 或裸指针(.get())即可,避免无谓的原子计数增减;只有确实要延长生命周期时才拷贝出 shared_ptr

weak_ptr:不占有的观察者

weak_ptrshared_ptr 的“弱引用”:指向控制块但不增加强引用计数,因此不会延长对象生命周期。主要解决两个问题:

  1. 打破 shared_ptr 循环引用
  2. 观察对象但不拥有(如缓存、观察者模式,需先检查对象是否存活)。

循环引用问题

1
2
3
4
5
6
7
8
9
10
struct Node {
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev; // 循环引用:两节点互相持有,计数永不归零
};

auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b; // b 的 use_count = 2
b->prev = a; // a 的 use_count = 2
// a、b 离开作用域后 use_count 各降为 1,对象永不释放 → 内存泄漏

解法:把其中一环改为 weak_ptr

1
2
3
4
struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // 弱引用,不增加计数
};

lock():安全访问

weak_ptr 不能直接访问对象(对象可能已销毁)。用 lock() 提升为 shared_ptr

1
2
3
4
5
6
7
std::weak_ptr<Widget> wp = sp;

if (auto sp2 = wp.lock()) { // 对象存活,sp2 是新的 shared_ptr
sp2->doSomething();
} else {
// 对象已销毁
}

lock() 等价于 shared_ptr<T>(wp) 的原子化版本,避免“检查存活”与“使用”之间的竞态。也可用 expired() 检查,但 lock() 更安全(原子)。

enable_shared_from_this

当对象需要在成员函数中返回指向自身的 shared_ptr 时,直接 shared_ptr<X>(this) 会创建新的控制块,导致双重释放:

1
2
3
struct Bad {
std::shared_ptr<Bad> get() { return std::shared_ptr<Bad>(this); } // 灾难!
};

正确做法是继承 std::enable_shared_from_this

1
2
3
struct Good : std::enable_shared_from_this<Good> {
std::shared_ptr<Good> get() { return shared_from_this(); } // 复用已有控制块
};

shared_from_this() 要求对象本身已由 shared_ptr 管理,否则 UB。构造函数中(对象尚未被管理)不能调用。

enable_shared_from_this 的原理

enable_shared_from_this 内部持有一个 weak_ptr<T> weak_this。当 shared_ptr 第一次构造该对象时,会检查它是否继承自 enable_shared_from_this,若是则把 weak_this 指向自己(已有的控制块)。之后 shared_from_this()lock()weak_ptr,复用同一控制块。

aliasing constructor:共享所有权但指向别处

shared_ptr 的别名构造函数允许两个 shared_ptr 共享一个控制块(生命周期绑定),却指向不同对象:

1
2
3
4
5
struct Pair { Widget a; Widget b; };

auto sp = std::make_shared<Pair>();
std::shared_ptr<Widget> wp(sp, &sp->b); // wp 指向 b,但控制块绑定整个 Pair
// sp 销毁后,只要 wp 存活,Pair 就不释放

用于把大对象的一部分以 shared_ptr 暴露出去,同时保证父对象存活。

常见陷阱

1. 用裸指针构造多个 shared_ptr

1
2
3
Widget* raw = new Widget;
std::shared_ptr<Widget> sp1(raw);
std::shared_ptr<Widget> sp2(raw); // 两个独立控制块 → 双重释放

2. 在对象内部返回 this

见上文 enable_shared_from_this

3. 误以为 shared_ptr 线程安全

对象本身和 shared_ptr 变量仍需同步保护(见“线程安全性”节)。

4. 循环引用

shared_ptr 互相持有导致泄漏,用 weak_ptr 打破(见上文)。

5. make_shared 与弱引用延长内存

weak_ptr 长期存活时,make_shared 的对象内存延迟释放。需尽快释放大对象内存时避免 make_shared

与移动语义的关系

智能指针的移动是“所有权转移”而非“资源拷贝”,与 [[cxx-move-semantics]] 一致:

1
2
std::unique_ptr<T> up = std::move(other);   // O(1) 转移
std::shared_ptr<T> sp = std::move(other); // 计数不变,只搬指针

shared_ptr 移动构造比拷贝构造更高效:拷贝要原子地增加计数,移动则直接搬指针、计数不变。容器中存放 shared_ptr 时,优先 emplace_back(std::move(sp)) 而非 push_back(sp)

选型决策树

1
2
3
4
5
6
7
需要管理动态资源吗?
├─ 否 → 不要用智能指针,用栈对象 / 值语义
└─ 是 → 资源是否独占?
├─ 是 → unique_ptr(默认选择)
└─ 否 → 是否需要共享所有权?
├─ 是 → shared_ptr
└─ 只需观察 → weak_ptr

小结

  • 默认 unique_ptr:零开销、表达独占、可转 shared_ptr
  • shared_ptr 用于真正的共享:注意控制块、make_shared 的内存权衡、线程安全分层。
  • weak_ptr 打破循环与观察:用 lock() 安全访问。
  • enable_shared_from_this:对象内返回自身 shared_ptr 的唯一正确方式。
  • 优先 make_unique/make_shared:异常安全、性能更好。

智能指针是现代 C++ 资源管理的基石,配合 [[cxx-rvo]]、[[cxx-special-member-functions]] 和 [[cxx-new-and-delete]],基本可以告别裸 delete