C++ 智能指针
什么是智能指针
在 C++ 中,智能指针(Smart Pointer)本质上是一个对象,它的行为看起来像指针,但额外封装了资源管理语义,最核心的目标只有一个:
自动管理动态资源的生命周期,避免手动
new / delete带来的错误。
为什么需要智能指针
在传统 C++ 中,动态内存通常这样管理:
int* p = new int(10);
// ...
delete p;
这种写法存在几个长期困扰 C++ 程序员的问题:
- ❌ 忘记
delete→ 内存泄漏 - ❌ 重复
delete→ 未定义行为 - ❌ 异常路径提前返回 → 资源无法释放
- ❌ 所有权不清晰 → 谁负责释放内存不明确
这些问题的本质原因是:
资源的申请和释放是“人为约定”的,而不是“语言强制”的。
智能指针解决的核心问题
智能指针引入了一个非常重要的思想:
让资源的生命周期与对象的生命周期绑定。
也就是说:
- 资源在 构造函数中获取
- 资源在 析构函数中释放
这正是 C++ 中经典的 RAII(Resource Acquisition Is Initialization)思想。
示意代码:
{
std::unique_ptr<int> p(new int(10));
// 使用 p
} // 作用域结束,p 析构,内存自动释放
不需要显式 delete,也不怕异常提前离开作用域。
为什么“叫指针”,但本质是对象
虽然名字叫“智能指针”,但它并不是裸指针:
std::unique_ptr<int> p;
p是一个 对象- 内部 持有一个原始指针
- 通过重载
*、->等运算符,表现得“像指针一样使用”
*p = 42;
p->some_function();
所以可以这样理解:
智能指针 = 原始指针 + 析构策略 + 所有权语义
智能指针和普通指针的本质区别
| 对比点 | 原始指针 | 智能指针 |
|---|---|---|
| 是否自动释放 | ❌ | ✅ |
| 是否表达所有权 | ❌ | ✅ |
| 是否安全 | ❌ | 相对安全 |
| 是否支持 RAII | ❌ | ✅ |
| 是否可作为对象传递 | ⚠️ | ✅ |
智能指针并不是“万能的指针”
需要强调的是:
智能指针解决的是“资源管理问题”,不是“所有指针问题”。
它并不会:
- 自动解决循环引用(
shared_ptr仍然可能泄漏) - 自动让程序“没有 bug”
- 替代所有原始指针的使用场景
智能指针的价值在于:
把“释放资源”从程序员的责任,转移到语言和类型系统中。
- 智能指针是 封装了资源管理语义的对象
- 它通过 RAII 自动管理资源生命周期
- 解决了手动
new / delete带来的大量安全隐患 - 是现代 C++ 资源管理的基础工具之一
unique_ptr —— 独占所有权的智能指针
std::unique_ptr 是 C++11 引入的智能指针,用来表达一种非常明确的语义:
某个资源在任意时刻只能被一个对象拥有。
这也是它名字中 unique 的真正含义。
unique_ptr 的核心语义:独占所有权
std::unique_ptr<int> p(new int(10));
此时:
p唯一拥有这块动态内存- 不存在第二个
unique_ptr可以同时管理同一资源 p生命周期结束时,资源自动释放
这解决了裸指针中最难处理的问题之一:
“到底谁负责 delete?”
在 unique_ptr 中,答案是:类型本身明确表达了所有权。
为什么 unique_ptr 不允许拷贝
下面的代码是不允许的:
std::unique_ptr<int> p1(new int(10));
std::unique_ptr<int> p2 = p1; // ❌ 编译错误
原因不是技术限制,而是语义约束:
- 拷贝意味着 两个对象同时拥有同一资源
- 这直接违背了 “unique” 的设计初衷
所以 unique_ptr:
- ❌ 禁止拷贝构造
- ❌ 禁止拷贝赋值
在底层设计上,实现的伪代码如下:
std::unique_ptr(const std::unique_ptr&) = delete;
std::unique_ptr& operator=(const std::unique_ptr&) = delete;
这样一来,就从类型系统层面杜绝“双重释放”的可能。
unique_ptr 是“可移动的”
虽然不能拷贝,但 unique_ptr 可以移动:
std::unique_ptr<int> p1(new int(10));
std::unique_ptr<int> p2 = std::move(p1);
移动后:
p2接管资源的所有权p1被置为空(内部指针为nullptr)
这里的语义是:
所有权发生转移,而不是共享。
这非常符合现实中的“交接责任”模型。
unique_ptr 与 RAII 的结合
unique_ptr 的析构函数中会自动释放资源:
{
std::unique_ptr<int> p(new int(10));
} // 离开作用域,自动 delete
无论是:
- 正常路径
return- 抛异常
资源都会被正确释放。
这正是 RAII 在现代 C++ 中的标准实践。
推荐使用 make_unique
相比下面的写法:
std::unique_ptr<int> p(new int(10));
更推荐:
auto p = std::make_unique<int>(10);
原因有三点:
异常安全
- 避免构造参数抛异常导致资源泄漏
代码更简洁
表达意图更清晰
在现代 C++ 中:
“不直接写 new”几乎是一个共识。
unique_ptr 的使用场景
unique_ptr 非常适合以下情况:
- 管理堆对象的唯一所有权
- 函数返回动态资源
- 表达“拥有即负责释放”的语义
- 作为类的成员管理资源
例如:
std::unique_ptr<Foo> createFoo() {
return std::make_unique<Foo>();
}
unique_ptr 并不等于“不能传递”
虽然不能拷贝,但你可以:
- 按引用传递(不转移所有权)
- 按值 +
std::move传递(转移所有权)
void take(std::unique_ptr<Foo> p); // 接管所有权
void observe(const std::unique_ptr<Foo>& p); // 只观察
类型本身就清楚表达了函数的意图。
unique_ptr表达 独占所有权- 禁止拷贝,允许移动
- 生命周期结束时自动释放资源
- 是现代 C++ 中 首选的资源管理工具
- 当你“只有一个拥有者”时,应优先使用
unique_ptr
std::shared_ptr 用来解决这样一类问题:
同一份资源需要被多个对象共同拥有,且生命周期无法由某一个对象单独决定。
与 unique_ptr 的“唯一所有权”不同,shared_ptr 表达的是 共享所有权。
auto p1 = std::make_shared<int>(10);
auto p2 = p1;
此时:
p1和p2共同管理同一块内存- 资源不会因为某一个指针离开作用域而被释放
- 只有当 最后一个
shared_ptr被销毁 时,资源才会释放
shared_ptr 并不只是一根指针,它至少包含两个部分:
- 被管理的对象指针
- 控制块(control block)
可以抽象成:
shared_ptr
├── ptr ───────▶ 实际对象
└── ctrl ───────▶ 控制块
控制块里有什么(引用计数核心)
控制块通常包含:
struct ControlBlock {
atomic<size_t> shared_count; // 强引用计数
atomic<size_t> weak_count; // 弱引用计数
Deleter deleter; // 删除策略
};
其中最关键的是:
shared_count:
记录当前 有多少个 shared_ptr 在共享资源weak_count:
用于weak_ptr,后面章节展开
引用计数是如何工作的
创建时
auto p = std::make_shared<int>(10);
- 分配一块内存(对象 + 控制块)
shared_count = 1
拷贝时
auto p2 = p;
shared_count++
析构时
p2.~shared_ptr();
shared_count--如果
shared_count == 0:- 调用 deleter 释放对象
- 但 控制块不一定立刻释放(weak_ptr 仍可能存在)
控制块的最终释放
当:
shared_count == 0weak_count == 0
控制块才会被释放
标准保证的是:
不同的 shared_ptr 实例,在多线程中同时进行拷贝、析构是安全的。
也就是说:
shared_ptr<int> p = ...;
std::thread t1([p]{ /* 使用 p */ });
std::thread t2([p]{ /* 使用 p */ });
是安全的。
线程安全的实现原理
引用计数必须是原子的。
所以:
shared_count++;
shared_count--;
在标准实现中:
- 使用
std::atomic - 或平台提供的原子指令(如
lock xadd)
这是 shared_ptr 性能成本的重要来源。
一个非常重要但常被误解的点
shared_ptr 只保证“控制块”的线程安全,不保证“对象本身”的线程安全。
例如:
shared_ptr<Foo> p;
p->modify(); // ❌ 如果多个线程同时调用,仍然是数据竞争
如果对象本身需要并发访问:
- 仍然需要 mutex
- 或对象本身是线程安全的
auto p = std::make_shared<T>(...);
相比:
shared_ptr<T> p(new T(...));
优势在于:
- 对象 + 控制块 一次分配
- 更少的内存碎片
- 更好的 cache locality
但代价是:
控制块和对象绑定,析构顺序不可分离
(比如自定义 deleter 场景)
struct A {
shared_ptr<B> b;
};
struct B {
shared_ptr<A> a;
};
A和B的shared_count永远不会归零A:我要释放自己,但B还在引用我B:我要释放自己,但A还在引用我- 很好,都别想释放了
- 资源泄漏
这里的演示可能抽象了点,我们来说具体一点:
- 当
A需要释放自己的时候,它会检查自己的shared_count:- 如果
shared_count不为零,说明还有其他shared_ptr在引用它,它就不能释放自己。
- 如果
- 同理,
B也是一样的逻辑。
所以说,最后并不是说二者会互相等到导致卡住,这不是线程死锁问题,只是他们各自都不会释放自己,因为 shared_count 永远不会归零。所以才会导致资源泄漏。
👉 这正是 weak_ptr 存在的原因
(可以作为你下一节的自然过渡)
适合场景:
- 多个对象共同拥有资源
- 生命周期难以静态确定
- 需要跨线程共享所有权
不适合场景:
- 明确单一所有者
- 性能极端敏感的代码
- 能用
unique_ptr解决的地方
如果能用 unique_ptr,就不要用 shared_ptr。
shared_ptr基于 引用计数 管理资源- 控制块记录共享和弱引用数量
- 引用计数操作是原子的,因此有性能成本
- 线程安全只覆盖“引用计数”,不覆盖“对象本身”
- 循环引用需要
weak_ptr解决
weak_ptr —— 不参与所有权的观察者指针
std::weak_ptr 本质上是:
对 shared_ptr 所管理对象的一种“非拥有性引用”
它解决的是 shared_ptr 的一个根本问题:
循环引用导致引用计数永远无法归零
为什么需要 weak_ptr(动机)
先回顾 shared_ptr 的问题:
struct A {
std::shared_ptr<B> b;
};
struct B {
std::shared_ptr<A> a;
};
此时:
A持有BB持有A- 两者
shared_count >= 1 - 析构永远不会发生
根本原因是:
shared_ptr 之间形成了“强引用环”
weak_ptr 的语义本质
weak_ptr 的语义可以总结为一句话:
我能知道对象还活着,但我不决定它是否该死。
关键点:
- ❌ 不增加
shared_count - ✅ 增加
weak_count - ❌ 不能直接解引用
- ✅ 可以“尝试”获得一个 shared_ptr
一个非常重要的事实:
weak_ptr 和 shared_ptr 指向的是同一个控制块
┌────────────┐
shared_ptr│ 控制块 │◀──────── weak_ptr
│────────────│
│ shared_cnt │
│ weak_cnt │
└────────────┘
│
▼
实际对象
weak_ptr 不保存对象指针本身,它只保存:
- 控制块指针
weak_ptr 的核心数据:weak_count
控制块中:
struct ControlBlock {
atomic<size_t> shared_count;
atomic<size_t> weak_count;
};
创建 weak_ptr
std::shared_ptr<int> sp = ...;
std::weak_ptr<int> wp = sp;
发生的事情:
shared_count不变weak_count++
对象销毁 vs 控制块销毁(重点)
这是 weak_ptr 能存在的根本原因。
shared_count == 0
- ✅ 对象被销毁
- ❌ 控制块仍然存在(只要
weak_count > 0)
weak_count 也归零时
shared_count == 0 && weak_count == 0
- 控制块才被释放
👉 这就保证了:
weak_ptr 可以在对象已被销毁后,安全地“观察”其状态
为什么 weak_ptr 不能直接解引用
std::weak_ptr<int> wp;
*wp; // ❌ 不允许
wp->foo(); // ❌ 不允许
原因非常根本:
weak_ptr 不保证对象存在
如果允许解引用:
- 可能访问已经析构的对象
- 会直接导致悬垂引用
lock():weak_ptr 的核心操作
使用方式
if (auto sp = wp.lock()) {
// 对象仍然存在
sp->do_something();
} else {
// 对象已经被销毁
}
lock() 的底层原理
lock() 做了三件事:
原子地检查
shared_count如果
shared_count > 0:shared_count++- 构造一个新的 shared_ptr
否则返回空的 shared_ptr
关键点:这一步是原子的
防止:
- 一个线程判断对象“还活着”
- 另一个线程刚好析构最后一个 shared_ptr
expired() 的作用与局限
if (wp.expired()) { ... }
底层:
return shared_count == 0;
但要注意:
expired() 只能用于“快速判断”,不能作为逻辑保证
错误示例:
if (!wp.expired()) {
auto sp = wp.lock(); // ❗ 仍可能失败(并发)
}
正确用法:
if (auto sp = wp.lock()) {
...
}
weak_ptr 解决循环引用的标准方式
struct A {
std::shared_ptr<B> b;
};
struct B {
std::weak_ptr<A> a; // 断环
};
现在:
A→ 强引用 →BB→ 弱引用 →A
当 A 释放:
A.shared_count == 0B的 weak_ptr 不阻止析构- 整个结构可正确释放
weak_ptr 的线程安全性
和 shared_ptr 一致:
weak_count是原子操作lock()是线程安全的
但同样注意:
weak_ptr / shared_ptr 的线程安全 ≠ 对象的线程安全
典型使用场景
观察者模式
class Observer {
std::weak_ptr<Subject> subject;
};
避免被观察者和观察者互相“绑死”。
缓存系统
std::unordered_map<Key, std::weak_ptr<Value>> cache;
- 对象无人使用时自动回收
- 缓存不会强行延长生命周期
回调 / lambda 捕获
std::weak_ptr<Foo> wp = sp;
callback = [wp] {
if (auto sp = wp.lock()) {
sp->do();
}
};
避免回调延长对象生命周期。
- weak_ptr 不拥有对象
- 它只“观察” shared_ptr 的控制块
- 通过
lock()原子地尝试获取所有权 - 它的存在是为了解决 shared_ptr 的结构性缺陷
shared_ptr 管生,weak_ptr 看死。
在类之间存在相互调用关系时,如果双方都使用shared_ptr持有对方,很容易形成循环引用,导致资源无法释放。此时引入
weak_ptr,并不是为了“更细粒度地管理资源”,而是为了打破生命周期之间的强耦合。
weak_ptr本身并不参与对象的生命周期管理,它只作为一种“观察者”,用于判断对象是否仍然存在。在真正需要访问对象时,通过lock()临时获取一个shared_ptr,在这一小段作用域内安全地使用对象。可以将
weak_ptr理解为一个“看门狗”:
它不会阻止对象被释放,但可以在进入前确认对象是否仍然存活。正因为
weak_ptr不拥有对象,所以它通常只应在以下场景中使用:
—— 对象之间存在依赖关系,但不应形成所有权关系。如果一个对象本身就承担着资源所有权的职责,则仍然应该使用
shared_ptr或unique_ptr,而不是直接依赖weak_ptr。