前言
C++ 智能指针是 RAII(resource acquisition is initialization)在堆对象管理上的标准答案:把「谁负责 delete」这件事编码进类型,让所有权在编译期或运行期有明确归属。标准库提供三种:std::unique_ptr表示独占所有权,std::shared_ptr表示共享所有权,std::weak_ptr表示不增加引用计数的观察者。三者都是类模板,但模板参数的含义、删除器的存放方式、控制块的分配策略各不相同。
典型误解集中在几处。第一,以为用了shared_ptr就不会泄漏——两个对象互相持有shared_ptr时引用计数永远不归零,这是循环引用,必须用weak_ptr打破。第二,以为shared_ptr是线程安全的——它的控制块(引用计数)是线程安全的,但被指向对象的读写不是,多线程访问同一对象仍需同步。第三,以为std::make_shared和shared_ptr<T>(new T)完全等价——前者一次分配,后者两次,而且在存在weak_ptr时内存释放时机不同。
本文按「独占 → 共享 → 观察」的顺序讲三种指针的模板参数与所有权语义,再讲删除器、工厂函数和类型擦除这些模板化用法,最后给出一个观察者模式的完整示例。代码基准为 C++17;涉及 C++20 的改动会单独标注。
一、unique_ptr:独占所有权
1.1 模板形态
// 适用于 C++17(声明示意)
namespace std {
template <class T, class Deleter = std::default_delete<T>>
class unique_ptr;
template <class T, class Deleter = std::default_delete<T>>
class unique_ptr<T[], Deleter>; // 数组特化
}删除器是类型的一部分。unique_ptr<T, D>与unique_ptr<T>是不同类型,不能互相赋值。默认删除器std::default_delete<T>是无状态的空类,常见实现借助空基类优化,让sizeof(unique_ptr<T>)等于sizeof(T*);但标准并不保证这一点,不要把它写进静态断言。
1.2 所有权转移
unique_ptr不可拷贝,只能移动:
// 适用于 C++17
#include <memory>
std::unique_ptr<int> make_value(int v) {
return std::make_unique<int>(v); // C++14 起
}
std::unique_ptr<int> a = make_value(1);
std::unique_ptr<int> b = std::move(a); // a 变为 nullptr
// std::unique_ptr<int> c = b; // 错误:拷贝构造被删除std::move只是把表达式转成右值引用,真正把指针搬过去的是移动构造函数:源指针置空,目标接管。移动之后不要读a指向的内容,它已经是空指针。
1.3 数组与自定义删除器
数组要用unique_ptr<T[]>,它提供operator[]并在析构时调用delete[]:
// 适用于 C++17
auto buffer = std::make_unique<int[]>(10); // 10 个元素,值初始化
buffer[0] = 42;用unique_ptr<int>管理new int[10]是错的:默认删除器调用delete而不是delete[],属于 UB。反过来,用unique_ptr<int[]>管理单个new int也不匹配。
自定义删除器适合包装 C 接口资源:
// 适用于 C++17
#include <cstdio>
#include <memory>
struct FileCloser {
void operator()(std::FILE* f) const noexcept {
if (f != nullptr) std::fclose(f);
}
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
FilePtr open_file(const char* path) {
return FilePtr(std::fopen(path, "rb"));
}FileCloser是空类,FilePtr的大小通常仍是一个指针。如果删除器有状态(比如持有一个日志对象),它会被存进unique_ptr内部,对象尺寸随之增大。
1.4 作为函数参数
| 场景 | 推荐写法 | 理由 |
|---|
| 只观察,不接管 | T*或T& | 不涉及所有权,调用方保留指针 |
| 接管所有权 | std::unique_ptr<T>按值 | 移动语义清晰,调用方必须std::move |
| 可能接管也可能不接管 | std::unique_ptr<T>&& | 意图明确,但不如按值直观 |
| 返回新对象 | 按值返回std::unique_ptr<T> | 移动构造,无拷贝 |
按值传递unique_ptr是最清楚的「 sink 」语义:函数签名本身告诉调用方「这个对象归我了」。如果只是想用一下对象,传T*或T&就够了,不要为了「显得现代」而传const std::unique_ptr<T>&——那既限制了调用方(不能传栈对象),又暗示了错误的所有权关系。
二、shared_ptr:共享所有权
2.1 控制块与引用计数
shared_ptr由两部分组成:指向对象的指针,以及指向控制块的指针。控制块里存强引用计数、弱引用计数、删除器和分配器。拷贝一个shared_ptr增加强计数,销毁减少;计数归零时调用删除器销毁对象。
// 适用于 C++17
#include <memory>
auto p = std::make_shared<int>(42);
auto q = p; // 强计数变为 2
long n = p.use_count(); // 通常为 2,多线程下只是瞬时快照
q.reset(); // 强计数回到 1use_count()在并发环境下不保证反映「此刻」的精确值,只适合调试和日志,不要用它做同步判断。C++17 弃用、C++20 移除了unique()成员,判断是否独占应改用use_count() == 1且明确知道没有并发。
2.2 make_shared 与直接构造
// 适用于 C++17
std::shared_ptr<int> a(new int(1)); // 两次分配:对象 + 控制块
std::shared_ptr<int> b = std::make_shared<int>(1); // 一次分配:对象与控制块同块make_shared在常见实现上把对象和控制块放在同一块内存里,少一次分配,局部性也更好。代价是:只要还有weak_ptr存活,整块内存(包括对象所占的空间)都不能释放,因为控制块和对象在同一块分配里。对象析构函数会先执行,但内存要等弱计数也归零。
make_shared也无法指定自定义删除器,且它用::new分配,对需要特殊对齐或特殊分配器的类型不适用。
2.3 同一个裸指针只能构造一次
// 适用于 C++17 —— 错误
int* raw = new int(5);
std::shared_ptr<int> p1(raw);
std::shared_ptr<int> p2(raw); // 两个独立控制块p1和p2各自认为自己是唯一所有者,各自在计数归零时delete raw,结果是重复释放。正确做法是从一开始就用make_shared,或者只让一个shared_ptr接管裸指针,其余从它拷贝。
同类错误还有在类内部写shared_ptr<T>(this):如果对象已经由外部shared_ptr管理,这会创建第二个控制块。需要从this得到shared_ptr时,继承std::enable_shared_from_this<T>并调用shared_from_this()。
2.4 别名构造与指针转换
shared_ptr有一个别名构造函数(aliasing constructor),它共享控制块但指向另一个地址:
// 适用于 C++17
struct Widget {
int id;
int payload;
};
auto w = std::make_shared<Widget>();
std::shared_ptr<int> pid(w, &w->id); // 与 w 共享所有权,指向 id 成员pid的计数与w共享,w释放时pid也不会让对象提前销毁。这个特性常用于返回「指向对象内部子对象」的shared_ptr。
类型转换用std::static_pointer_cast、std::dynamic_pointer_cast、std::const_pointer_cast;C++17 还提供std::reinterpret_pointer_cast。它们都保留控制块,不会产生第二个所有权。
三、weak_ptr:不拥有,只观察
3.1 打破循环引用
// 适用于 C++17
#include <memory>
#include <string>
struct Node {
std::string name;
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // 弱引用,不参与计数
};
int main() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->prev = a; // 若这里也用 shared_ptr,a 和 b 永远不释放
return 0;
}如果prev也是shared_ptr,a的计数被b->prev持有,b的计数被a->next持有,两个计数都不会归零。把其中一个改成weak_ptr后,强引用形成单向链,计数可以正常归零。
3.2 使用前必须 lock
weak_ptr不能直接解引用,必须先提升为shared_ptr:
// 适用于 C++17
std::weak_ptr<int> wp = a;
if (auto sp = wp.lock()) {
// 提升成功,对象在这次作用域内保证存活
int v = *sp;
} else {
// 对象已销毁
}lock()是原子操作:它与「最后一个shared_ptr析构」之间存在同步,保证提升成功时对象一定还没被销毁。不要写成if (!wp.expired()) { auto sp = wp.lock(); ... }——expired()与lock()之间可能被其他线程插进来销毁对象,这两步不是原子的。
3.3 观察者模式的自然载体
weak_ptr非常适合「主题持有观察者列表,但不拥有观察者」的场景:观察者销毁后,主题下一次通知时lock()失败,顺手把失效项清掉。完整示例见下一节。
四、模板化用法:工厂、删除器与类型擦除
4.1 工厂函数模板
// 适用于 C++17
#include <memory>
#include <utility>
template <typename T, typename... Args>
std::unique_ptr<T> make_object(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}这基本就是std::make_unique的实现思路。自己写的时候要注意:new T(...)与unique_ptr构造之间如果抛异常,new出来的内存会泄漏;标准库的实现用了一些技巧保证异常安全,业务代码直接调std::make_unique即可。C++14 起标准库已提供它,不必自己造。
4.2 删除器决定类型是否可擦除
| 特性 | unique_ptr | shared_ptr |
|---|
| 删除器是否是类型的一部分 | 是 | 否(存在控制块里) |
| 不同删除器能否互相赋值 | 不能 | 可以(同类型 T) |
| 对象大小 | 通常等于裸指针(无状态删除器) | 通常两个指针大小 |
| 拷贝 | 禁止 | 允许 |
| 开销 | 无引用计数 | 原子计数增减 |
因为shared_ptr的删除器被类型擦除进控制块,std::shared_ptr<void>可以持有任意类型的指针,只要构造时给出正确的删除器。这是它比unique_ptr灵活的地方,也是它更重的原因。
4.3 用 shared_ptr 管理数组
C++17 起shared_ptr<T[]>有专门支持,operator[]可用:
// 适用于 C++17
std::shared_ptr<int[]> arr(new int[10], std::default_delete<int[]>());
arr[0] = 1;C++17 之前需要用shared_ptr<int>(new int[10], [](int* p) { delete[] p; })。C++20 起std::make_shared支持数组形式,但支持的编译器版本不一,使用前要查工具链的支持表。
五、完整示例:主题与观察者
// 适用于 C++17
#include <algorithm>
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
class Observer {
public:
virtual ~Observer() = default;
virtual void on_event(const std::string& event) = 0;
};
class Subject {
public:
void subscribe(const std::shared_ptr<Observer>& obs) {
observers_.push_back(obs);
}
void notify(const std::string& event) {
// 先清理已经失效的观察者,再逐个通知
observers_.erase(
std::remove_if(observers_.begin(), observers_.end(),
[](const std::weak_ptr<Observer>& w) { return w.expired(); }),
observers_.end());
for (auto& w : observers_) {
if (auto obs = w.lock()) {
obs->on_event(event);
}
}
}
private:
std::vector<std::weak_ptr<Observer>> observers_;
};
class ConsoleObserver : public Observer {
public:
explicit ConsoleObserver(std::string name) : name_(std::move(name)) {}
void on_event(const std::string& event) override {
std::cout << name_ << ": " << event << '\n';
}
private:
std::string name_;
};
int main() {
Subject subject;
auto a = std::make_shared<ConsoleObserver>("A");
auto b = std::make_shared<ConsoleObserver>("B");
subject.subscribe(a);
subject.subscribe(b);
subject.notify("start");
b.reset(); // B 被销毁,weak_ptr 自动失效
subject.notify("stop"); // 只剩 A 收到
return 0;
}Subject用weak_ptr存储观察者,因此不延长观察者的生命周期;观察者被销毁后,下一次notify会先erase掉过期项。如果把容器换成vector<shared_ptr<Observer>>,观察者就永远无法销毁,这是事件总线里最常见的泄漏来源。
注意notify里先清理再遍历,避免在遍历过程中修改容器导致迭代器失效;remove_if与erase的组合是标准的 erase-remove 惯用法。
常见坑点
- ❌
std::shared_ptr<int> p(new int(1));和std::shared_ptr<int> q(p.get());
✅ 从已有shared_ptr拷贝,或统一用make_shared同一裸指针构造两次会产生两个控制块,导致重复释放。
- ❌ 两个对象互相持有
shared_ptr
✅ 其中一侧改成std::weak_ptr循环引用让强计数永远不归零,对象无法销毁。
- ❌
if (!wp.expired()) { auto sp = wp.lock(); use(*sp); }
✅if (auto sp = wp.lock()) { use(*sp); }expired()与lock()之间的状态可能变化,判断与提升必须一步完成。
- ❌ 认为
shared_ptr线程安全,多个线程直接改同一对象
✅ 只依赖控制块的原子性;对象本身用互斥量或原子类型保护shared_ptr保证计数安全,不保证被指向对象的访问安全。
- ❌
std::unique_ptr<int> p(new int[10]);
✅std::unique_ptr<int[]> p(new int[10]);或std::make_unique<int[]>(10)默认删除器用delete而不是delete[],属于 UB。
- ❌ 在类里写
shared_ptr<T>(this)返回自身
✅ 继承std::enable_shared_from_this<T>,用shared_from_this()否则会创建第二个控制块,对象被析构两次。
- ❌ 用
use_count()做业务判断或同步
✅ 用weak_ptr::lock()判断对象是否存活 多线程下use_count()只是一个瞬时快照,不构成同步。
- ❌ 把
unique_ptr按const unique_ptr<T>&传参表示「只读使用」
✅ 传T*或T&,接管所有权时按值传unique_ptr<T>引用参数限制了调用方,也模糊了所有权语义。
总结
| 指针 | 所有权 | 拷贝 | 主要开销 | 典型场景 |
|---|
unique_ptr | 独占 | 禁止,可移动 | 几乎为零 | 工厂返回值、成员、RAII 包装 |
shared_ptr | 共享 | 允许 | 控制块 + 原子计数 | 多方共享生命周期 |
weak_ptr | 不拥有 | 允许 | 控制块中的弱计数 | 打破循环、观察者、缓存 |
智能指针的价值不只是「自动 delete」,而是把所有权关系写进类型:看到unique_ptr<T>就知道只有一个所有者,看到shared_ptr<T>就知道生命周期由多方共同决定,看到weak_ptr<T>就知道它随时可能失效。模板参数和删除器的设计进一步把「怎么释放」也纳入了类型系统。
实际工程里应优先用unique_ptr,只有在确实需要共享生命周期时才升级到shared_ptr,并且一旦出现「双向关系」就立刻考虑weak_ptr。这套组合能覆盖绝大多数资源管理场景,剩下的少数情况(自定义分配器、跨 DLL 边界、与 C 接口交互)再针对性处理。