文章目录
- 智能指针的RAII思想
- 手撕两种智能指针
-
- 独占型:`unique_ptr`
- 手撕`shared_ptr`
-
- 代码
- 灵魂拷问:`shared_ptr`是线程安全的吗?
- 解决`shared_ptr`循环引用问题
-
- 代码
- 借助案例理解循环引用
- C++提供的库函数
- 总结
智能指针的RAII思想
在大型项目中,裸指针最大的弊端在于生命周期管理失控,在复杂的操作流中,我们很容易忘记调用delete导致内存泄漏,或者内存释放后继续访问悬空指针,又甚至是引发多重释放
由此,C++提出一个重要思想:RAII(资源获取即初始化):
将资源的生命周期和一个栈上对象的生命周期绑定在一起
栈上对象的生命周期管理是自动的,堆上资源生命周期管理是手动的
绑定就是让栈上对象去管理堆上资源
举个例子:
class Mutex{
public:
// 省略部分代码
void Lock() { ::pthread_mutex_lock(&_lock); }
void Unlock() { ::pthread_mutex_unlock(&_lock); }
private:
pthread_mutex_t _lock;
};
class LockGuard{
public:
LockGuard(Mutex &mtx):_mtx(mtx) { _mtx.Lock(); }
~LockGuard() { _mtx.Unlock(); }
private:
Mutex &_mtx;
};
利用类的特性,构造分配资源,析构释放资源,这就是RAII的思想
手撕两种智能指针
智能指针就是一个分配在栈上的对象,在内部封装了裸指针,根据栈上对象的生命周期规律来完成对资源的自动管理
独占型:unique_ptr
- 本质:代表“专属所有权”。比如说自己独居的公寓,钥匙只有自己持有,不能共享给别人
- 实现的核心是禁用拷贝,启动移动
#pragma once
// 手撕unique_ptr
// 本质是“独占所有权”—>我独居的公寓,钥匙只有一把,绝不共享
// C++98有个auto_ptr:核心是管理权限的转移,原本的资源直接释放
// 而实现unique_prt的核心就是“禁用拷贝,启用移动”
#include <iostream>
template <typename T>
class UniquePtr{
private:
T* _ptr;
public:
// 1. 构造时接管内存
// explicit 禁止构造函数自动转换类型
explicit UniquePtr(T* ptr) : _ptr(ptr) { }
// 2. 析构时自动释放内存
~UniquePtr() { delete _ptr; }
// 绝对禁止拷贝构造和赋值重载
// 如果不禁用,两个UniquePtr内部的_ptr指向同一块内存,会析构两次
UniquePtr(const UniquePtr&) = delete;
UniquePtr& operator=(const UniquePtr&) = delete;
// 4. 允许移动构造和移动赋值:转移资源所有全
// noexcept 明确告诉编译器,整个函数绝对不会抛异常
// 举个例子理解这两个函数
// 一个指针管理一台车
// 场景A有车钥匙,现在要把车过户给B
UniquePtr(UniquePtr&& other) noexcept
:_ptr(other._ptr) // B拿到了A的车钥匙
{
// 必须把A的钥匙拿走
other._ptr = nullptr; // 把原对象的所有权置空,防止析构时释放内存
}
// A有一台保时捷,B原本就有一台奥迪,现在A把保时捷过户给B
UniquePtr& operator=(UniquePtr&& other) noexcept{
if(this != &other) { // 自己过户给自己,啥也不干
delete _ptr; // 先释放自身原有资源,B得先把原来奥迪报废掉,不然停在车库没人管(内存泄漏)
_ptr = other._ptr; // 接管新资源,B拿到A的钥匙
other._ptr = nullptr; // 没收A的钥匙
}
return *this;
}
// 模拟指针行为
// 解引用* 拿实体,要的是指针指向的那个东西本身
T& operator*() const { return *_ptr; }
// 成员访问符-> 拿动作,要调用整个对象的成员
T* operator-> const { return _ptr; }
};
只要资源不需要共享,默认首选unique_ptr
借用车辆过户的例子理解一下代码思路:
假设有一个指针管理着一辆车,**unique_ptr就像一把唯一的车钥匙
-
移动构造:
A有钥匙,现在把车过户给B
- B先拿到钥匙,此时两把钥匙指向同一辆车
- 必须把A兜里的钥匙没收,如果不没收,A和B都有钥匙,等A和B析构时,都会去报废delete这台车,引起Double Free
-
移动赋值:
A有保时捷,B原本有台奥迪,现在A把保时捷过户给B
- 首先要防止A给自己过户,那就是啥也没干
- B先要把自己原来的奥迪报废掉!不然奥迪就永远停在车库里没人管了(内存泄漏)
- B拿到A的保时捷钥匙
- 没收A的钥匙
借用看电视的例子理解模拟指针行为:
假设你有一台电视(对象T,存在堆上),你手里拿着遥控器(智能指针对象,存在栈上),遥控器内部有红外发射芯片(裸指针)记录了电视的位置
你操作电视,两个动作对应两个重载:
- 搬动电视本身
- 按遥控器让电视执行动作
现在讲解两行容易搞晕的函数:
- T& operator*() const { return *_ptr; }
- operator*重载解引用:我要电视本体,我要搬动电视本身
- _ptr只是红外芯片(一个地址),要通过*解引用顺着地址找到实体,所以返回*_ptr
- 为什么是T&,我操作的是电视本体(引用只是给电视起别名),如果不加引用,就会拷贝一个新电视,我们搬动的就不是电视本体了,如果返回T*那就是返回电视的地址,违反初衷
- T* operator-> const { return _ptr; }
- operator->重载成员访问符:我要通过遥控器发送指令,调整电视功能
- _ptr:这时候只需要地址,不需要通过地址找到本体
- T*:C++语法糖硬性规定:
- 假设p是遥控器对象,Channel()是电视的功能
- 当写下p->Channel(),这时会发现,遥控器对象并没有这个功能啊,是电视机有
- 编译器底层会翻译成:( p.operator() ) -> Channel()这个哈桑念书必须给出一个真实的红外芯片(裸指针T*),编译器才能用裸指针调用后面的函数
手撕shared_ptr
代码
- 本质定义:代表“所有权共享”。类比大学宿舍,大家都有钥匙,最后退宿的人负责锁门并归还最后一把钥匙,这是通过外部维护一块“引用计数”实现的
- 实现核心:提取公共的引用计数模块,确保所有拷贝的智能指针都能看到同一个计数器
#pragma once
// 共享所有权,大学宿舍,大家都有钥匙,
// 最后退宿的人负责锁门并归还钥匙
// 外部维护一块引用计数实现
// 核心:提取公共的引用计数块,确保所有拷贝的智能指针都能看到同一个计数器
#include <iostream>
template <typename T>
class SharedPtr {
private:
T* _ptr;
int* _ref_cnt; // 必须分配到堆上,让所有副本共享同一块计数内存
// 封装释放逻辑
void release() {
if(_ref_cnt) (*_ref_cnt)—;
if(*_ref_cnt == 0) {
delete _ptr;
delete _ref_cnt;
}
}
public:
// 1. 构造:分配资源和计数器
explicit SharedPtr(T* ptr = nullptr)
:_ptr(ptr)
{
if(_ptr) _ref_cnt = new int(1);
else _ref_cnt = nullptr;
}
// 2. 拷贝构造:共享资源,计数+1
SharedPtr(const SharedPtr& other)
:_ptr(other._ptr)
,_ref_cnt(other._ref_cnt)
{
if(_ref_cnt) (*_ref_cnt)++;
}
// 3. 赋值重载
SharedPtr& operator=(const SharedPtr& other) {
if(this != &other) {
release(); // 释放自身指向的旧资源:旧计数-1
_ptr = other._ptr;
_ref_cnt = other._ref_cnt;
if(_ref_cnt) (*_ref_cnt)++; // 新计数+1
}
return *this;
}
// 4. 析构:最后一个人还钥匙
~SharedPtr() { release(); }
T* operator->() const { return _ptr; }
T& operator*() const { return *_ptr; }
};
灵魂拷问:shared_ptr是线程安全的吗?
对于任何包含两个或更多状态变量的复合对象,只要不能用单条CPU指令完成覆盖,它就天然不是线程安全的
控制的引用计数操作是线程安全的:
- 多个线程同时对各自的shared_ptr副本进行拷贝或析构时,底层的ref_cnt++/–是通过std:atomic原子操作实现的
管理业务对象是非线程安全的:
- 智能指针只管大门钥匙,不管屋内家具,如果线程A和线程B同时通过各自的shared_ptr去修改里面的桌子desk对象的数量,这就产生数据竞争了,需要在业务层对修改对象操作加锁
自身赋值非线程安全:
- SharedPtr 内部有两个独立的指针(_ptr 和 _ref_cnt)。由于它们加起来占 16 个字节(64 位),CPU 无法用一条原生原子指令同时替换这两个指针。这就必然导致**“状态撕裂”**:
- 线程A修改完指针后没来得及更新计数器被挂起
- 此时对象处于“新数据+老计数器”的撕裂状态
- 线程B读取畸形对象,导致计数错乱或释放错误堆内存
啥是原子操作?
“原子”在计算机里意味着“不可分割的最小执行单元”
一个原子操作,要么完全执行,要么根本没执行,绝对不会有执行一半被人打断的中间态
看代码:(*_ref_cnt)++,在高级语言里它只是一行代码,但在 CPU 底层,它是三个独立的动作(Read-Modify-Write):
并发灾难:假设计数器当前是 1。线程 A 读到了 1,还没来得及加;此时系统把 CPU 抢走给了线程 B,线程 B 也读到了 1,加 1 后写回 2。接着线程 A 恢复执行,在自己原来 1 的基础上加 1,也写回 2。 结果:两次 ++,结果本该是 3,现在却是 2。这就导致未来必定会发生内存泄漏
解决shared_ptr循环引用问题
代码
#include <iostream>
#include <memory>
struct LeakB;
struct LeakA {
std::shared_ptr<LeakB> _ptr2B;
LeakA() { std::cout << "LeakA 构造\\n"; }
~LeakA() { std::cout << "LeakA 析构\\n"; }
};
struct LeakB {
std::shared_ptr<LeakA> _ptr2A;
LeakB() { std::cout << "LeakB 构造\\n"; }
~LeakB() { std::cout << "LeakB 析构\\n"; }
};
void badFunc() {
std::cout << "\\n测试一开始,全是shared_ptr的循环引用 <<<\\n";
{
std::shared_ptr<LeakA> a(new LeakA()); // A的计数=1
std::shared_ptr<LeakB> b(new LeakB()); // B的计数=1
a->_ptr2B = b; // B的计数变2
b->_ptr2A = a; // A的计数变2
} // 离开作用域,泄漏了
std::cout << ">>> 测试一结束,看到析构了吗?没有!内存泄露了!\\n";
}
struct SafeB;
struct SafeA {
std::shared_ptr<SafeB> _ptr2b; // A强引用B
SafeA() { std::cout << "SafeA 构造\\n"; }
~SafeA() { std::cout << "SafeA 析构\\n"; }
};
struct SafeB {
std::weak_ptr<SafeA> _ptr2a; // B弱引用A,B只是看着A,不增加计数
SafeB() { std::cout << "SafeB 构造\\n"; }
~SafeB() { std::cout << "SafeB 析构\\n"; }
};
void goodFunc() {
std::cout << "\\n测试二开始,引入weak_ptr <<<\\n";
{
std::shared_ptr<SafeA> a(new SafeA()); // A的计数=1
std::shared_ptr<SafeB> b(new SafeB()); // B的计数=1
a->_ptr2b = b; // B的计数变2
b->_ptr2a = a; // A的计数不变
} // 离开作用域,安全回收
std::cout << ">>> 测试二结束,触发析构,内存清空\\n";
}
int main() {
badFunc();
goodFunc();
return 0;
}
借助案例理解循环引用
- 互相死锁->全是shared_ptr
- shared_ptr是强硬绑定(同生共死)
- 场景:假设LeakA是家公司,LeakB是公司里唯一员工
- 公司A规定:只要我还有一个员工(B)在,我就绝对不注销(A强引用B)
- 员工B想:只要公司(A)没注销,我就绝不离职(B强引用A)
- 结果:老板想要注销(离开作用域),结果公司看着员工,员工看着公司,没人愿意迈开第一步,内存泄露了,资源永远卡在内存里
- shared_ptr主导weak_ptr旁观
- weak_ptr代表默默观察,不负责你的生死
- 场景:合理架构是要明确主次
- 公司SafeA掌握绝对控制权:我有员工SafeB,我不倒闭,员工就有饭吃(A强引用B)
- 员工SafeB清醒了:我只是打工的,公司SafeA和我有啥关系,我只负责看公司还在不在(B弱引用A)
- 结果:老板想注销(离开作用域)公司注销了,员工看公司跑路了,自己也就走了
C++提供的库函数
std::unique_ptr(独占指针)
- 本质:专属所有权,保证同一时刻只有自己指向那块堆内存,生命周期和栈作用域严格绑定
- 怎么使用:
- **禁止拷贝,可以移动:**严禁传值调用,如果想要转移所有权,必须显式调用std::move()
- 规范:使用std::make_unique<T>()创建
- 使用场景:独占的资源管理(文件、网络连接)
std::shared_ptr(共享指针)
- 本质:共享所有权,通过堆上的引用计数,让多个指针对同一块内存共同负责,最后一个人负责释放内存
- 怎么使用:
- 规范:使用std::make_shared<T>()创建
- 解决循环引用问题配合weak_ptr
- 使用场景:生命周期开度不可控的场景,例如把对象丢进线程池,不知道什么时候执行完、多个模块共享一份只读数据
weak_ptr(弱指针)
- 本质:无所有权的旁观者,是shared_ptr的辅助工具,只观察存活状态,不干预生命周期,不增加引用计数
- 怎么使用:
- 解决std::shared_ptr循环引用问题
- 使用场景:专治 shared_ptr 的循环引用死锁、
总结

done~



